diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForms.kt b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForms.kt index ff337a824f..28a8414422 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForms.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForms.kt @@ -7,6 +7,7 @@ import androidx.annotation.AnyThread import androidx.annotation.RequiresApi import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.SingleSubject import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.AttachmentId @@ -29,6 +30,7 @@ object AudioWaveForms { private val TAG = Log.tag(AudioWaveForms::class.java) private val cache = ThreadSafeLruCache(200) + private val pending = hashMapOf>() @AnyThread @JvmStatic @@ -43,39 +45,47 @@ object AudioWaveForms { val cachedInfo = cache.get(cacheKey) if (cachedInfo != null) { Log.i(TAG, "Loaded wave form from cache $cacheKey") + synchronized(pending) { + pending.remove(cacheKey) + } return Single.just(cachedInfo) } - val databaseCache = Single.fromCallable { - val audioHash = attachment.audioHash - return@fromCallable if (audioHash != null) { - checkDatabaseCache(cacheKey, audioHash.audioWaveForm) + val pendingSubject = synchronized(pending) { + if (pending.containsKey(cacheKey)) { + Log.i(TAG, "Wave currently generating, returning existing subject") + return pending[cacheKey]!! } else { - Miss + pending[cacheKey] = SingleSubject.create() } - }.subscribeOn(Schedulers.io()) - val generateWaveForm: Single = if (attachment is DatabaseAttachment) { - Single.fromCallable { generateWaveForm(context, uri, cacheKey, attachment.attachmentId) } - } else { - Single.fromCallable { generateWaveForm(context, uri, cacheKey) } - }.subscribeOn(Schedulers.io()) + pending[cacheKey]!! + } - return databaseCache - .flatMap { r -> - if (r is Miss) { - generateWaveForm + Single.fromCallable { attachment.audioHash?.let { checkDatabaseCache(cacheKey, it.audioWaveForm) } ?: Miss } + .flatMap { result -> + if (result !is Success) { + if (attachment is DatabaseAttachment) { + Single.fromCallable { generateWaveForm(context, uri, cacheKey, attachment.attachmentId) } + } else { + Single.fromCallable { generateWaveForm(context, uri, cacheKey) } + } } else { - Single.just(r) + Single.just(result) } } - .map { r -> - if (r is Success) { - r.audioFileInfo + .map { result -> + if (result is Success) { + result.audioFileInfo } else { throw IOException("Unable to generate wave form") } } + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .subscribe(pendingSubject) + + return pendingSubject } private fun checkDatabaseCache(cacheKey: String, audioWaveForm: AudioWaveFormData): CacheCheckResult { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java index a7b2ad13a8..d6e735c799 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -176,7 +176,6 @@ public final class AudioView extends FrameLayout { final boolean showControls, final boolean forceHideDuration) { - this.disposable.dispose(); this.callbacks = callbacks; if (duration != null) { @@ -213,25 +212,26 @@ public final class AudioView extends FrameLayout { showPlayButton(); } - this.audioSlide = audio; - if (seekBar instanceof WaveFormSeekBarView) { WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar; waveFormView.setColors(waveFormPlayedBarsColor, waveFormUnplayedBarsColor, waveFormThumbTint); if (android.os.Build.VERSION.SDK_INT >= 23) { - disposable = AudioWaveForms.getWaveForm(getContext(), audioSlide.asAttachment()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - data -> { - durationMillis = data.getDuration(TimeUnit.MILLISECONDS); - updateProgress(0, 0); - if (!forceHideDuration && duration != null) { - duration.setVisibility(VISIBLE); - } - waveFormView.setWaveData(data.getWaveForm()); - }, - t -> waveFormView.setWaveMode(false) - ); + if (audioSlide == null || !Objects.equals(audioSlide.getUri(), audio.getUri())) { + disposable.dispose(); + disposable = AudioWaveForms.getWaveForm(getContext(), audio.asAttachment()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + data -> { + durationMillis = data.getDuration(TimeUnit.MILLISECONDS); + updateProgress(0, 0); + if (!forceHideDuration && duration != null) { + duration.setVisibility(VISIBLE); + } + waveFormView.setWaveData(data.getWaveForm()); + }, + t -> waveFormView.setWaveMode(false) + ); + } } else { waveFormView.setWaveMode(false); if (duration != null) { @@ -243,6 +243,8 @@ public final class AudioView extends FrameLayout { if (forceHideDuration && duration != null) { duration.setVisibility(View.GONE); } + + this.audioSlide = audio; } public void setDownloadClickListener(@Nullable SlideClickListener listener) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt index 0e3eb22a5c..3073b0ad99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView import org.thoughtcrime.securesms.components.reminder.Reminder import org.thoughtcrime.securesms.components.reminder.ReminderView +import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView import org.thoughtcrime.securesms.database.identity.IdentityRecordList import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.groups.GroupId @@ -52,6 +53,7 @@ class ConversationBannerView @JvmOverloads constructor( private val unverifiedBannerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.unverified_banner_stub) } private val reminderStub: Stub by lazy { ViewUtil.findStubById(this, R.id.reminder_stub) } private val reviewBannerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.review_banner_stub) } + private val voiceNotePlayerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.voice_note_player_stub) } var listener: Listener? = null @@ -140,6 +142,20 @@ class ConversationBannerView @JvmOverloads constructor( hide(reviewBannerStub) } + fun showVoiceNotePlayer(state: VoiceNotePlayerView.State, voiceNotePlayerViewListener: VoiceNotePlayerView.Listener) { + show( + stub = voiceNotePlayerStub + ) { + val playerView: VoiceNotePlayerView = findViewById(R.id.voice_note_player_view) + playerView.listener = voiceNotePlayerViewListener + playerView.setState(state) + } + } + + fun clearVoiceNotePlayer() { + hide(voiceNotePlayerStub) + } + private fun show(stub: Stub, bind: V.() -> Unit = {}) { TransitionManager.beginDelayedTransition(this, Slide(Gravity.TOP)) stub.get().bind() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 71e182dc77..5bd8aa6b60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -125,6 +125,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.ConversationS import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState +import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey.RecipientSearchKey import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.contactshare.ContactUtil @@ -416,6 +417,10 @@ class ConversationFragment : ) } + private val voiceNotePlayerListener: VoiceNotePlayerView.Listener by lazy { + VoiceNotePlayerViewListener() + } + private val conversationTooltips = ConversationTooltips(this) private val colorizer = Colorizer() private val textDraftSaveDebouncer = Debouncer(500) @@ -930,6 +935,18 @@ class ConversationFragment : .addTo(disposables) presentTypingIndicator() + + getVoiceNoteMediaController().finishPostpone() + + getVoiceNoteMediaController() + .voiceNotePlayerViewState + .observe(viewLifecycleOwner) { state: Optional -> + if (state.isPresent) { + binding.conversationBanner.showVoiceNotePlayer(state.get(), voiceNotePlayerListener) + } else { + binding.conversationBanner.clearVoiceNotePlayer() + } + } } private fun initializeInlineSearch() { @@ -3793,4 +3810,39 @@ class ConversationFragment : draftViewModel.saveEphemeralVoiceNoteDraft(draft.asDraft()) } } + + private inner class VoiceNotePlayerViewListener : VoiceNotePlayerView.Listener { + override fun onCloseRequested(uri: Uri) { + getVoiceNoteMediaController().stopPlaybackAndReset(uri) + } + + override fun onSpeedChangeRequested(uri: Uri, speed: Float) { + getVoiceNoteMediaController().setPlaybackSpeed(uri, speed) + } + + override fun onPlay(uri: Uri, messageId: Long, position: Double) { + getVoiceNoteMediaController().startSinglePlayback(uri, messageId, position) + } + + override fun onPause(uri: Uri) { + getVoiceNoteMediaController().pausePlayback(uri) + } + + override fun onNavigateToMessage(threadId: Long, threadRecipientId: RecipientId, senderId: RecipientId, messageTimestamp: Long, messagePositionInThread: Long) { + if (threadId != viewModel.threadId) { + startActivity( + ConversationIntents.createBuilderSync(requireActivity(), threadRecipientId, threadId) + .withStartingPosition(messagePositionInThread.toInt()) + .build() + ) + } else { + viewModel + .moveToMessage(messageTimestamp, senderId) + .subscribeBy { + moveToPosition(it) + } + .addTo(disposables) + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index 33545065e9..fbed431974 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -320,9 +320,9 @@ class ConversationRepository( }.subscribeOn(Schedulers.io()) } - fun getMessagePosition(threadId: Long, messageRecord: MessageRecord): Single { + fun getMessagePosition(threadId: Long, dateReceived: Long, authorId: RecipientId): Single { return Single.fromCallable { - SignalDatabase.messages.getMessagePositionInConversation(threadId, messageRecord.dateReceived) + SignalDatabase.messages.getMessagePositionInConversation(threadId, dateReceived, authorId) }.subscribeOn(Schedulers.io()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 52e74933b7..ae0899d2fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -262,8 +262,13 @@ class ConversationViewModel( return repository.getNextMentionPosition(threadId) } + fun moveToMessage(dateReceived: Long, author: RecipientId): Single { + return repository.getMessagePosition(threadId, dateReceived, author) + .observeOn(AndroidSchedulers.mainThread()) + } + fun moveToMessage(messageRecord: MessageRecord): Single { - return repository.getMessagePosition(threadId, messageRecord) + return repository.getMessagePosition(threadId, messageRecord.dateReceived, messageRecord.fromRecipient.id) .observeOn(AndroidSchedulers.mainThread()) } diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index 9234245fd3..be3a49dddb 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -4,6 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipChildren="false" app:animateKeyboardChanges="true"> @@ -138,6 +139,13 @@ app:layout_constraintStart_toStartOf="@id/parent_start_guideline" app:layout_constraintTop_toBottomOf="@+id/toolbar"> + + , T : Any> Observable.subscribeWithSubject( return subject } + +fun , T : Any> Single.subscribeWithSubject( + subject: S, + disposables: CompositeDisposable +): S { + subscribeBy( + onSuccess = { + subject.onNext(it) + subject.onComplete() + }, + onError = subject::onError + ).addTo(disposables) + + return subject +}