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 8ab6e19637..7957e6aca8 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 @@ -89,6 +89,7 @@ import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.MuteDialog import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.badges.gifts.OpenableGift import org.thoughtcrime.securesms.badges.gifts.OpenableGiftItemDecoration import org.thoughtcrime.securesms.badges.gifts.flow.GiftFlowActivity @@ -112,6 +113,7 @@ import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearL import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity +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.contactshare.Contact @@ -200,6 +202,7 @@ import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.messagerequests.MessageRequestState import org.thoughtcrime.securesms.mms.AttachmentManager +import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.QuoteModel @@ -380,6 +383,7 @@ class ConversationFragment : get() = binding.conversationSearchBottomBar.root private lateinit var reactionDelegate: ConversationReactionDelegate + private lateinit var voiceMessageRecordingDelegate: VoiceMessageRecordingDelegate override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -603,6 +607,12 @@ class ConversationFragment : reactionDelegate = ConversationReactionDelegate(conversationReactionStub) reactionDelegate.setOnReactionSelectedListener(OnReactionsSelectedListener()) + voiceMessageRecordingDelegate = VoiceMessageRecordingDelegate( + this, + AudioRecorder(requireContext(), inputPanel), + VoiceMessageRecordingSessionCallbacks() + ) + binding.conversationBanner.listener = ConversationBannerListener() viewModel .reminder @@ -658,6 +668,8 @@ class ConversationFragment : .addTo(disposables) initializeSearch() + + inputPanel.setListener(InputPanelListener()) } private fun presentInputReadyState(inputReadyState: InputReadyState) { @@ -2717,23 +2729,32 @@ class ConversationFragment : } override fun onRecorderStarted() { - // TODO [cfv2] Not yet implemented + voiceMessageRecordingDelegate.onRecorderStarted() } override fun onRecorderLocked() { - // TODO [cfv2] Not yet implemented + updateToggleButtonState() + voiceMessageRecordingDelegate.onRecorderLocked() } override fun onRecorderFinished() { - // TODO [cfv2] Not yet implemented + updateToggleButtonState() + voiceMessageRecordingDelegate.onRecorderFinished() } override fun onRecorderCanceled(byUser: Boolean) { - // TODO [cfv2] Not yet implemented + updateToggleButtonState() + voiceMessageRecordingDelegate.onRecorderCanceled(byUser) } override fun onRecorderPermissionRequired() { - // TODO [cfv2] Not yet implemented + Permissions + .with(this@ConversationFragment) + .request(Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withRationaleDialog(getString(R.string.ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone), R.drawable.ic_mic_solid_24) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages)) + .execute() } override fun onEmojiToggle() { @@ -2867,4 +2888,26 @@ class ConversationFragment : } } } + + private inner class VoiceMessageRecordingSessionCallbacks : VoiceMessageRecordingDelegate.SessionCallback { + override fun onSessionWillBegin() { + getVoiceNoteMediaController().pausePlayback() + } + + override fun sendVoiceNote(draft: VoiceNoteDraft) { + val audioSlide = AudioSlide(requireContext(), draft.uri, draft.size, MediaUtil.AUDIO_AAC, true) + + sendMessageWithoutComposeInput( + slide = audioSlide + ) + } + + override fun cancelEphemeralVoiceNoteDraft(draft: VoiceNoteDraft) { + draftViewModel.cancelEphemeralVoiceNoteDraft(draft.asDraft()) + } + + override fun saveEphemeralVoiceNoteDraft(draft: VoiceNoteDraft) { + draftViewModel.cancelEphemeralVoiceNoteDraft(draft.asDraft()) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/VoiceMessageRecordingDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/VoiceMessageRecordingDelegate.kt new file mode 100644 index 0000000000..68e09afe4b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/VoiceMessageRecordingDelegate.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.pm.ActivityInfo +import android.view.WindowManager +import android.widget.Toast +import androidx.fragment.app.Fragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.core.SingleObserver +import io.reactivex.rxjava3.disposables.Disposable +import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.concurrent.addTo +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.audio.AudioRecorder +import org.thoughtcrime.securesms.audio.BluetoothVoiceNoteUtil +import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft +import org.thoughtcrime.securesms.conversation.VoiceRecorderWakeLock +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.util.ServiceUtil +import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat + +/** + * Delegate class for VoiceMessage recording. + */ +class VoiceMessageRecordingDelegate( + private val fragment: Fragment, + private val audioRecorder: AudioRecorder, + private val sessionCallback: SessionCallback +) { + + companion object { + private val TAG = Log.tag(VoiceMessageRecordingDelegate::class.java) + } + + private val disposables = LifecycleDisposable().apply { + bindTo(fragment.viewLifecycleOwner) + } + + private val voiceRecorderWakeLock = VoiceRecorderWakeLock(fragment.requireActivity()) + private val bluetoothVoiceNoteUtil = BluetoothVoiceNoteUtil.create( + fragment.requireContext(), + this::beginRecording, + this::onBluetoothPermissionDenied + ) + + private var session: Session? = null + + fun onRecorderStarted() { + val audioManager: AudioManagerCompat = ApplicationDependencies.getAndroidCallAudioManager() + if (audioManager.isBluetoothHeadsetAvailable) { + connectToBluetoothAndBeginRecording() + } else { + Log.d(TAG, "Recording from phone mic because no bluetooth devices were available.") + beginRecording() + } + } + + fun onRecorderLocked() { + voiceRecorderWakeLock.acquire() + fragment.requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + + fun onRecorderFinished() { + bluetoothVoiceNoteUtil.disconnectBluetoothScoConnection() + voiceRecorderWakeLock.release() + vibrateAndResetOrientation(20) + session?.completeRecording() + } + + fun onRecorderCanceled(byUser: Boolean) { + bluetoothVoiceNoteUtil.disconnectBluetoothScoConnection() + voiceRecorderWakeLock.release() + vibrateAndResetOrientation(50) + + if (byUser) { + session?.discardRecording() + } else { + session?.saveDraft() + } + } + + @Suppress("DEPRECATION") + private fun vibrateAndResetOrientation(milliseconds: Long) { + val activity = fragment.activity + if (activity != null) { + val vibrator = ServiceUtil.getVibrator(activity) + vibrator.vibrate(milliseconds) + + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + + private fun connectToBluetoothAndBeginRecording() { + Log.d(TAG, "Initiating Bluetooth SCO connection...") + bluetoothVoiceNoteUtil.connectBluetoothScoConnection() + } + + @Suppress("DEPRECATION") + private fun beginRecording() { + val vibrator = ServiceUtil.getVibrator(fragment.requireContext()) + vibrator.vibrate(20) + + fragment.requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + fragment.requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED + + sessionCallback.onSessionWillBegin() + session = Session(audioRecorder.startRecording(), sessionCallback).apply { + addTo(disposables) + } + } + + private fun onBluetoothPermissionDenied() { + MaterialAlertDialogBuilder(fragment.requireContext()) + .setTitle(R.string.ConversationParentFragment__bluetooth_permission_denied) + .setMessage(R.string.ConversationParentFragment__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call) + .setPositiveButton(R.string.ConversationParentFragment__open_settings) { _, _ -> fragment.startActivity(Permissions.getApplicationSettingsIntent(fragment.requireContext())) } + .setNegativeButton(R.string.ConversationParentFragment__not_now, null) + .show() + } + + interface SessionCallback { + fun onSessionWillBegin() + fun sendVoiceNote(draft: VoiceNoteDraft) + fun cancelEphemeralVoiceNoteDraft(draft: VoiceNoteDraft) + fun saveEphemeralVoiceNoteDraft(draft: VoiceNoteDraft) + } + + private inner class Session( + observable: Single, + private val sessionCallback: SessionCallback + ) : SingleObserver, Disposable { + + private var saveDraft = true + private var shouldSend = false + private var disposable = Disposable.empty() + + init { + observable + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this) + } + + override fun onSubscribe(d: Disposable) { + disposable = d + } + + override fun onSuccess(draft: VoiceNoteDraft) { + when { + shouldSend -> sessionCallback.sendVoiceNote(draft) + !saveDraft -> sessionCallback.cancelEphemeralVoiceNoteDraft(draft) + else -> sessionCallback.saveEphemeralVoiceNoteDraft(draft) + } + + session?.dispose() + session = null + } + + override fun onError(e: Throwable) { + Toast.makeText(fragment.requireContext(), R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show() + Log.e(TAG, "Error in RecordingSession.", e) + + session?.dispose() + session = null + } + + override fun dispose() = disposable.dispose() + + override fun isDisposed(): Boolean = disposable.isDisposed + + fun completeRecording() { + shouldSend = true + audioRecorder.stopRecording() + } + + fun discardRecording() { + saveDraft = false + shouldSend = false + audioRecorder.stopRecording() + } + + fun saveDraft() { + saveDraft = true + shouldSend = false + audioRecorder.stopRecording() + } + } +}