From e1e64233c96b6dc7770582672c7e8b59f42d419b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 01:54:12 +0000 Subject: [PATCH] refactor: voice recordings on okio paths; host ports for the note composer - RecordingResult/AnonymizedResult move to commons with okio Path files (commons now exposes okio as api); VoiceAnonymizationController moves to commonsUI and re-voices through AccountViewModelHost.anonymizeVoice. - mediaUriOfFile expect/actual replaces Uri.fromFile in the voice uploads. - AccountViewModelHost gains scheduledPostStore and createWritingAssistant; ShortNotePostViewModel no longer reads Amethyst.instance, Context, UUID or System time. AMETHYST_CLIENT_TAG_NAME moves to commons. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P --- .../model/accountsCache/AccountCacheState.kt | 3 +- .../ui/actions/uploads/RecordAudio.kt | 1 + .../ui/actions/uploads/RecordVoiceButton.kt | 1 + .../ui/actions/uploads/VoiceAnonymizer.kt | 17 ++----- .../ui/actions/uploads/VoiceMessagePreview.kt | 13 +++--- .../actions/uploads/VoiceMessageRecorder.kt | 11 ++--- .../amethyst/ui/note/ReactionsRow.kt | 2 +- .../loggedIn/AndroidAccountViewModelHost.kt | 17 +++++++ .../chats/cordnGroup/CordnComposer.kt | 2 +- .../chats/cordnGroup/CordnGroupChatScreen.kt | 12 ++--- .../loggedIn/home/ShortNotePostScreen.kt | 5 +-- .../loggedIn/home/ShortNotePostViewModel.kt | 45 ++++++++++--------- .../loggedIn/home/VoiceReplyViewModel.kt | 26 ++++++----- commons/build.gradle.kts | 5 ++- .../service/uploads/MediaUri.android.kt | 3 ++ .../amethyst/commons/audio/VoiceRecording.kt | 44 ++++++++++++++++++ .../amethyst/commons/model/ClientTag.kt | 24 ++++++++++ .../commons/service/uploads/MediaUri.kt | 5 +++ .../viewmodels/AccountViewModelHost.kt | 19 ++++++++ .../commons/service/uploads/MediaUri.ios.kt | 4 ++ .../commons/service/uploads/MediaUri.jvm.kt | 4 ++ .../audio}/VoiceAnonymizationController.kt | 26 +++++------ 22 files changed, 198 insertions(+), 91 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceRecording.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ClientTag.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio}/VoiceAnonymizationController.kt (84%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 4157379fb2..d4b9fac806 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.marmot.EncryptedMarmotMessageStore import com.vitorpamplona.amethyst.commons.marmot.EncryptedMlsGroupStateStore import com.vitorpamplona.amethyst.commons.marmot.EncryptedPublishObligationStore import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore +import com.vitorpamplona.amethyst.commons.model.AMETHYST_CLIENT_TAG_NAME import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AccountSettings import com.vitorpamplona.amethyst.commons.model.GeohashIdentityStore @@ -419,7 +420,7 @@ class AccountCacheState( } companion object { - const val CLIENT_TAG_NAME = "Amethyst" + const val CLIENT_TAG_NAME = AMETHYST_CLIENT_TAG_NAME } } 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 de9d78d496..7ec1097bb4 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 @@ -36,6 +36,7 @@ import androidx.compose.ui.platform.LocalContext import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.commons.audio.RecordingResult import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.record_a_message_description import com.vitorpamplona.amethyst.commons.ui.components.ToggleableBox 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 fe3f79176a..cfc50ac5a6 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.audio.ExpandingCirclesAnimation import com.vitorpamplona.amethyst.commons.audio.FloatingRecordingIndicator +import com.vitorpamplona.amethyst.commons.audio.RecordingResult import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt index 0ea2b1c747..bb8e710f24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt @@ -25,6 +25,7 @@ import android.media.MediaCodecInfo import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMuxer +import com.vitorpamplona.amethyst.commons.audio.AnonymizedResult import com.vitorpamplona.amethyst.commons.audio.PitchShifter import com.vitorpamplona.amethyst.commons.audio.VoicePreset import com.vitorpamplona.quartz.utils.Log @@ -33,24 +34,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext +import okio.Path.Companion.toOkioPath import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.abs -/** - * Result of voice anonymization processing. - * - * @property file The output audio file (AAC in MP4 container) - * @property waveform Amplitude data for waveform visualization (one value per second) - * @property duration Audio duration in seconds - */ -data class AnonymizedResult( - val file: File, - val waveform: List, - val duration: Int, -) - /** * Processes audio files to alter voice characteristics for privacy. * @@ -109,7 +98,7 @@ class VoiceAnonymizer { } onProgress(1f) - Result.success(AnonymizedResult(outputFile, waveform, duration)) + Result.success(AnonymizedResult(outputFile.toOkioPath(), waveform, duration)) } catch (e: CancellationException) { // Let cancellation propagate so structured concurrency works (e.g. the // upload screen being dismissed cancels this job). 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 f43c6b5a19..6150807b7d 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 @@ -48,6 +48,7 @@ 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.commons.audio.RecordingResult import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -64,12 +65,12 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import java.io.File +import okio.Path @Composable fun VoiceMessagePreview( voiceMetadata: AudioMeta, - localFile: File? = null, + localFile: Path? = null, onRemove: () -> Unit, onReRecord: ((RecordingResult) -> Unit)? = null, isUploading: Boolean = false, @@ -263,7 +264,7 @@ private fun ReRecordButton( @Composable private fun ManageMediaPlayer( voiceMetadata: AudioMeta, - localFile: File?, + localFile: Path?, onCompletion: () -> Unit, onPlayerChanged: (MediaPlayer?) -> Unit, onRelease: () -> Unit, @@ -371,12 +372,12 @@ private fun handleWaveformScrub( private fun createMediaPlayer( url: String, - localFile: File?, + localFile: Path?, ): MediaPlayer? = try { MediaPlayer().apply { - if (localFile != null && localFile.exists()) { - setDataSource(localFile.absolutePath) + if (localFile != null && localFile.toFile().exists()) { + setDataSource(localFile.toString()) } else { setDataSource(url) } 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 892b84128b..2181d9cd0a 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 @@ -24,6 +24,7 @@ import android.content.Context import android.media.MediaRecorder import android.os.Build import androidx.media3.common.MimeTypes +import com.vitorpamplona.amethyst.commons.audio.RecordingResult import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils @@ -35,15 +36,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import okio.Path.Companion.toOkioPath import java.io.File -class RecordingResult( - val file: File, - val mimeType: String, - val amplitudes: List, - val duration: Int, -) - @Suppress("DEPRECATION") class VoiceMessageRecorder { @Volatile @@ -145,7 +140,7 @@ class VoiceMessageRecorder { return if (duration >= 1 && file != null) { RecordingResult( - file, + file.toOkioPath(), MimeTypes.AUDIO_AAC, amplitudesCopy, duration, 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 9c2cbc2076..c3e68d1927 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 @@ -756,7 +756,7 @@ fun ReplyViaVoiceReaction( nav.nav { Route.VoiceReply( replyToNoteId = baseNote.idHex, - recordingFilePath = audio.file.absolutePath, + recordingFilePath = audio.file.toString(), mimeType = audio.mimeType, duration = audio.duration, amplitudes = Json.encodeToString(audio.amplitudes), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt index 4aedc816af..ddd888b277 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AndroidAccountViewModelHost.kt @@ -25,7 +25,11 @@ import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AppModules import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.audio.AnonymizedResult +import com.vitorpamplona.amethyst.commons.audio.VoicePreset import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore +import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistant import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlTransport import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure @@ -33,8 +37,10 @@ import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader import com.vitorpamplona.amethyst.commons.tor.MoneyOpRelayRouting import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModelHost +import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent import com.vitorpamplona.amethyst.service.uploads.AndroidMediaUploader +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizer import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -49,6 +55,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import okio.Path /** [AccountViewModelHost] over the app's [AppModules]. Lazy services are read on first use. */ class AndroidAccountViewModelHost( @@ -77,6 +84,16 @@ class AndroidAccountViewModelHost( override val deviceLocation: DeviceLocation get() = modules.locationManager + override val scheduledPostStore: ScheduledPostStore get() = modules.scheduledPostStore + + override suspend fun anonymizeVoice( + input: Path, + presetName: String, + ): Result = VoiceAnonymizer().anonymize(input.toFile(), VoicePreset.valueOf(presetName)) + + // Built on the application context: the assistant outlives any one Activity inside the ViewModel. + override fun createWritingAssistant(): WritingAssistant = WritingAssistantFactory.create(modules.appContext) + override val savedAccounts: Flow> = LocalPreferences .accountsFlow() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt index f65fa5ec3b..5050bf3973 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.audio.RecordingResult import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState @@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt index d6eeb8c94a..d39be4e29d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.audio.RecordingResult import com.vitorpamplona.amethyst.commons.audio.WaveformData import com.vitorpamplona.amethyst.commons.chats.ui.AutoScrollToNewest import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager @@ -121,7 +122,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.model.cordn.CordnMediaService import com.vitorpamplona.amethyst.service.uploads.MetadataStripper -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWaveformPlayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog @@ -219,7 +219,7 @@ private fun CordnGroupChat( // it after sending. A note recorded and then abandoned — the screen closed, // the room switched — never reached that, so it stayed on disk: an // unencrypted copy of a message that was never even sent. - pendingVoice?.file?.delete() + pendingVoice?.file?.toFile()?.delete() } } var attachError by remember { mutableStateOf(null) } @@ -595,11 +595,11 @@ private fun CordnGroupChat( onVoiceNote = { recording -> // Re-recording replaces what was there; the old file is a temp // file this screen owns, so it goes now rather than being leaked. - pendingVoice?.file?.delete() + pendingVoice?.file?.toFile()?.delete() pendingVoice = recording }, onRemoveVoice = { - pendingVoice?.file?.delete() + pendingVoice?.file?.toFile()?.delete() pendingVoice = null }, attaching = attaching, @@ -1311,7 +1311,7 @@ private suspend fun sendVoiceNote( ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session)) try { - val bytes = withContext(Dispatchers.IO) { recording.file.readBytes() } + val bytes = withContext(Dispatchers.IO) { recording.file.toFile().readBytes() } val tag = CordnMediaService(accountViewModel.account) .upload( @@ -1331,7 +1331,7 @@ private suspend fun sendVoiceNote( // invisible to the person who sent it. room.add(session.manager.send(room.gid, content = caption.trim(), tags = arrayOf(tag))) } finally { - withContext(Dispatchers.IO) { recording.file.delete() } + withContext(Dispatchers.IO) { recording.file.toFile().delete() } } } 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 c6be482894..878c44f549 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 @@ -63,7 +63,6 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -192,7 +191,6 @@ fun ShortNotePostScreen( postViewModel.init(accountViewModel) postViewModel.setGroupThread(groupThreadId, groupThreadRelayUrl) - val context = LocalContext.current val mediaResolver = rememberSharedMediaResolver() val proposeAiImprovements by @@ -201,8 +199,7 @@ fun ShortNotePostScreen( LaunchedEffect(proposeAiImprovements) { if (proposeAiImprovements == BooleanType.ALWAYS) { - // The assistant outlives this Activity inside the ViewModel, so it must not hold it. - postViewModel.initWritingAssistant(context.applicationContext) + postViewModel.initWritingAssistant() } } 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 fafc19c91c..b8b42ac905 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 @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home -import android.content.Context import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd @@ -33,8 +32,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.audio.RecordingResult +import com.vitorpamplona.amethyst.commons.audio.VoiceAnonymizationController import com.vitorpamplona.amethyst.commons.audio.VoicePreset +import com.vitorpamplona.amethyst.commons.model.AMETHYST_CLIENT_TAG_NAME import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.BooleanType @@ -77,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState +import com.vitorpamplona.amethyst.commons.service.uploads.mediaUriOfFile import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.IAudience @@ -89,11 +91,8 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource +import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState -import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent @@ -188,7 +187,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.util.UUID +import okio.Path +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid enum class UserSuggestionAnchor { MAIN_MESSAGE, @@ -287,7 +288,7 @@ open class ShortNotePostViewModel : // Voice Messages var voiceRecording by mutableStateOf(null) - var voiceLocalFile by mutableStateOf(null) + var voiceLocalFile by mutableStateOf(null) var isUploadingVoice by mutableStateOf(false) var voiceMetadata by mutableStateOf(null) var voiceSelectedServer by mutableStateOf(null) @@ -305,9 +306,10 @@ open class ShortNotePostViewModel : error, ) }, + anonymize = { file, preset -> accountViewModel.host.anonymizeVoice(file, preset.name) }, ) - val activeFile: java.io.File? + val activeFile: Path? get() = voiceAnonymization.activeFile(voiceLocalFile) val activeWaveform: List? @@ -478,9 +480,9 @@ open class ShortNotePostViewModel : accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value == BooleanType.ALWAYS - fun initWritingAssistant(context: Context) { + fun initWritingAssistant() { if (writingAssistant != null) return - writingAssistant = WritingAssistantFactory.create(context) + writingAssistant = accountViewModel.host.createWritingAssistant() ?: return refreshAiStatus() } @@ -1260,21 +1262,23 @@ open class ShortNotePostViewModel : * Runs on the mining queue's scope when PoW is on, so it must not touch * viewModelScope. */ + @OptIn(ExperimentalUuidApi::class) private suspend fun storeScheduledPost( template: EventTemplate, extraNotesToBroadcast: List, publishAtSec: Long, ) { val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) - Amethyst.instance.scheduledPostStore.add( + val store = accountViewModel.host.scheduledPostStore ?: return + store.add( ScheduledPost( - id = UUID.randomUUID().toString(), + id = Uuid.random().toString(), accountPubkey = event.pubKey, signedEventJson = event.toJson(), relayUrls = relays.map { it.url }, extraEventsJson = extras.map { it.toJson() }, publishAtSec = publishAtSec, - createdAtSec = System.currentTimeMillis() / 1000, + createdAtSec = TimeUtils.now(), ), ) } @@ -1570,7 +1574,7 @@ open class ShortNotePostViewModel : val replyingToEvent = originalNote?.event ?: return false return replyingToEvent is TextNoteEvent && replyingToEvent.isNewThread() && - replyingToEvent.isClient(AccountCacheState.CLIENT_TAG_NAME) + replyingToEvent.isClient(AMETHYST_CLIENT_TAG_NAME) } fun upload( @@ -1916,14 +1920,13 @@ open class ShortNotePostViewModel : voiceOrchestrator = null } - private fun deleteVoiceLocalFile(toDelete: java.io.File? = voiceLocalFile) { + private fun deleteVoiceLocalFile(toDelete: Path? = voiceLocalFile) { toDelete?.let { file -> try { - if (file.delete()) { - Log.d("ShortNotePostViewModel") { "Deleted voice file: ${file.absolutePath}" } - } + platformFileSystem.delete(file, mustExist = false) + Log.d("ShortNotePostViewModel") { "Deleted voice file: $file" } } catch (e: Exception) { - Log.w("ShortNotePostViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + Log.w("ShortNotePostViewModel", "Failed to delete voice file: $file", e) } } } @@ -1942,7 +1945,7 @@ open class ShortNotePostViewModel : isUploadingVoice = true try { - val uri = android.net.Uri.fromFile(fileToUpload) + val uri = mediaUriOfFile(fileToUpload) val orchestrator = UploadOrchestrator() voiceOrchestrator = orchestrator diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 1f1e65aca0..287b79bcc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -27,6 +27,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.audio.RecordingResult +import com.vitorpamplona.amethyst.commons.audio.VoiceAnonymizationController import com.vitorpamplona.amethyst.commons.audio.VoicePreset import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -41,11 +43,10 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState +import com.vitorpamplona.amethyst.commons.service.uploads.mediaUriOfFile import com.vitorpamplona.amethyst.commons.ui.loadStringRes -import com.vitorpamplona.amethyst.commons.util.deleteOrWarn +import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.tags.markedETags @@ -61,7 +62,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import java.io.File +import okio.Path +import okio.Path.Companion.toPath @Stable class VoiceReplyViewModel : ViewModel() { @@ -70,7 +72,7 @@ class VoiceReplyViewModel : ViewModel() { var replyToNote: Note? by mutableStateOf(null) var voiceRecording: RecordingResult? by mutableStateOf(null) - var voiceLocalFile: File? by mutableStateOf(null) + var voiceLocalFile: Path? by mutableStateOf(null) var voiceMetadata: AudioMeta? by mutableStateOf(null) var voiceSelectedServer: ServerName? by mutableStateOf(null) var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) @@ -89,9 +91,10 @@ class VoiceReplyViewModel : ViewModel() { ) } }, + anonymize = { file, preset -> accountViewModel.host.anonymizeVoice(file, preset.name) }, ) - val activeFile: File? + val activeFile: Path? get() = voiceAnonymization.activeFile(voiceLocalFile) val activeWaveform: List? @@ -126,7 +129,7 @@ class VoiceReplyViewModel : ViewModel() { emptyList() } - val file = File(recordingFilePath) + val file = recordingFilePath.toPath() voiceLocalFile = file voiceRecording = RecordingResult( @@ -164,11 +167,10 @@ class VoiceReplyViewModel : ViewModel() { private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { - if (file.deleteOrWarn("VoiceReplyViewModel", "voice file")) { - Log.d("VoiceReplyViewModel") { "Voice file removed or already gone: ${file.absolutePath}" } - } + platformFileSystem.delete(file, mustExist = false) + Log.d("VoiceReplyViewModel") { "Voice file removed or already gone: $file" } } catch (e: Exception) { - Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + Log.w("VoiceReplyViewModel", "Failed to delete voice file: $file", e) } } } @@ -195,7 +197,7 @@ class VoiceReplyViewModel : ViewModel() { try { val result = withContext(Dispatchers.IO) { - val uri = android.net.Uri.fromFile(fileToUpload) + val uri = mediaUriOfFile(fileToUpload) orchestrator.upload( uri = uri, mimeType = recording.mimeType, diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 86c5c10c33..5903154980 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -87,8 +87,9 @@ kotlin { // the ForwardingFileSystem the apps wrap Coil's disk cache in. It // used to arrive transitively through Coil; with Coil in // :commonsUI the Apple targets lost it (JVM still saw it via - // OkHttp), so declare the dependency the file actually has. - implementation(libs.okio) + // OkHttp), so declare the dependency the file actually has. `api` + // because voice recordings cross module boundaries as okio Paths. + api(libs.okio) // DataStore (KMP, Apache-2.0) — the preference storage layer. // Publishes android/jvm/ios/linux/macos variants plus common diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.android.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.android.kt index b38e335f3f..53d0e23e82 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.android.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.android.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.commons.service.uploads import android.net.Uri +import okio.Path actual typealias MediaUri = Uri actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegment + +actual fun mediaUriOfFile(path: Path): MediaUri = Uri.fromFile(path.toFile()) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceRecording.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceRecording.kt new file mode 100644 index 0000000000..7529c01300 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceRecording.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.audio + +import okio.Path + +/** A finished voice-message recording: the audio file, its type, and its waveform. */ +class RecordingResult( + val file: Path, + val mimeType: String, + val amplitudes: List, + val duration: Int, +) + +/** + * A recording re-voiced for privacy. + * + * @property file The output audio file (AAC in MP4 container) + * @property waveform Amplitude data for waveform visualization (one value per second) + * @property duration Audio duration in seconds + */ +data class AnonymizedResult( + val file: Path, + val waveform: List, + val duration: Int, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ClientTag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ClientTag.kt new file mode 100644 index 0000000000..e480bcc672 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ClientTag.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +/** The name this app signs into NIP-89 `client` tags, and recognizes on events it wrote. */ +const val AMETHYST_CLIENT_TAG_NAME = "Amethyst" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt index b4de3123e9..c11127132b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.uploads +import okio.Path + /** * A picked file's address as the platform hands it over: Android's `content://` [android.net.Uri] * (a typealias there, so app code keeps reading it as a `Uri`), a string-backed subclass @@ -30,6 +32,9 @@ expect abstract class MediaUri { abstract override fun toString(): String } +/** The address of a local file, such as a voice recording the app wrote itself. */ +expect fun mediaUriOfFile(path: Path): MediaUri + /** The last path segment of this address, if it has one (on Android, `Uri.lastPathSegment`). */ expect fun MediaUri.lastPathSegmentOrNull(): String? diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt index 4cbef6742e..6eb5d1830c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModelHost.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.viewmodels +import com.vitorpamplona.amethyst.commons.audio.AnonymizedResult import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore +import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistant import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader @@ -34,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import kotlinx.collections.immutable.PersistentMap import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import okio.Path /** * What the account's ViewModel needs from the process it runs in: app-wide services that outlive @@ -74,6 +78,21 @@ interface AccountViewModelHost { /** The device's position as geohashes. */ val deviceLocation: DeviceLocation get() = DeviceLocation.None + /** Posts signed now and published later by a background worker; null where scheduling is unsupported. */ + val scheduledPostStore: ScheduledPostStore? get() = null + + /** + * Re-voices the recording at [input] with the voice preset named [presetName], into a new file + * beside it. + */ + suspend fun anonymizeVoice( + input: Path, + presetName: String, + ): Result = Result.failure(UnsupportedOperationException("Voice anonymization is not available on this platform")) + + /** A new on-device writing assistant, or null where the platform has none. */ + fun createWritingAssistant(): WritingAssistant? = null + /** Public keys of every account saved on this device. */ val savedAccounts: Flow> diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt index a8e33c608e..0fe4f5cc41 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.uploads +import okio.Path + actual abstract class MediaUri { actual abstract override fun toString(): String } @@ -38,3 +40,5 @@ class StringMediaUri( } actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegmentOf(toString()) + +actual fun mediaUriOfFile(path: Path): MediaUri = StringMediaUri(path.toString()) diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt index a8e33c608e..0fe4f5cc41 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.uploads +import okio.Path + actual abstract class MediaUri { actual abstract override fun toString(): String } @@ -38,3 +40,5 @@ class StringMediaUri( } actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegmentOf(toString()) + +actual fun mediaUriOfFile(path: Path): MediaUri = StringMediaUri(path.toString()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceAnonymizationController.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceAnonymizationController.kt index 3f3a70fb4c..7fe80b0f86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/VoiceAnonymizationController.kt @@ -18,22 +18,24 @@ * 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 +package com.vitorpamplona.amethyst.commons.audio import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.vitorpamplona.amethyst.commons.audio.VoicePreset +import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import java.io.File +import okio.Path class VoiceAnonymizationController( private val scope: CoroutineScope, private val logTag: String, private val onError: (Throwable) -> Unit, + /** Re-voices a recording with a preset; the platform's audio pipeline, reached through the host. */ + private val anonymize: suspend (Path, VoicePreset) -> Result, ) { var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE) private set @@ -44,7 +46,7 @@ class VoiceAnonymizationController( private var processingJob: Job? = null - fun activeFile(originalFile: File?): File? = + fun activeFile(originalFile: Path?): Path? = if (selectedPreset == VoicePreset.NONE) { originalFile } else { @@ -60,7 +62,7 @@ class VoiceAnonymizationController( fun selectPreset( preset: VoicePreset, - originalFile: File?, + originalFile: Path?, ) { Log.d(logTag) { "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}" } if (processingPreset != null || preset == selectedPreset) return @@ -82,8 +84,7 @@ class VoiceAnonymizationController( processingJob = scope.launch { try { - val anonymizer = VoiceAnonymizer() - val result = anonymizer.anonymize(file, preset) + val result = anonymize(file, preset) result .onSuccess { anonymizedResult -> @@ -109,15 +110,10 @@ class VoiceAnonymizationController( fun deleteDistortedFiles() { distortedFiles.values.forEach { result -> try { - if (result.file.exists()) { - if (result.file.delete()) { - Log.d(logTag) { "Deleted distorted file: ${result.file.absolutePath}" } - } else { - Log.w(logTag) { "Failed to delete distorted file: ${result.file.absolutePath}" } - } - } + platformFileSystem.delete(result.file, mustExist = false) + Log.d(logTag) { "Deleted distorted file: ${result.file}" } } catch (e: Exception) { - Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e) + Log.w(logTag, "Failed to delete distorted file: ${result.file}", e) } } distortedFiles = emptyMap()