From 6d7205625a381618aad4946d3d2c64ad3b6f2349 Mon Sep 17 00:00:00 2001 From: Dumitru Date: Sat, 13 Dec 2025 23:18:47 +0100 Subject: [PATCH 01/23] Add iosX64 target --- quartz/build.gradle.kts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index c5b947c87..53f07e3c6 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -74,6 +74,13 @@ kotlin { // https://developer.android.com/kotlin/multiplatform/migrate val xcfName = "quartz-kmpKit" + + iosX64 { + binaries.framework { + baseName = xcfName + } + } + iosArm64 { binaries.framework { baseName = xcfName @@ -208,6 +215,10 @@ kotlin { } } + val iosX64Main by getting { + dependsOn(iosMain.get()) // iosX64Main depends on iosMain + } + val iosArm64Main by getting { dependsOn(iosMain.get()) // iosArm64Main depends on iosMain } @@ -223,12 +234,16 @@ kotlin { } } + val iosX64MainTest by getting { + dependsOn(iosTest.get()) // iosX64MainTest depends on iosTest + } + val iosArm64Test by getting { - dependsOn(iosTest.get()) // iosArm64Main depends on iosMain + dependsOn(iosTest.get()) // iosArm64Test depends on iosTest } val iosSimulatorArm64Test by getting { - dependsOn(iosTest.get()) // iosSimulatorArm64Main depends on iosMain + dependsOn(iosTest.get()) // iosSimulatorArm64Test depends on iosTest } } } @@ -283,4 +298,4 @@ mavenPublishing { connection = "https://github.com/vitorpamplona/amethyst/.git" } } -} \ No newline at end of file +} From 3c06e0ffb18c664bd2d5017ede4d457be03531fe Mon Sep 17 00:00:00 2001 From: Dumitru Date: Sat, 13 Dec 2025 23:40:55 +0100 Subject: [PATCH 02/23] Fix typo iosX64MainTest to iosX64Test --- quartz/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 53f07e3c6..a9f1d39e6 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -234,8 +234,8 @@ kotlin { } } - val iosX64MainTest by getting { - dependsOn(iosTest.get()) // iosX64MainTest depends on iosTest + val iosX64Test by getting { + dependsOn(iosTest.get()) // iosX64Test depends on iosTest } val iosArm64Test by getting { From 49ec21c2432441c17104f2873a52fcecd8e76d5e Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 14 Dec 2025 01:45:25 +0000 Subject: [PATCH 03/23] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hi-rIN/strings.xml | 1 + amethyst/src/main/res/values-pl-rPL/strings.xml | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 73c5573db..d323a304d 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -21,6 +21,7 @@ अवैध बरताव अन्य उत्पीडन + हिंसा अज्ञात पनःप्रसारक चिह्न अज्ञात लेखक diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index cdad165fb..74edb8bcc 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -21,6 +21,7 @@ Antyspołeczne zachowanie Inne Nękanie + Przemoc Nieznani Ikona transmitera Autor nieznany @@ -60,8 +61,8 @@ Czy aplikacja sygnatariusza została odinstalowana? Sprawdź, czy aplikacja sygnatariusza jest zainstalowana i czy ma to konto. Wyloguj się i zaloguj ponownie, jeśli aplikacja sygnatariusza uległa zmianie. Zapy Liczba wyświetleń - Promuj - promowany + Powtórz + powtórzony edytowano edytuj #%1$s oryginalny From 49517e22640837df338a36a75e27229cf833f1db Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Dec 2025 10:12:15 +0100 Subject: [PATCH 04/23] quartz --- .../quartz/experimental/trustedAssertions/list/TagArrayExt.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt index de6eec972..3d76052a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.experimental.trustedAssertions.list -import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag.Companion.parse import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip01Core.core.TagArray From c2636e6e2f20410931b8fedd4825d7abc1d4c91f Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Dec 2025 10:17:09 +0100 Subject: [PATCH 05/23] amethyst --- .../src/main/java/com/vitorpamplona/amethyst/model/Account.kt | 1 - .../src/main/java/com/vitorpamplona/amethyst/model/User.kt | 1 - .../amethyst/model/nip51Lists/peopleList/FollowListsState.kt | 3 --- .../amethyst/model/nip51Lists/peopleList/PeopleListsState.kt | 1 - .../amethyst/model/topNavFeeds/FeedTopNavFilterState.kt | 1 - .../reqCommand/user/watchers/UserCardsSubAssembler.kt | 2 -- .../amethyst/ui/layouts/listItem/SlimListItemLayout.kt | 2 -- .../amethyst/ui/navigation/drawer/DrawerContent.kt | 2 -- .../com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt | 1 - .../com/vitorpamplona/amethyst/ui/note/types/FollowList.kt | 3 --- .../loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt | 1 - .../bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt | 1 - .../ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt | 1 - .../ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt | 1 - 14 files changed, 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b1e5d6ad4..a9de6b64d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -214,7 +214,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.math.BigDecimal diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index c81a0b755..19ba65b5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -43,7 +43,6 @@ import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.flow.MutableStateFlow import java.math.BigDecimal -import kotlin.plus interface UserDependencies diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 983e623a4..29b5d1f0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -43,9 +43,6 @@ import com.vitorpamplona.quartz.nip51Lists.followList.personFirst import com.vitorpamplona.quartz.nip51Lists.followList.removePerson import com.vitorpamplona.quartz.nip51Lists.followList.title import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag -import com.vitorpamplona.quartz.nip51Lists.peopleList.description -import com.vitorpamplona.quartz.nip51Lists.peopleList.image -import com.vitorpamplona.quartz.nip51Lists.peopleList.name import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt index 7dfd32898..77e6d0a54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip51Lists.followList.description import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.description diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index a9cd1a484..17b3d2bf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index b8879fa77..f8fbd4542 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -33,8 +33,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet -import kotlin.collections.component1 -import kotlin.collections.component2 class UserCardsSubAssembler( client: INostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt index 89dc89d79..deb6f870d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt @@ -20,10 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.layouts.listItem -import android.view.Surface import androidx.annotation.VisibleForTesting import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index baa2930bb..fb16001a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.navigation.drawer -import android.R.attr.fontWeight import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -122,7 +121,6 @@ import com.vitorpamplona.amethyst.ui.theme.bannerModifier import com.vitorpamplona.amethyst.ui.theme.drawerSpacing import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier -import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes.followerCount import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index a30068dbe..9c3ccf5a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.navigation.routes import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute -import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Community import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt index ea1beb2c8..c79454a9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,10 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt index d51cb49de..2bdfbca8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlin.collections.emptyList @Stable class BookmarkGroupViewModel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt index ae744bdcf..2f8680c6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import kotlin.toString @Composable fun NewBookmarkGroupCreationDialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index be8594ff1..86d975767 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 48b57a643..6be7f4903 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -35,7 +35,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color From 9ef9fb4b096e752139aee46fbfd3fca3bc31afca Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 14 Dec 2025 10:20:25 +0100 Subject: [PATCH 06/23] amethyst lint --- .../com/vitorpamplona/amethyst/model/Account.kt | 2 +- .../java/com/vitorpamplona/amethyst/model/User.kt | 2 +- .../model/nip51Lists/peopleList/FollowListsState.kt | 10 +++++----- .../ui/layouts/listItem/SlimListItemLayout.kt | 13 ++++++------- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a9de6b64d..d32ca043b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -601,7 +601,7 @@ class Account( url: String, method: String, body: ByteArray? = null, - ): HTTPAuthorizationEvent? = signer.sign(HTTPAuthorizationEvent.build(url, method, body)) + ): HTTPAuthorizationEvent = signer.sign(HTTPAuthorizationEvent.build(url, method, body)) suspend fun createBlossomUploadAuth( hash: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 19ba65b5e..aa694f42d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -218,7 +218,7 @@ class User( fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size - suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } + fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } fun reportsOrNull(): UserReportCache? = reports diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 29b5d1f0c..59ab65117 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -80,14 +80,14 @@ class FollowListsState( .map { existingPeopleListNotes() } .onStart { emit(existingPeopleListNotes()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) val followListsEventIds = followListNotes .map { it.eventIdSet() } .onStart { emit(followListNotes.value.eventIdSet()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptySet()) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) @OptIn(ExperimentalCoroutinesApi::class) val latestLists: StateFlow> = @@ -104,7 +104,7 @@ class FollowListsState( .map { it.mapToUserIdSet() } .onStart { emit(latestLists.value.mapToUserIdSet()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptySet()) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) fun FollowListEvent.toUI() = PeopleList( @@ -123,7 +123,7 @@ class FollowListsState( .map { it.toUI() } .onStart { emit(latestLists.value.toUI()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) fun List.select(dTag: String) = this.firstOrNull { @@ -139,7 +139,7 @@ class FollowListsState( fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) - fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value) + fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.KIND) || deletesAnyEventIn(followListsEventIds.value) fun hasItemInNoteList(notes: Set): Boolean = notes.anyNotNullEvent { event -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt index deb6f870d..6d2d01c4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -109,13 +108,13 @@ fun ChannelNamePreview() { SlimListItem( headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = CenterVertically) { Text("This is my author", Modifier.weight(1f)) TimeAgo(TimeUtils.now()) } }, supportingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = CenterVertically) { Text("This is a message from this person", Modifier.weight(1f)) Spacer(modifier = Height4dpModifier) NewItemsBubble() @@ -135,13 +134,13 @@ fun ChannelNamePreview() { ListItem( headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = CenterVertically) { Text("This is my author", Modifier.weight(1f)) TimeAgo(TimeUtils.now()) } }, supportingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = CenterVertically) { Text("This is a message from this person", Modifier.weight(1f)) Spacer(modifier = Height4dpModifier) NewItemsBubble() @@ -507,7 +506,7 @@ private class ListItemMeasurePolicy : MultiContentMeasurePolicy { } } -private fun IntrinsicMeasureScope.calculateWidth( +private fun calculateWidth( leadingWidth: Int, trailingWidth: Int, headlineWidth: Int, @@ -644,7 +643,7 @@ private value class ListItemType private constructor( /** Three line list item */ val ThreeLine = ListItemType(3) - internal operator fun invoke( + operator fun invoke( hasOverline: Boolean, hasSupporting: Boolean, isSupportingMultiline: Boolean, From 8028c7b2d4c4356c25fe2ee28df6c09e94b65b6f Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 14 Dec 2025 17:15:06 +0000 Subject: [PATCH 07/23] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 1 + amethyst/src/main/res/values-de-rDE/strings.xml | 1 + amethyst/src/main/res/values-pt-rBR/strings.xml | 1 + amethyst/src/main/res/values-sv-rSE/strings.xml | 1 + 4 files changed, 4 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 39ef191b9..4578cac80 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -21,6 +21,7 @@ Nezákonné jednání Jiný Obtěžování + Násilí Neznámý Ikona předávání Neznámý autor diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 6927c4cf6..02887615c 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -21,6 +21,7 @@ Illegales Verhalten Andere Belästigung + Gewalt Unbekannt Relay-Symbol Unbekannter Autor diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7df7cf9fb..be0152b9b 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -21,6 +21,7 @@ Comportamento ilegal Outro Assédio + Violência Desconhecido Ícone do relay Autor desconhecido diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 41cf307f0..435c7b261 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -21,6 +21,7 @@ Olagligt beteende Annat Trakasseri + Våld Okänd Relä ikon Okänd användare From 6e7c02c5b6f9ca773df60c3e4ebdb0b0e3a242ca Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 15 Dec 2025 11:15:10 +0000 Subject: [PATCH 08/23] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-sl-rSI/strings.xml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index aa75a9622..86b75dcd2 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -21,6 +21,7 @@ Nedovoljeno vedenje Drugo Nadlegovanje + Nasilje Nepoznano Ikona releja Avtor neznan @@ -41,7 +42,7 @@ Zlonamerna programska vsebina Prijavi moderatorja Zlonamerna programska vsebina - Administrator + Moderator Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, da omogočiš tudi pisanje Uporabljaš javni ključ in javni ključi omogočajo le branje. @@ -153,8 +154,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem URL pasice URL spletne strani Zaimki - LN Naslov - Zastarel LN (lightning) URL + Lightning naslov (LUD-16) + Zastarel lightning naslov (LUD-06) Shrani v galerijo Slika shranjena v foto galerijo telefona Prenos videa se je začel… @@ -401,6 +402,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Dodaj kot zasebni zaznamek Odstrani iz seznama zaznamkov Metapodatki seznama zaznamkov so vidni vsem na Nostru. Le tvoji zasebni člani so šifrirani. + Premakni v javno + Premakni v zasebno Storitev Wallet Connect Pooblasti Nostr skrivnost (Nostr secret) za plačevanje z Zapi brez zapuščanja aplikacije. Nostr skrivnost (Nostr secret) hranite na varnem in, če je mogoče, uporabite zasebni rele Wallet Connect javni ključ From 8ebd76b77b6a23d7248e4c188a5974f3c0e680f8 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 17:10:30 +0100 Subject: [PATCH 09/23] add a record voice button to new post screen --- .../ui/actions/uploads/RecordVoiceButton.kt | 49 +++++++ .../loggedIn/home/ShortNotePostScreen.kt | 20 ++- .../loggedIn/home/ShortNotePostViewModel.kt | 125 +++++++++++++++++- 3 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt new file mode 100644 index 000000000..3d3198311 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -0,0 +1,49 @@ +/** + * 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.ui.actions.uploads + +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { + RecordAudioBox( + modifier = Modifier, + onRecordTaken = { recording -> + onVoiceTaken(recording) + }, + ) { isRecording -> + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + modifier = Modifier.height(22.dp), + tint = if (isRecording) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 581d6825d..6617d5b1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -405,12 +406,16 @@ private fun NewPostScreenBody( ) } - BottomRowActions(postViewModel) + BottomRowActions(postViewModel, accountViewModel) } } @Composable -private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { +private fun BottomRowActions( + postViewModel: ShortNotePostViewModel, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current val scrollState = rememberScrollState() Row( modifier = @@ -440,6 +445,17 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { }, ) + RecordVoiceButton( + onVoiceTaken = { recording -> + postViewModel.selectVoiceRecording(recording) + postViewModel.uploadVoiceMessage( + accountViewModel.account.settings.defaultFileServer, + accountViewModel.toastManager::toast, + context, + ) + }, + ) + if (postViewModel.canUsePoll) { // These should be hashtag recommendations the user selects in the future. // val hashtag = stringRes(R.string.poll_hashtag) 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 bb07d2c19..624a999e5 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 @@ -42,11 +42,14 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -116,6 +119,9 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers @@ -177,6 +183,11 @@ open class ShortNotePostViewModel : // Images and Videos var multiOrchestrator by mutableStateOf(null) + // Voice Messages + var voiceRecording by mutableStateOf(null) + var isUploadingVoice by mutableStateOf(false) + var voiceMetadata by mutableStateOf(null) + // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) @@ -504,6 +515,23 @@ open class ShortNotePostViewModel : } private suspend fun createTemplate(): EventTemplate? { + // Check if this is a voice message + voiceMetadata?.let { audioMeta -> + return if (originalNote != null) { + // Create voice reply event + @Suppress("UNCHECKED_CAST") + VoiceReplyEvent.build( + voiceMessage = audioMeta, + replyingTo = originalNote!!.toEventHint() as com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle, + ) + } else { + // Create root voice event + VoiceEvent.build( + voiceMessage = audioMeta, + ) + } + } + val tagger = NewMessageTagger( message.text, @@ -715,6 +743,9 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false + voiceRecording = null + isUploadingVoice = false + voiceMetadata = null pTags = null wantsPoll = false @@ -833,9 +864,16 @@ open class ShortNotePostViewModel : private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) - fun canPost(): Boolean = - message.text.isNotBlank() && + fun canPost(): Boolean { + // Voice messages can be posted without text + if (voiceMetadata != null) { + return !isUploadingVoice && !isUploadingImage + } + + // Regular text/media posts require text + return message.text.isNotBlank() && !isUploadingImage && + !isUploadingVoice && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && ( @@ -846,7 +884,9 @@ open class ShortNotePostViewModel : isValidValueMaximum.value ) ) && - multiOrchestrator == null + multiOrchestrator == null && + voiceRecording == null + } fun insertAtCursor(newElement: String) { message = message.insertUrlAtCursor(newElement) @@ -856,6 +896,85 @@ open class ShortNotePostViewModel : multiOrchestrator = MultiOrchestrator(uris) } + fun selectVoiceRecording(recording: RecordingResult) { + voiceRecording = recording + } + + fun uploadVoiceMessage( + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + val recording = voiceRecording ?: return + + viewModelScope.launch(Dispatchers.IO) { + isUploadingVoice = true + + try { + val uri = android.net.Uri.fromFile(recording.file) + val orchestrator = UploadOrchestrator() + + val result = + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = account, + context = context, + useH265 = false, + ) + + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + voiceMetadata = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + voiceRecording = null + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + // For NIP95, we need to create the event and get the nevent URL + // This is handled differently - skip for now + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "NIP95 not yet supported for voice messages", + ) + } + } + } + is UploadingState.Error -> { + val errorMessage = stringRes(context, result.errorResource, *result.params) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessage) + voiceRecording = null + } + else -> { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "Unexpected upload state", + ) + } + } + } catch (e: Exception) { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + e.message ?: e.javaClass.simpleName, + ) + voiceRecording = null + } finally { + isUploadingVoice = false + } + } + } + override fun locationFlow(): StateFlow { if (location == null) { location = locationManager().geohashStateFlow From c0d7afe86c0c286c7fc4f71e9aa98d6e7f250aa9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 17:24:45 +0100 Subject: [PATCH 10/23] fix potential race condition with previous recorder in method stop --- .../ui/actions/uploads/VoiceMessageRecorder.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt index 5c5115eae..6e5323cc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.content.Context import android.media.MediaRecorder import android.os.Build +import android.util.Log import androidx.media3.common.MimeTypes import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils @@ -53,7 +54,7 @@ class VoiceMessageRecorder { MediaRecorder() } - suspend fun start( + fun start( context: Context, scope: CoroutineScope, ) { @@ -88,8 +89,15 @@ class VoiceMessageRecorder { } } - suspend fun stop(): RecordingResult? { - recorder?.stop() + fun stop(): RecordingResult? { + job?.cancel() + job = null + + try { + recorder?.stop() + } catch (e: RuntimeException) { + Log.w("VoiceMessageRecorder", "Failed to stop recording... Too short?", e) + } recorder?.reset() recorder = null val currentTime = TimeUtils.now() From f3fea8cfb4c42972d6fe9dc4186d5845355d6b88 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 18:05:53 +0100 Subject: [PATCH 11/23] Flow: Record, preview, then post or cancel Added media server selection for upload Added recording indicator Added upload progress Text input disabled for voice messages --- .../ui/actions/uploads/RecordAudio.kt | 24 +- .../ui/actions/uploads/RecordVoiceButton.kt | 76 +++++- .../ui/actions/uploads/RecordingIndicators.kt | 248 ++++++++++++++++++ .../ui/actions/uploads/VoiceMessagePreview.kt | 219 ++++++++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 2 +- .../loggedIn/home/ShortNotePostScreen.kt | 155 +++++++++-- .../loggedIn/home/ShortNotePostViewModel.kt | 154 ++++++----- 7 files changed, 784 insertions(+), 94 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 889a29a9b..1d8f2ff23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -24,9 +24,12 @@ import android.Manifest import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -35,16 +38,30 @@ import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.delay @OptIn(ExperimentalPermissionsApi::class) @Composable fun RecordAudioBox( modifier: Modifier, onRecordTaken: (RecordingResult) -> Unit, - content: @Composable (Boolean) -> Unit, + content: @Composable (Boolean, Int) -> Unit, ) { val mediaRecorder = remember { mutableStateOf(null) } val context = LocalContext.current + var elapsedSeconds by remember { mutableIntStateOf(0) } + + // Track elapsed time while recording + LaunchedEffect(mediaRecorder.value) { + if (mediaRecorder.value != null) { + while (mediaRecorder.value != null) { + delay(1000) + elapsedSeconds++ + } + } else { + elapsedSeconds = 0 + } + } ClickAndHoldBoxComposable( modifier = modifier, @@ -55,6 +72,7 @@ fun RecordAudioBox( if (!recordPermissionState.status.isGranted) { recordPermissionState.launchPermissionRequest() } else { + elapsedSeconds = 0 mediaRecorder.value = VoiceMessageRecorder() mediaRecorder.value?.start(context, scope) } @@ -62,6 +80,7 @@ fun RecordAudioBox( }, onRelease = { val result = mediaRecorder.value?.stop() + mediaRecorder.value = null if (result != null) { onRecordTaken(result) } else { @@ -76,7 +95,8 @@ fun RecordAudioBox( }, onCancel = { mediaRecorder.value?.stop() + mediaRecorder.value = null }, - content, + content = @Composable { isRecording -> content(isRecording, elapsedSeconds) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index 3d3198311..f390eea3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -20,12 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.actions.uploads +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -33,17 +42,62 @@ import com.vitorpamplona.amethyst.ui.stringRes @Composable fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { - RecordAudioBox( - modifier = Modifier, - onRecordTaken = { recording -> - onVoiceTaken(recording) - }, - ) { isRecording -> - Icon( - imageVector = Icons.Default.Mic, - contentDescription = stringRes(id = R.string.record_a_message), - modifier = Modifier.height(22.dp), - tint = if (isRecording) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + var isRecording by remember { mutableStateOf(false) } + var elapsedSeconds by remember { mutableIntStateOf(0) } + + Column { + // Floating recording indicator at the top + FloatingRecordingIndicator( + modifier = Modifier.height(50.dp), + isRecording = isRecording, + elapsedSeconds = elapsedSeconds, + ) + + RecordAudioBox( + modifier = Modifier, + onRecordTaken = { recording -> + isRecording = false + elapsedSeconds = 0 + onVoiceTaken(recording) + }, + ) { recordingState, elapsed -> + // Update parent state to trigger recompositions + if (isRecording != recordingState) { + isRecording = recordingState + } + if (elapsedSeconds != elapsed) { + elapsedSeconds = elapsed + } + + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + // Expanding circles background animation + ExpandingCirclesAnimation( + modifier = Modifier.size(48.dp), + isRecording = recordingState, + primaryColor = MaterialTheme.colorScheme.primary, + ) + + // Microphone icon + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + modifier = Modifier.height(22.dp), + tint = + if (recordingState) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } + } + + // Empty space at the bottom for layout balance + Box( + modifier = Modifier.height(50.dp), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt new file mode 100644 index 000000000..ae2c25aab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -0,0 +1,248 @@ +/** + * 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.ui.actions.uploads + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Animated expanding circles that pulse outward from the recording button + */ +@Composable +fun ExpandingCirclesAnimation( + modifier: Modifier = Modifier, + isRecording: Boolean, + primaryColor: Color = MaterialTheme.colorScheme.primary, +) { + val infiniteTransition = rememberInfiniteTransition(label = "expanding_circles") + + // First circle animation + val scale1 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle1_scale", + ) + + val alpha1 by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle1_alpha", + ) + + // Second circle animation (offset by 500ms) + val scale2 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle2_scale", + ) + + val alpha2 by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle2_alpha", + ) + + // Third circle animation (offset by 1000ms) + val scale3 by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 2.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle3_scale", + ) + + val alpha3 by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "circle3_alpha", + ) + + if (!isRecording) return + + Layout( + modifier = modifier, + content = { + // Circle 1 + Box( + modifier = + Modifier + .scale(scale1) + .alpha(alpha1) + .background(primaryColor.copy(alpha = 0.3f), CircleShape), + ) + + // Circle 2 + Box( + modifier = + Modifier + .scale(scale2) + .alpha(alpha2) + .background(primaryColor.copy(alpha = 0.2f), CircleShape), + ) + + // Circle 3 + Box( + modifier = + Modifier + .scale(scale3) + .alpha(alpha3) + .background(primaryColor.copy(alpha = 0.1f), CircleShape), + ) + }, + ) { measurables, constraints -> + // All circles are centered at the same position + val placeables = measurables.map { it.measure(constraints) } + layout(constraints.maxWidth, constraints.maxHeight) { + placeables.forEach { placeable -> + placeable.placeRelative( + x = (constraints.maxWidth - placeable.width) / 2, + y = (constraints.maxHeight - placeable.height) / 2, + ) + } + } + } +} + +/** + * Floating recording indicator showing elapsed time + */ +@Composable +fun FloatingRecordingIndicator( + modifier: Modifier = Modifier, + isRecording: Boolean, + elapsedSeconds: Int, +) { + if (!isRecording) return + + Box( + modifier = + modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 16.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + // Pulsing red dot + val infiniteTransition = rememberInfiniteTransition(label = "recording_dot") + val dotAlpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000), + repeatMode = RepeatMode.Reverse, + ), + label = "dot_alpha", + ) + + Icon( + imageVector = Icons.Default.FiberManualRecord, + contentDescription = "Recording", + tint = Color.White, + modifier = + Modifier + .alpha(dotAlpha) + .padding(end = 8.dp), + ) + + Text( + text = "Recording ${formatSeconds(elapsedSeconds)}", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} + +private fun formatSeconds(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return if (minutes > 0) { + String.format("%d:%02d", minutes, secs) + } else { + String.format("0:%02d", secs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt new file mode 100644 index 000000000..bff7e3efb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -0,0 +1,219 @@ +/** + * 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.ui.actions.uploads + +import android.content.Context +import android.media.MediaPlayer +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import java.io.File + +@Composable +fun VoiceMessagePreview( + voiceMetadata: AudioMeta, + localFile: File? = null, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var isPlaying by remember { mutableStateOf(false) } + var progress by remember { mutableFloatStateOf(0f) } + var mediaPlayer by remember { mutableStateOf(null) } + + // Initialize MediaPlayer + DisposableEffect(voiceMetadata.url, localFile) { + val player = createMediaPlayer(context, voiceMetadata.url, localFile) + mediaPlayer = player + + onDispose { + player?.release() + mediaPlayer = null + isPlaying = false + } + } + + // Update progress while playing + LaunchedEffect(isPlaying) { + if (isPlaying && mediaPlayer != null) { + while (isActive && isPlaying) { + val player = mediaPlayer + if (player != null && player.isPlaying) { + val current = player.currentPosition.toFloat() + val duration = player.duration.toFloat() + progress = if (duration > 0) current / duration else 0f + delay(100) + } else { + isPlaying = false + progress = 0f + } + } + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + ).padding(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Play/Pause Button + IconButton( + onClick = { + mediaPlayer?.let { player -> + if (isPlaying) { + player.pause() + isPlaying = false + } else { + if (progress >= 1f) { + player.seekTo(0) + progress = 0f + } + player.start() + isPlaying = true + } + } + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Waveform and Duration + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + AudioWaveformReadOnly( + amplitudes = voiceMetadata.waveform ?: emptyList(), + progress = progress, + waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), + progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), + onProgressChange = { newProgress -> + mediaPlayer?.let { player -> + val newPosition = (newProgress * player.duration).toInt() + player.seekTo(newPosition) + progress = newProgress + } + }, + ) + + Text( + text = formatDuration(voiceMetadata.duration ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Remove Button + IconButton( + onClick = onRemove, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringRes(context, R.string.remove), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun createMediaPlayer( + context: Context, + url: String, + localFile: File?, +): MediaPlayer? = + try { + MediaPlayer().apply { + if (localFile != null && localFile.exists()) { + setDataSource(localFile.absolutePath) + } else { + setDataSource(url) + } + prepare() + setOnCompletionListener { + // Reset to beginning when playback completes + seekTo(0) + } + } + } catch (e: Exception) { + null + } + +private fun formatDuration(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return String.format("%d:%02d", minutes, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index bd8d3d7f4..716d0191f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -609,7 +609,7 @@ fun ReplyViaVoiceReaction( onRecordTaken = { audio -> accountViewModel.sendVoiceReply(baseNote, audio, context) }, - ) { + ) { _, _ -> VoiceReplyIcon(iconSizeModifier, grayTint) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 6617d5b1f..09b288a3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -24,7 +24,10 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -35,33 +38,47 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -94,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -261,18 +279,21 @@ private fun NewPostScreenBody( } } - Row( - modifier = Modifier.padding(vertical = Size10dp), - ) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) - MessageField( - R.string.what_s_on_your_mind, - postViewModel, - ) + // Only show text input if no voice message is being posted + if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { + Row( + modifier = Modifier.padding(vertical = Size10dp), + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + MessageField( + R.string.what_s_on_your_mind, + postViewModel, + ) + } } if (postViewModel.wantsPoll) { @@ -342,6 +363,58 @@ private fun NewPostScreenBody( } } + // Show preview for both uploaded messages (voiceMetadata) and pending recordings + (postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata -> + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) + val fileServersState = + accountViewModel.account.serverLists.liveServerList + .collectAsState() + val fileServers = fileServersState.value + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) + } + }.toImmutableList() + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = Size5dp, horizontal = Size10dp), + ) { + // Display voice preview or uploading progress + postViewModel.voiceOrchestrator?.let { orchestrator -> + VoiceUploadingProgress(orchestrator) + } ?: run { + VoiceMessagePreview( + voiceMetadata = metadata, + localFile = postViewModel.voiceLocalFile, + onRemove = { postViewModel.removeVoiceMessage() }, + ) + } + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = + fileServers + .firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } + ?.name + ?: fileServers[0].name, + options = fileServerOptions, + onSelect = { postViewModel.voiceSelectedServer = fileServers[it] }, + ) + } + } + } + if (postViewModel.wantsInvoice) { postViewModel.lnAddress()?.let { lud16 -> InvoiceRequest( @@ -448,11 +521,6 @@ private fun BottomRowActions( RecordVoiceButton( onVoiceTaken = { recording -> postViewModel.selectVoiceRecording(recording) - postViewModel.uploadVoiceMessage( - accountViewModel.account.settings.defaultFileServer, - accountViewModel.toastManager::toast, - context, - ) }, ) @@ -520,3 +588,56 @@ private fun AddPollButton( } } } + +@Composable +private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { + val progressValue = orchestrator.progress.collectAsState().value + val progressStatusValue = orchestrator.progressState.collectAsState().value + + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + Box( + modifier = Modifier.size(55.dp), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + val animatedProgress = + animateFloatAsState( + targetValue = progressValue.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ).value + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressStatusValue) { + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} 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 624a999e5..2a243073b 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 @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing @@ -185,8 +186,11 @@ open class ShortNotePostViewModel : // Voice Messages var voiceRecording by mutableStateOf(null) + var voiceLocalFile by mutableStateOf(null) var isUploadingVoice by mutableStateOf(false) var voiceMetadata by mutableStateOf(null) + var voiceSelectedServer by mutableStateOf(null) + var voiceOrchestrator by mutableStateOf(null) // Polls var canUsePoll by mutableStateOf(false) @@ -477,6 +481,19 @@ open class ShortNotePostViewModel : } suspend fun sendPostSync() { + // Upload voice message first if it hasn't been uploaded yet + if (voiceRecording != null && voiceMetadata == null) { + val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer + uploadVoiceMessageSync( + serverToUse, + { _, _ -> }, // Ignore errors during sync upload before post + ) + // Update default server if voice message was successfully uploaded + if (voiceMetadata != null && voiceSelectedServer != null && voiceSelectedServer?.type != ServerType.NIP95) { + account.settings.changeDefaultFileServer(voiceSelectedServer!!) + } + } + val template = createTemplate() ?: return val extraNotesToBroadcast = mutableListOf() @@ -744,8 +761,11 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false voiceRecording = null + voiceLocalFile = null isUploadingVoice = false voiceMetadata = null + voiceSelectedServer = null + voiceOrchestrator = null pTags = null wantsPoll = false @@ -865,8 +885,8 @@ open class ShortNotePostViewModel : private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) fun canPost(): Boolean { - // Voice messages can be posted without text - if (voiceMetadata != null) { + // Voice messages can be posted without text (with either uploaded or pending recording) + if (voiceMetadata != null || voiceRecording != null) { return !isUploadingVoice && !isUploadingImage } @@ -884,8 +904,7 @@ open class ShortNotePostViewModel : isValidValueMaximum.value ) ) && - multiOrchestrator == null && - voiceRecording == null + multiOrchestrator == null } fun insertAtCursor(newElement: String) { @@ -898,80 +917,89 @@ open class ShortNotePostViewModel : fun selectVoiceRecording(recording: RecordingResult) { voiceRecording = recording + voiceLocalFile = recording.file } - fun uploadVoiceMessage( + fun getVoicePreviewMetadata(): AudioMeta? = + voiceRecording?.let { recording -> + AudioMeta( + url = "", // Empty URL for preview (local file will be used) + mimeType = recording.mimeType, + duration = recording.duration, + waveform = recording.amplitudes, + ) + } + + fun removeVoiceMessage() { + voiceRecording = null + voiceLocalFile = null + voiceMetadata = null + voiceSelectedServer = null + isUploadingVoice = false + voiceOrchestrator = null + } + + suspend fun uploadVoiceMessageSync( server: ServerName, onError: (title: String, message: String) -> Unit, - context: Context, ) { val recording = voiceRecording ?: return - viewModelScope.launch(Dispatchers.IO) { - isUploadingVoice = true + isUploadingVoice = true - try { - val uri = android.net.Uri.fromFile(recording.file) - val orchestrator = UploadOrchestrator() + try { + val uri = android.net.Uri.fromFile(recording.file) + val orchestrator = UploadOrchestrator() + voiceOrchestrator = orchestrator - val result = - orchestrator.upload( - uri = uri, - mimeType = recording.mimeType, - alt = null, - contentWarningReason = null, - compressionQuality = CompressorQuality.UNCOMPRESSED, - server = server, - account = account, - context = context, - useH265 = false, - ) + val result = + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = account, + context = Amethyst.instance.appContext, + useH265 = false, + ) - when (result) { - is UploadingState.Finished -> { - when (val orchestratorResult = result.result) { - is UploadOrchestrator.OrchestratorResult.ServerResult -> { - voiceMetadata = - AudioMeta( - url = orchestratorResult.url, - mimeType = recording.mimeType, - hash = orchestratorResult.fileHeader.hash, - duration = recording.duration, - waveform = recording.amplitudes, - ) - voiceRecording = null - } - is UploadOrchestrator.OrchestratorResult.NIP95Result -> { - // For NIP95, we need to create the event and get the nevent URL - // This is handled differently - skip for now - onError( - stringRes(context, R.string.failed_to_upload_media_no_details), - "NIP95 not yet supported for voice messages", + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + voiceMetadata = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, ) - } + voiceRecording = null + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + // For NIP95, we need to create the event and get the nevent URL + // This is handled differently - skip for now + onError("Upload Error", "NIP95 not yet supported for voice messages") } } - is UploadingState.Error -> { - val errorMessage = stringRes(context, result.errorResource, *result.params) - onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessage) - voiceRecording = null - } - else -> { - onError( - stringRes(context, R.string.failed_to_upload_media_no_details), - "Unexpected upload state", - ) - } } - } catch (e: Exception) { - onError( - stringRes(context, R.string.failed_to_upload_media_no_details), - e.message ?: e.javaClass.simpleName, - ) - voiceRecording = null - } finally { - isUploadingVoice = false + is UploadingState.Error -> { + onError("Upload Error", "Failed to upload voice message") + voiceRecording = null + } + else -> { + onError("Upload Error", "Unexpected upload state") + } } + } catch (e: Exception) { + onError("Upload Error", e.message ?: e.javaClass.simpleName) + voiceRecording = null + } finally { + isUploadingVoice = false + voiceOrchestrator = null } } From 555fc983f6146f56c2f75db604eced045f3ca3d6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 18:35:24 +0100 Subject: [PATCH 12/23] Fix rate conditions between subsequent recordings reduce duplicate code fix a few coding issues (state management, callback usage, scope management) --- .../ui/actions/uploads/RecordAudio.kt | 51 +++++-- .../ui/actions/uploads/RecordVoiceButton.kt | 15 ++- .../ui/actions/uploads/RecordingIndicators.kt | 12 +- .../ui/actions/uploads/TimeFormatUtils.kt | 39 ++++++ .../ui/actions/uploads/VoiceMessagePreview.kt | 113 +++++++++++----- .../actions/uploads/VoiceMessageRecorder.kt | 127 +++++++++++++++--- .../amethyst/ui/components/ClickableBox.kt | 13 +- 7 files changed, 277 insertions(+), 93 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 1d8f2ff23..6d5c7d896 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.Manifest import android.widget.Toast import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -39,6 +40,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -50,15 +52,41 @@ fun RecordAudioBox( val mediaRecorder = remember { mutableStateOf(null) } val context = LocalContext.current var elapsedSeconds by remember { mutableIntStateOf(0) } + var wantsToRecord by remember { mutableStateOf(false) } + + // Must be called at Composable scope, not in callback + val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) + val scope = rememberCoroutineScope() + + DisposableEffect(Unit) { + onDispose { + wantsToRecord = false + mediaRecorder.value?.stop() + mediaRecorder.value = null + } + } + + // Start recording once permission is granted AND user wants to record + LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { + if (recordPermissionState.status.isGranted && wantsToRecord && mediaRecorder.value == null) { + elapsedSeconds = 0 + mediaRecorder.value = VoiceMessageRecorder() + mediaRecorder.value?.start(context, scope) + } + } // Track elapsed time while recording LaunchedEffect(mediaRecorder.value) { - if (mediaRecorder.value != null) { - while (mediaRecorder.value != null) { + // Capture the current recorder state to avoid repeated reads of volatile state + val currentRecorder = mediaRecorder.value + if (currentRecorder != null) { + // Loop while coroutine is active - LaunchedEffect will cancel when mediaRecorder.value changes + while (isActive) { delay(1000) elapsedSeconds++ } } else { + // Reset elapsed time when not recording elapsedSeconds = 0 } } @@ -66,19 +94,17 @@ fun RecordAudioBox( ClickAndHoldBoxComposable( modifier = modifier, onPress = { - val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) - val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { - if (!recordPermissionState.status.isGranted) { - recordPermissionState.launchPermissionRequest() - } else { - elapsedSeconds = 0 - mediaRecorder.value = VoiceMessageRecorder() - mediaRecorder.value?.start(context, scope) - } + wantsToRecord = true + if (!recordPermissionState.status.isGranted) { + recordPermissionState.launchPermissionRequest() + } else if (mediaRecorder.value == null) { + elapsedSeconds = 0 + mediaRecorder.value = VoiceMessageRecorder() + mediaRecorder.value?.start(context, scope) } }, onRelease = { + wantsToRecord = false val result = mediaRecorder.value?.stop() mediaRecorder.value = null if (result != null) { @@ -94,6 +120,7 @@ fun RecordAudioBox( } }, onCancel = { + wantsToRecord = false mediaRecorder.value?.stop() mediaRecorder.value = null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index f390eea3e..9cedeeab6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Mic import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -61,12 +62,14 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { onVoiceTaken(recording) }, ) { recordingState, elapsed -> - // Update parent state to trigger recompositions - if (isRecording != recordingState) { - isRecording = recordingState - } - if (elapsedSeconds != elapsed) { - elapsedSeconds = elapsed + // Update parent state after composition completes + SideEffect { + if (isRecording != recordingState) { + isRecording = recordingState + } + if (elapsedSeconds != elapsed) { + elapsedSeconds = elapsed + } } Box( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index ae2c25aab..45b41fb7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -228,7 +228,7 @@ fun FloatingRecordingIndicator( ) Text( - text = "Recording ${formatSeconds(elapsedSeconds)}", + text = "Recording ${formatSecondsToTime(elapsedSeconds)}", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -236,13 +236,3 @@ fun FloatingRecordingIndicator( } } } - -private fun formatSeconds(seconds: Int): String { - val minutes = seconds / 60 - val secs = seconds % 60 - return if (minutes > 0) { - String.format("%d:%02d", minutes, secs) - } else { - String.format("0:%02d", secs) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt new file mode 100644 index 000000000..80cac54e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TimeFormatUtils.kt @@ -0,0 +1,39 @@ +/** + * 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.ui.actions.uploads + +import java.util.Locale + +/** + * Formats seconds into a human-readable time string (M:SS or MM:SS format). + * + * @param seconds The number of seconds to format + * @return Formatted time string (e.g., "0:05", "1:23", "12:45") + */ +fun formatSecondsToTime(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return if (minutes > 0) { + String.format(Locale.getDefault(), "%d:%02d", minutes, secs) + } else { + String.format(Locale.getDefault(), "0:%02d", secs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index bff7e3efb..7a2fb1759 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.content.Context import android.media.MediaPlayer +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -77,9 +78,20 @@ fun VoiceMessagePreview( // Initialize MediaPlayer DisposableEffect(voiceMetadata.url, localFile) { val player = createMediaPlayer(context, voiceMetadata.url, localFile) + player?.setOnCompletionListener { + isPlaying = false + progress = 0f + } mediaPlayer = player onDispose { + // Stop playback and clean up + try { + player?.stop() + } catch (e: IllegalStateException) { + // Player might already be stopped + Log.d("VoiceMessagePreview", "MediaPlayer stop failed (already stopped)", e) + } player?.release() mediaPlayer = null isPlaying = false @@ -87,19 +99,37 @@ fun VoiceMessagePreview( } // Update progress while playing - LaunchedEffect(isPlaying) { - if (isPlaying && mediaPlayer != null) { - while (isActive && isPlaying) { - val player = mediaPlayer - if (player != null && player.isPlaying) { - val current = player.currentPosition.toFloat() - val duration = player.duration.toFloat() - progress = if (duration > 0) current / duration else 0f - delay(100) - } else { + LaunchedEffect(mediaPlayer, isPlaying) { + // Capture player reference to avoid reading volatile state repeatedly + val player = mediaPlayer + if (player != null && isPlaying) { + while (isActive) { + try { + if (player.isPlaying) { + val current = player.currentPosition.toFloat() + val duration = player.duration.toFloat() + // Validate values before calculating progress + val newProgress = + if (duration > 0 && current >= 0) { + (current / duration).coerceIn(0f, 1f) + } else { + 0f + } + // Only update if value is valid (not NaN or Infinity) + if (newProgress.isFinite()) { + progress = newProgress + } + } else { + // Player stopped, exit loop and let LaunchedEffect restart + break + } + } catch (e: IllegalStateException) { + // Player in invalid state, stop tracking + Log.w("VoiceMessagePreview", "MediaPlayer in invalid state during progress tracking", e) isPlaying = false - progress = 0f + break } + delay(100) } } } @@ -121,17 +151,25 @@ fun VoiceMessagePreview( // Play/Pause Button IconButton( onClick = { - mediaPlayer?.let { player -> - if (isPlaying) { - player.pause() - isPlaying = false - } else { - if (progress >= 1f) { - player.seekTo(0) - progress = 0f + val player = mediaPlayer + if (player != null) { + try { + if (isPlaying) { + player.pause() + isPlaying = false + } else { + // Validate progress before comparison + if (progress.isFinite() && progress >= 1f) { + player.seekTo(0) + progress = 0f + } + player.start() + isPlaying = true } - player.start() - isPlaying = true + } catch (e: IllegalStateException) { + // MediaPlayer in invalid state, ignore + Log.w("VoiceMessagePreview", "MediaPlayer operation failed in onClick handler", e) + isPlaying = false } } }, @@ -157,16 +195,29 @@ fun VoiceMessagePreview( waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), onProgressChange = { newProgress -> - mediaPlayer?.let { player -> - val newPosition = (newProgress * player.duration).toInt() - player.seekTo(newPosition) - progress = newProgress + // Validate incoming progress value + if (newProgress.isFinite() && newProgress >= 0f && newProgress <= 1f) { + val player = mediaPlayer + if (player != null) { + try { + val duration = player.duration + // Only seek if duration is valid + if (duration > 0) { + val newPosition = (newProgress * duration).toInt() + player.seekTo(newPosition) + progress = newProgress + } + } catch (e: IllegalStateException) { + // MediaPlayer in invalid state, ignore + Log.w("VoiceMessagePreview", "MediaPlayer seek failed in onProgressChange", e) + } + } } }, ) Text( - text = formatDuration(voiceMetadata.duration ?: 0), + text = formatSecondsToTime(voiceMetadata.duration ?: 0), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -203,17 +254,7 @@ private fun createMediaPlayer( setDataSource(url) } prepare() - setOnCompletionListener { - // Reset to beginning when playback completes - seekTo(0) - } } } catch (e: Exception) { null } - -private fun formatDuration(seconds: Int): String { - val minutes = seconds / 60 - val secs = seconds % 60 - return String.format("%d:%02d", minutes, secs) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt index 6e5323cc4..dae416df7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -23,13 +23,17 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import android.content.Context import android.media.MediaRecorder import android.os.Build -import android.util.Log import androidx.media3.common.MimeTypes +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File @@ -41,30 +45,44 @@ class RecordingResult( ) class VoiceMessageRecorder { + @Volatile private var recorder: MediaRecorder? = null private var outputFile: File? = null private var startTime: Long = 0 - private var job: Job? = null + + // Own scope to manage lifecycle independently from caller + private var recorderScope: CoroutineScope? = null + + @Volatile + private var amplitudeSamplingJob: Job? = null private var amplitudes: MutableList = mutableListOf() private fun createRecorder(context: Context): MediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(context) + MediaRecorder(context.applicationContext) } else { MediaRecorder() } + @Synchronized fun start( context: Context, - scope: CoroutineScope, + parentScope: CoroutineScope, ) { + // Clean up any existing recording first + cleanup() + val fileName = RandomInstance.randomChars(16) + ".mp4" - val outputFile = File(context.cacheDir, "/voice/$fileName") + val outputFile = File(context.cacheDir, "voice/$fileName") outputFile.parentFile?.mkdirs() this.outputFile = outputFile this.startTime = TimeUtils.now() this.amplitudes.clear() + // Create own scope with SupervisorJob so failures don't cascade + val scopeJob = SupervisorJob(parentScope.coroutineContext[Job]) + recorderScope = CoroutineScope(Dispatchers.Main.immediate + scopeJob) + createRecorder(context).apply { setAudioEncodingBitRate(16 * 44100) setAudioSamplingRate(44100) // Set the desired audio sampling rate (e.g., 44.1 kHz) @@ -79,38 +97,103 @@ class VoiceMessageRecorder { recorder = this } - job?.cancel() - job = - scope.launch { - while (recorder != null) { - amplitudes.add(recorder?.maxAmplitude?.toFloat() ?: 0f) + // Launch amplitude sampling in our own scope + amplitudeSamplingJob = + recorderScope?.launch { + while (isActive) { + val recorderRef = recorder ?: break + try { + val amplitude = recorderRef.maxAmplitude.toFloat() + synchronized(amplitudes) { + amplitudes.add(amplitude) + } + } catch (e: IllegalStateException) { + // MediaRecorder might be in invalid state, stop sampling + Log.w("VoiceMessageRecorder", "MediaRecorder in invalid state during amplitude sampling", e) + break + } delay(1000) } } } + @Synchronized fun stop(): RecordingResult? { - job?.cancel() - job = null - - try { - recorder?.stop() - } catch (e: RuntimeException) { - Log.w("VoiceMessageRecorder", "Failed to stop recording... Too short?", e) + if (recorder == null) { + cleanup() + return null } - recorder?.reset() - recorder = null val currentTime = TimeUtils.now() val file = outputFile - return if (currentTime - startTime >= 1 && file != null) { + + // Capture amplitudes before cleanup + val amplitudesCopy = + synchronized(amplitudes) { + amplitudes.toList() + } + val duration = (currentTime - startTime).toInt() + + // Clean up recorder and scope + cleanup() + + return if (duration >= 1 && file != null) { RecordingResult( file, MimeTypes.AUDIO_AAC, - amplitudes, - (currentTime - startTime).toInt(), + amplitudesCopy, + duration, ) } else { null } } + + /** + * Cleans up all resources: stops recorder, cancels jobs, cancels scope. + * Safe to call multiple times. + */ + @Synchronized + private fun cleanup() { + // Cancel amplitude sampling job + amplitudeSamplingJob?.cancel() + amplitudeSamplingJob = null + + // Stop any remaining coroutines before touching the recorder + recorderScope?.cancel() + recorderScope = null + + // Swap local reference so we always null out the volatile field + val recorderToRelease = recorder + recorder = null + + recorderToRelease?.let { mediaRecorder -> + try { + mediaRecorder.stop() + } catch (e: IllegalStateException) { + Log.w("VoiceMessageRecorder", "Failed to stop MediaRecorder due to illegal state", e) + } catch (e: RuntimeException) { + // MediaRecorder.stop() can throw RuntimeException if the recording is too short + // or if no valid audio data was captured. This is a known Android issue. + Log.w("VoiceMessageRecorder", "Failed to stop MediaRecorder (recording may be too short or invalid)", e) + } finally { + try { + mediaRecorder.reset() + } catch (resetError: Exception) { + Log.w("VoiceMessageRecorder", "Failed to reset MediaRecorder before release", resetError) + } + try { + mediaRecorder.release() + } catch (releaseError: Exception) { + Log.w("VoiceMessageRecorder", "Failed to release MediaRecorder resources", releaseError) + } + } + } + + // Reset transient state so a fresh recording always starts cleanly + outputFile = null + startTime = 0 + synchronized(amplitudes) { + amplitudes.clear() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt index 100f12398..6df79b5cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt @@ -115,7 +115,7 @@ fun ClickAndHoldBox( @Composable fun ClickAndHoldBoxComposable( modifier: Modifier = Modifier, - onPress: @Composable () -> Unit, + onPress: () -> Unit, onRelease: suspend () -> Unit, onCancel: suspend () -> Unit, content: @Composable (Boolean) -> Unit, @@ -123,15 +123,16 @@ fun ClickAndHoldBoxComposable( val interactionSource = remember { MutableInteractionSource() } var isPressed by remember { mutableStateOf(false) } - if (isPressed) { - onPress() - } - LaunchedEffect(interactionSource) { val pressInteractions = mutableListOf() interactionSource.interactions.collect { interaction -> when (interaction) { - is PressInteraction.Press -> pressInteractions.add(interaction) + is PressInteraction.Press -> { + if (pressInteractions.isEmpty()) { + onPress() + } + pressInteractions.add(interaction) + } is PressInteraction.Release -> { onRelease() pressInteractions.remove(interaction.press) From 2a0eeed55fabba04ce5caa2d323b43c114b671fe Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 18:55:34 +0100 Subject: [PATCH 13/23] code review: remove unsafe casting to avoid app crash if a non-voice reply context leaks through lint fixes --- .../ui/actions/uploads/VoiceMessagePreview.kt | 5 ++--- .../ui/screen/loggedIn/home/ShortNotePostScreen.kt | 8 ++------ .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 11 ++++++----- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index 7a2fb1759..a601837be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.actions.uploads -import android.content.Context import android.media.MediaPlayer import android.util.Log import androidx.compose.foundation.background @@ -77,7 +76,7 @@ fun VoiceMessagePreview( // Initialize MediaPlayer DisposableEffect(voiceMetadata.url, localFile) { - val player = createMediaPlayer(context, voiceMetadata.url, localFile) + val player = createMediaPlayer(voiceMetadata.url, localFile) player?.setOnCompletionListener { isPlaying = false progress = 0f @@ -242,7 +241,6 @@ fun VoiceMessagePreview( } private fun createMediaPlayer( - context: Context, url: String, localFile: File?, ): MediaPlayer? = @@ -256,5 +254,6 @@ private fun createMediaPlayer( prepare() } } catch (e: Exception) { + Log.w("VoiceMessagePreview", "Failed to create MediaPlayer", e) null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 09b288a3b..b347e68b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -479,16 +479,12 @@ private fun NewPostScreenBody( ) } - BottomRowActions(postViewModel, accountViewModel) + BottomRowActions(postViewModel) } } @Composable -private fun BottomRowActions( - postViewModel: ShortNotePostViewModel, - accountViewModel: AccountViewModel, -) { - val context = LocalContext.current +private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { val scrollState = rememberScrollState() Row( modifier = 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 2a243073b..8915803d6 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 @@ -235,7 +235,7 @@ open class ShortNotePostViewModel : fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null - fun user(): User? = account.userProfile() + fun user(): User = account.userProfile() open fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM @@ -534,15 +534,16 @@ open class ShortNotePostViewModel : private suspend fun createTemplate(): EventTemplate? { // Check if this is a voice message voiceMetadata?.let { audioMeta -> - return if (originalNote != null) { + // Only create voice reply if original note is also a VoiceEvent + val originalVoiceHint = originalNote?.toEventHint() + return if (originalVoiceHint != null) { // Create voice reply event - @Suppress("UNCHECKED_CAST") VoiceReplyEvent.build( voiceMessage = audioMeta, - replyingTo = originalNote!!.toEventHint() as com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle, + replyingTo = originalVoiceHint, ) } else { - // Create root voice event + // Create root voice event (no reply or original is not a voice message) VoiceEvent.build( voiceMessage = audioMeta, ) From 0f06838c257724b95013a594abe1a94163567318 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 19:09:09 +0100 Subject: [PATCH 14/23] code review: replace layout with box (Calling a androidx.compose.ui.UiComposable composable function where a UI Composable composable was expected ) --- .../ui/actions/uploads/RecordingIndicators.kt | 68 ++++++++----------- 1 file changed, 28 insertions(+), 40 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index 45b41fb7d..76125fdad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.Layout import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -133,47 +132,36 @@ fun ExpandingCirclesAnimation( if (!isRecording) return - Layout( - modifier = modifier, - content = { - // Circle 1 - Box( - modifier = - Modifier - .scale(scale1) - .alpha(alpha1) - .background(primaryColor.copy(alpha = 0.3f), CircleShape), - ) + Box(modifier = modifier, contentAlignment = Alignment.Center) { + // Circle 1 + Box( + modifier = + Modifier + .matchParentSize() + .scale(scale1) + .alpha(alpha1) + .background(primaryColor.copy(alpha = 0.3f), CircleShape), + ) - // Circle 2 - Box( - modifier = - Modifier - .scale(scale2) - .alpha(alpha2) - .background(primaryColor.copy(alpha = 0.2f), CircleShape), - ) + // Circle 2 + Box( + modifier = + Modifier + .matchParentSize() + .scale(scale2) + .alpha(alpha2) + .background(primaryColor.copy(alpha = 0.2f), CircleShape), + ) - // Circle 3 - Box( - modifier = - Modifier - .scale(scale3) - .alpha(alpha3) - .background(primaryColor.copy(alpha = 0.1f), CircleShape), - ) - }, - ) { measurables, constraints -> - // All circles are centered at the same position - val placeables = measurables.map { it.measure(constraints) } - layout(constraints.maxWidth, constraints.maxHeight) { - placeables.forEach { placeable -> - placeable.placeRelative( - x = (constraints.maxWidth - placeable.width) / 2, - y = (constraints.maxHeight - placeable.height) / 2, - ) - } - } + // Circle 3 + Box( + modifier = + Modifier + .matchParentSize() + .scale(scale3) + .alpha(alpha3) + .background(primaryColor.copy(alpha = 0.1f), CircleShape), + ) } } From 09f66359adfd260e2034192195b3bcc82a29711a Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 19:17:24 +0100 Subject: [PATCH 15/23] code review: delete temp voice files in removeVoiceMessage(), cancel() and uploadVoiceMessageSync() --- .../loggedIn/home/ShortNotePostViewModel.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 8915803d6..b7430793d 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 @@ -761,6 +761,7 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false + deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null isUploadingVoice = false @@ -932,6 +933,7 @@ open class ShortNotePostViewModel : } fun removeVoiceMessage() { + deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null voiceMetadata = null @@ -940,6 +942,19 @@ open class ShortNotePostViewModel : voiceOrchestrator = null } + private fun deleteVoiceLocalFile() { + voiceLocalFile?.let { file -> + try { + if (file.exists()) { + file.delete() + Log.d("ShortNotePostViewModel", "Deleted voice file: ${file.absolutePath}") + } + } catch (e: Exception) { + Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + } + } + } + suspend fun uploadVoiceMessageSync( server: ServerName, onError: (title: String, message: String) -> Unit, @@ -978,6 +993,9 @@ open class ShortNotePostViewModel : duration = recording.duration, waveform = recording.amplitudes, ) + // Delete the local file after successful upload + deleteVoiceLocalFile() + voiceLocalFile = null voiceRecording = null } is UploadOrchestrator.OrchestratorResult.NIP95Result -> { From a370112bc5cde56729bda5d4edaeaf01bf62ba7c Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 19:33:08 +0100 Subject: [PATCH 16/23] code review: extract startRecording code --- .../amethyst/ui/actions/uploads/RecordAudio.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 6d5c7d896..c5083fb12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -66,15 +66,20 @@ fun RecordAudioBox( } } - // Start recording once permission is granted AND user wants to record - LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { - if (recordPermissionState.status.isGranted && wantsToRecord && mediaRecorder.value == null) { + fun startRecording() { + if (mediaRecorder.value == null) { elapsedSeconds = 0 mediaRecorder.value = VoiceMessageRecorder() mediaRecorder.value?.start(context, scope) } } + LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { + if (recordPermissionState.status.isGranted && wantsToRecord) { + startRecording() + } + } + // Track elapsed time while recording LaunchedEffect(mediaRecorder.value) { // Capture the current recorder state to avoid repeated reads of volatile state @@ -97,10 +102,9 @@ fun RecordAudioBox( wantsToRecord = true if (!recordPermissionState.status.isGranted) { recordPermissionState.launchPermissionRequest() - } else if (mediaRecorder.value == null) { - elapsedSeconds = 0 - mediaRecorder.value = VoiceMessageRecorder() - mediaRecorder.value?.start(context, scope) + } else { + // Start immediately for responsive UX when permission already granted + startRecording() } }, onRelease = { From 3dd42492200e9dc80c2e79a7a523149795427be0 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 19:39:24 +0100 Subject: [PATCH 17/23] code review: abort posting message if voice upload fails --- .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 b7430793d..a477ddd95 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 @@ -486,10 +486,15 @@ open class ShortNotePostViewModel : val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer uploadVoiceMessageSync( serverToUse, - { _, _ -> }, // Ignore errors during sync upload before post + { _, _ -> }, // Error handling is done by checking voiceMetadata below ) + // Abort if upload failed - don't post without voice data + if (voiceMetadata == null) { + Log.w("ShortNotePostViewModel", "Voice upload failed, aborting post") + return + } // Update default server if voice message was successfully uploaded - if (voiceMetadata != null && voiceSelectedServer != null && voiceSelectedServer?.type != ServerType.NIP95) { + if (voiceSelectedServer != null && voiceSelectedServer?.type != ServerType.NIP95) { account.settings.changeDefaultFileServer(voiceSelectedServer!!) } } From de927b62bdb51d27993c077573710ee8402b7863 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 15 Dec 2025 20:11:00 +0100 Subject: [PATCH 18/23] translate hardcoded strings --- .../ui/actions/uploads/RecordingIndicators.kt | 9 +++++++-- .../loggedIn/home/ShortNotePostViewModel.kt | 18 +++++++++++++----- .../src/main/res/values-cs-rCZ/strings.xml | 8 ++++++++ .../src/main/res/values-de-rDE/strings.xml | 8 ++++++++ .../src/main/res/values-pt-rBR/strings.xml | 8 ++++++++ .../src/main/res/values-sv-rSE/strings.xml | 8 ++++++++ amethyst/src/main/res/values/strings.xml | 7 +++++++ 7 files changed, 59 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index 76125fdad..9b81d710f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -49,6 +49,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes /** * Animated expanding circles that pulse outward from the recording button @@ -176,6 +178,9 @@ fun FloatingRecordingIndicator( ) { if (!isRecording) return + val recordingLabel = stringRes(id = R.string.recording_indicator_description) + val recordingWithTime = stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds)) + Box( modifier = modifier @@ -207,7 +212,7 @@ fun FloatingRecordingIndicator( Icon( imageVector = Icons.Default.FiberManualRecord, - contentDescription = "Recording", + contentDescription = recordingLabel, tint = Color.White, modifier = Modifier @@ -216,7 +221,7 @@ fun FloatingRecordingIndicator( ) Text( - text = "Recording ${formatSecondsToTime(elapsedSeconds)}", + text = recordingWithTime, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, 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 a477ddd95..8b671c856 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 @@ -965,6 +965,14 @@ open class ShortNotePostViewModel : onError: (title: String, message: String) -> Unit, ) { val recording = voiceRecording ?: return + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) + val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) + val uploadVoiceUnexpected = stringRes(appContext, R.string.upload_error_voice_message_unexpected_state) + val uploadVoiceExceptionMessage: (String) -> String = { detail -> + stringRes(appContext, R.string.upload_error_voice_message_exception, detail) + } isUploadingVoice = true @@ -982,7 +990,7 @@ open class ShortNotePostViewModel : compressionQuality = CompressorQuality.UNCOMPRESSED, server = server, account = account, - context = Amethyst.instance.appContext, + context = appContext, useH265 = false, ) @@ -1006,20 +1014,20 @@ open class ShortNotePostViewModel : is UploadOrchestrator.OrchestratorResult.NIP95Result -> { // For NIP95, we need to create the event and get the nevent URL // This is handled differently - skip for now - onError("Upload Error", "NIP95 not yet supported for voice messages") + onError(uploadErrorTitle, uploadVoiceNip95NotSupported) } } } is UploadingState.Error -> { - onError("Upload Error", "Failed to upload voice message") + onError(uploadErrorTitle, uploadVoiceFailed) voiceRecording = null } else -> { - onError("Upload Error", "Unexpected upload state") + onError(uploadErrorTitle, uploadVoiceUnexpected) } } } catch (e: Exception) { - onError("Upload Error", e.message ?: e.javaClass.simpleName) + onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) voiceRecording = null } finally { isUploadingVoice = false diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 4578cac80..26e878db3 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1176,4 +1176,12 @@ Broadcast sady doporučení Smazat seznam Smazat sadu doporučení + Nahrávání + Nahrávání %1$s + Chyba nahrávání + Nepodařilo se nahrát hlasovou zprávu + Neočekávaný stav nahrávání + NIP-95 zatím není pro hlasové zprávy podporován + Nahrávání hlasu selhalo: %1$s + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 02887615c..3afa6ac9d 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1181,4 +1181,12 @@ anz der Bedingungen ist erforderlich Empfehlungspaket veröffentlichen Liste löschen Empfehlungspaket löschen + Aufnahme + Aufnahme %1$s + Upload-Fehler + Sprachnachricht konnte nicht hochgeladen werden + Unerwarteter Upload-Status + NIP-95 wird für Sprachnachrichten noch nicht unterstützt + Sprach-Upload fehlgeschlagen: %1$s + diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index be0152b9b..09490718a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1176,4 +1176,12 @@ Transmitir pacote de recomendações Excluir lista Excluir pacote de recomendações + Gravando + Gravando %1$s + Erro de upload + Falha ao enviar mensagem de voz + Estado de upload inesperado + O NIP-95 ainda não é suportado para mensagens de voz + Falha no upload de voz: %1$s + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 435c7b261..cfa126897 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1175,4 +1175,12 @@ Sänd rekommendationspaket Ta bort lista Ta bort rekommendationspaket + Spelar in + Spelar in %1$s + Uppladdningsfel + Misslyckades med att ladda upp röstmeddelandet + Oväntat uppladdningstillstånd + NIP-95 stöds ännu inte för röstmeddelanden + Uppladdning av röst misslyckades: %1$s + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 61221bc95..8db1152f4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -170,7 +170,14 @@ Record a message Record a message Click and hold to record a message + Recording + Recording %1$s Uploading… + Upload Error + Failed to upload voice message + Unexpected upload state + NIP-95 is not supported for voice messages yet + Voice upload failed: %1$s User does not have a lightning address set up to receive sats "reply here.. " Copies the Note ID to the clipboard for sharing in Nostr From 7e1998174ca57efa28a54e5accf2b1e413d23047 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 16 Dec 2025 09:36:24 +0100 Subject: [PATCH 19/23] Delete any existing temp file before replacing --- .../amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) 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 8b671c856..87b87b5fc 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 @@ -923,6 +923,8 @@ open class ShortNotePostViewModel : } fun selectVoiceRecording(recording: RecordingResult) { + // Delete any existing temp file before replacing + deleteVoiceLocalFile() voiceRecording = recording voiceLocalFile = recording.file } From 90c8a9df4ce05c34a8125cc1a70c37343c63428f Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 16 Dec 2025 12:10:29 +0100 Subject: [PATCH 20/23] use 0 size reference for all identity icons fix bug (R.string.github vs R.drawable.github) --- .../loggedIn/profile/header/DrawAdditionalInfo.kt | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 6be7f4903..6f4a4dc8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -72,6 +72,8 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.TelegramIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims +private const val IDENTITY_ICON_CACHE_KEY = 0 + @Composable fun DrawAdditionalInfo( baseUser: User, @@ -191,7 +193,7 @@ fun DrawAdditionalInfo( Row(verticalAlignment = Alignment.CenterVertically) { Icon( tint = Color.Unspecified, - painter = painterRes(resourceId = getIdentityClaimIcon(identity), getIdentityClaimIconReference(identity)), + painter = painterRes(resourceId = getIdentityClaimIcon(identity), IDENTITY_ICON_CACHE_KEY), contentDescription = stringRes(getIdentityClaimDescription(identity)), modifier = Modifier.size(16.dp), ) @@ -246,14 +248,5 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = is TelegramIdentity -> R.string.telegram is MastodonIdentity -> R.string.mastodon is GitHubIdentity -> R.string.github - else -> R.drawable.github - } - -fun getIdentityClaimIconReference(identity: IdentityClaimTag): Int = - when (identity) { - is TwitterIdentity -> 0 - is TelegramIdentity -> 0 - is MastodonIdentity -> 0 - is GitHubIdentity -> 0 - else -> 0 + else -> R.string.github } From 128155714d50f6da809d686e4465057e5ca61cf0 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 16 Dec 2025 13:37:17 +0000 Subject: [PATCH 21/23] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 15 +++++++-------- amethyst/src/main/res/values-de-rDE/strings.xml | 15 +++++++-------- amethyst/src/main/res/values-pt-rBR/strings.xml | 15 +++++++-------- amethyst/src/main/res/values-sv-rSE/strings.xml | 15 +++++++-------- 4 files changed, 28 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 26e878db3..5cc85b46e 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -158,7 +158,14 @@ Nahrajte zprávu Nahrajte zprávu Stiskněte a podržte pro nahrání zprávy + Nahrávání + Nahrávání %1$s Nahrávání… + Chyba nahrávání + Nepodařilo se nahrát hlasovou zprávu + Neočekávaný stav nahrávání + NIP-95 zatím není pro hlasové zprávy podporován + Nahrávání hlasu selhalo: %1$s Uživatel nemá nastavenou LN adresu pro přijímání sats "Odpověď zde…" Zkopíruje ID poznámky do schránky pro sdílení @@ -1176,12 +1183,4 @@ Broadcast sady doporučení Smazat seznam Smazat sadu doporučení - Nahrávání - Nahrávání %1$s - Chyba nahrávání - Nepodařilo se nahrát hlasovou zprávu - Neočekávaný stav nahrávání - NIP-95 zatím není pro hlasové zprávy podporován - Nahrávání hlasu selhalo: %1$s - diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 3afa6ac9d..4dea3cb35 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -160,7 +160,14 @@ erie gespeichert Eine Nachricht aufnehmen Eine Nachricht aufnehmen Zum Aufnehmen einer Nachricht gedrückt halten + Aufnahme + Aufnahme %1$s Hochladen… + Upload-Fehler + Sprachnachricht konnte nicht hochgeladen werden + Unerwarteter Upload-Status + NIP-95 wird für Sprachnachrichten noch nicht unterstützt + Sprach-Upload fehlgeschlagen: %1$s Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen "Hier antworten…" Kopiert die Notiz-ID zum Teilen in die Zwischenablage @@ -1181,12 +1188,4 @@ anz der Bedingungen ist erforderlich Empfehlungspaket veröffentlichen Liste löschen Empfehlungspaket löschen - Aufnahme - Aufnahme %1$s - Upload-Fehler - Sprachnachricht konnte nicht hochgeladen werden - Unerwarteter Upload-Status - NIP-95 wird für Sprachnachrichten noch nicht unterstützt - Sprach-Upload fehlgeschlagen: %1$s - diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 09490718a..c8278d893 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -158,7 +158,14 @@ Gravar uma mensagem Gravar uma mensagem Clique e segure para gravar uma mensagem + Gravando + Gravando %1$s Enviando… + Erro de upload + Falha ao enviar mensagem de voz + Estado de upload inesperado + O NIP-95 ainda não é suportado para mensagens de voz + Falha no upload de voz: %1$s Usuário não tem um endereço lightning configurado para receber sats "responda aqui.. " Copia o ID do canal (note) para compartilhar @@ -1176,12 +1183,4 @@ Transmitir pacote de recomendações Excluir lista Excluir pacote de recomendações - Gravando - Gravando %1$s - Erro de upload - Falha ao enviar mensagem de voz - Estado de upload inesperado - O NIP-95 ainda não é suportado para mensagens de voz - Falha no upload de voz: %1$s - diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index cfa126897..a3393dcdf 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -158,7 +158,14 @@ Spela in ett meddelande Spela in ett meddelande Tryck och håll in för att spela in ett meddelande + Spelar in + Spelar in %1$s Laddar upp… + Uppladdningsfel + Misslyckades med att ladda upp röstmeddelandet + Oväntat uppladdningstillstånd + NIP-95 stöds ännu inte för röstmeddelanden + Uppladdning av röst misslyckades: %1$s Användaren har inte en Lightningadressinställning för att ta emot sats "svara här.. " Kopierar antecknings-ID till urklipp för delning @@ -1175,12 +1182,4 @@ Sänd rekommendationspaket Ta bort lista Ta bort rekommendationspaket - Spelar in - Spelar in %1$s - Uppladdningsfel - Misslyckades med att ladda upp röstmeddelandet - Oväntat uppladdningstillstånd - NIP-95 stöds ännu inte för röstmeddelanden - Uppladdning av röst misslyckades: %1$s - From aa4270883d9131a1dc9cf71c25639a296ed7f52f Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 16 Dec 2025 17:00:00 +0100 Subject: [PATCH 22/23] correct way to clear collection --- .../service/playback/websocket/WssDataStreamCollector.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt index 0ca26eccf..c59521c07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt @@ -41,10 +41,10 @@ class WssDataStreamCollector : WebSocketListener() { reason: String, ) { super.onClosing(webSocket, code, reason) - wssData.removeAll(wssData) + wssData.clear() } - fun canStream(): Boolean = wssData.size > 0 + fun canStream(): Boolean = wssData.isNotEmpty() fun getNextStream(): ByteString = wssData.pollFirst() } From 3373d3a958382cc89a7f844823b26a055622c593 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 16 Dec 2025 17:08:44 +0100 Subject: [PATCH 23/23] log boolean returned by .delete() --- .../main/java/com/vitorpamplona/amethyst/LocalPreferences.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 910991d39..d2678ca68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -249,7 +249,9 @@ object LocalPreferences { val prefsDir = File(prefsDirPath) prefsDir.list()?.forEach { if (it.contains(npub)) { - File(prefsDir, it).delete() + if (!File(prefsDir, it).delete()) { + Log.w("LocalPreferences", "Failed to delete preference file: $it") + } } } }