From d26697b932902284ad1bc04c9abf435f8baff3ee Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Tue, 24 Dec 2024 11:58:27 -0600 Subject: [PATCH] Abstracting away media player functionality to MediaPlayerManager - Most code removed from ChatActivity - Most work in MediaPlayerManager - Added BackgroundVoiceMessageCard Signed-off-by: rapterjet2004 --- .../IncomingVoiceMessageViewHolder.kt | 25 +- .../OutcomingVoiceMessageViewHolder.kt | 24 +- .../com/nextcloud/talk/chat/ChatActivity.kt | 404 ++------- .../MessageInputVoiceRecordingFragment.kt | 24 +- .../talk/chat/data/io/MediaPlayerManager.kt | 247 +++++- .../talk/chat/viewmodels/ChatViewModel.kt | 58 +- .../chat/viewmodels/MessageInputViewModel.kt | 5 +- .../ConversationsListActivity.kt | 104 ++- .../talk/dagger/modules/ManagerModule.kt | 7 +- .../talk/dagger/modules/ViewModelModule.kt | 4 +- .../talk/ui/BackgroundVoiceMessageCard.kt | 195 +++++ .../utils/preferences/AppPreferences.java | 10 +- .../utils/preferences/AppPreferencesImpl.kt | 52 +- .../res/layout/activity_conversations.xml | 5 + app/src/main/res/values/strings.xml | 2 - gradle/verification-keyring.keys | 771 ++++++++++-------- gradle/verification-metadata.xml | 7 +- scripts/analysis/lint-results.txt | 2 +- 18 files changed, 1126 insertions(+), 820 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt index 2eb35a86e..89a0a2e30 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt @@ -36,7 +36,9 @@ import com.nextcloud.talk.utils.preferences.AppPreferences import com.stfalcon.chatkit.messages.MessageHolders import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.ExecutionException @@ -61,9 +63,8 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : @Inject lateinit var dateUtils: DateUtils - @JvmField @Inject - var appPreferences: AppPreferences? = null + lateinit var appPreferences: AppPreferences lateinit var message: ChatMessage @@ -83,7 +84,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : sharedApplication!!.componentApplication.inject(this) val filename = message.selectedIndividualHashMap!!["name"] - val retrieved = appPreferences!!.getWaveFormFromFile(filename) + val retrieved = appPreferences.getWaveFormFromFile(filename) if (retrieved.isNotEmpty() && message.voiceMessageFloatArray == null || message.voiceMessageFloatArray?.isEmpty() == true @@ -103,7 +104,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : setParentMessageDataOnMessageItem(message) updateDownloadState(message) - binding.seekbar.max = message.voiceMessageDuration * ONE_SEC + binding.seekbar.max = MAX viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar) viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT) @@ -139,10 +140,16 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : } }) - voiceMessageInterface.registerMessageToObservePlaybackSpeedPreferences(message.user.id) { speed -> - binding.playbackSpeedControlBtn.setSpeed(speed) + CoroutineScope(Dispatchers.Default).launch { + (voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed -> + withContext(Dispatchers.Main) { + binding.playbackSpeedControlBtn.setSpeed(speed) + } + }.collect() } + binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId)) + Reaction().showReactions( message, ::clickOnReaction, @@ -158,9 +165,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : private fun showVoiceMessageDuration(message: ChatMessage) { if (message.voiceMessageDuration > 0) { - binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime( - message.voiceMessageDuration.toLong() - ) binding.voiceMessageDuration.visibility = View.VISIBLE } else { binding.voiceMessageDuration.visibility = View.INVISIBLE @@ -200,7 +204,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : val t = message.voiceMessagePlayedSeconds.toLong() binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t) binding.voiceMessageDuration.visibility = View.VISIBLE - binding.seekbar.max = message.voiceMessageDuration * ONE_SEC binding.seekbar.progress = message.voiceMessageSeekbarProgress } else { showVoiceMessageDuration(message) @@ -372,6 +375,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : companion object { private const val TAG = "VoiceInMessageView" private const val SEEKBAR_START: Int = 0 - private const val ONE_SEC: Int = 1000 + private const val MAX: Int = 100 } } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt index 7e2d04241..184c09664 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt @@ -38,7 +38,9 @@ import com.nextcloud.talk.utils.preferences.AppPreferences import com.stfalcon.chatkit.messages.MessageHolders import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.ExecutionException @@ -65,9 +67,8 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : @Inject lateinit var dateUtils: DateUtils - @JvmField @Inject - var appPreferences: AppPreferences? = null + lateinit var appPreferences: AppPreferences lateinit var message: ChatMessage @@ -90,7 +91,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT) val filename = message.selectedIndividualHashMap!!["name"] - val retrieved = appPreferences!!.getWaveFormFromFile(filename) + val retrieved = appPreferences.getWaveFormFromFile(filename) if (retrieved.isNotEmpty() && message.voiceMessageFloatArray == null || message.voiceMessageFloatArray?.isEmpty() == true @@ -99,6 +100,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : binding.seekbar.setWaveData(message.voiceMessageFloatArray!!) } + binding.seekbar.max = MAX binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp) colorizeMessageBubble(message) @@ -136,10 +138,16 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : setReadStatus(message.readStatus) - voiceMessageInterface.registerMessageToObservePlaybackSpeedPreferences(message.user.id) { speed -> - binding.playbackSpeedControlBtn.setSpeed(speed) + CoroutineScope(Dispatchers.Default).launch { + (voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed -> + withContext(Dispatchers.Main) { + binding.playbackSpeedControlBtn.setSpeed(speed) + } + }.collect() } + binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId)) + Reaction().showReactions( message, ::clickOnReaction, @@ -199,9 +207,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : private fun showVoiceMessageDuration(message: ChatMessage) { if (message.voiceMessageDuration > 0) { - binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime( - message.voiceMessageDuration.toLong() - ) binding.voiceMessageDuration.visibility = View.VISIBLE } else { binding.voiceMessageDuration.visibility = View.INVISIBLE @@ -234,7 +239,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : val t = message.voiceMessagePlayedSeconds.toLong() binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t) binding.voiceMessageDuration.visibility = View.VISIBLE - binding.seekbar.max = message.voiceMessageDuration * ONE_SEC binding.seekbar.progress = message.voiceMessageSeekbarProgress } else { showVoiceMessageDuration(message) @@ -377,6 +381,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : companion object { private const val TAG = "VoiceOutMessageView" private const val SEEKBAR_START: Int = 0 - private const val ONE_SEC: Int = 1000 + private const val MAX = 100 } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 01160ae16..cbbcde937 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -27,8 +27,6 @@ import android.database.Cursor import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable -import android.media.MediaMetadataRetriever -import android.media.MediaPlayer import android.net.Uri import android.os.Build import android.os.Bundle @@ -332,24 +330,12 @@ class ChatActivity : private val filesToUpload: MutableList = ArrayList() lateinit var sharedText: String - var mediaPlayer: MediaPlayer? = null - var mediaPlayerHandler: Handler? = null - - private var currentlyPlayedVoiceMessage: ChatMessage? = null - - // messy workaround for a mediaPlayer bug, don't delete - private var lastRecordMediaPosition: Int = 0 - private var lastRecordedSeeked: Boolean = false - lateinit var participantPermissions: ParticipantPermissions private var videoURI: Uri? = null private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (currentlyPlayedVoiceMessage != null) { - stopMediaPlayer(currentlyPlayedVoiceMessage!!) - } val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java) intent.putExtras(Bundle()) startActivity(intent) @@ -361,22 +347,6 @@ class ChatActivity : val typingParticipants = HashMap() var callStarted = false - private var voiceMessageToRestoreId = "" - private var voiceMessageToRestoreAudioPosition = 0 - private var voiceMessageToRestoreWasPlaying = false - - private val playbackSpeedPreferencesObserver: (Map) -> Unit = { speedPreferenceLiveData -> - mediaPlayer?.let { mediaPlayer -> - (mediaPlayer.isPlaying == true).also { - currentlyPlayedVoiceMessage?.let { message -> - mediaPlayer.playbackParams.let { params -> - params.setSpeed(chatViewModel.getPlaybackSpeedPreference(message).value) - mediaPlayer.playbackParams = params - } - } - } - } - } private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener { override fun onSwitchTo(token: String?) { @@ -458,35 +428,7 @@ class ChatActivity : onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - appPreferences.readVoiceMessagePlaybackSpeedPreferences().let { playbackSpeedPreferences -> - chatViewModel.applyPlaybackSpeedPreferences(playbackSpeedPreferences) - } - initObservers() - - if (savedInstanceState != null) { - // Restore value of members from saved state - var voiceMessageId = savedInstanceState.getString(CURRENT_AUDIO_MESSAGE_KEY, "") - var voiceMessagePosition = savedInstanceState.getInt(CURRENT_AUDIO_POSITION_KEY, 0) - var wasAudioPLaying = savedInstanceState.getBoolean(CURRENT_AUDIO_WAS_PLAYING_KEY, false) - if (!voiceMessageId.equals("")) { - Log.d(RESUME_AUDIO_TAG, "restored voice messageID: " + voiceMessageId) - Log.d(RESUME_AUDIO_TAG, "audio position: " + voiceMessagePosition) - Log.d(RESUME_AUDIO_TAG, "audio was playing: " + wasAudioPLaying.toString()) - voiceMessageToRestoreId = voiceMessageId - voiceMessageToRestoreAudioPosition = voiceMessagePosition - voiceMessageToRestoreWasPlaying = wasAudioPLaying - } else { - Log.d(RESUME_AUDIO_TAG, "stored voice message id is empty, not resuming audio playing") - voiceMessageToRestoreId = "" - voiceMessageToRestoreAudioPosition = 0 - voiceMessageToRestoreWasPlaying = false - } - } else { - voiceMessageToRestoreId = "" - voiceMessageToRestoreAudioPosition = 0 - voiceMessageToRestoreWasPlaying = false - } } private fun getMessageInputFragment(): MessageInputFragment { @@ -551,17 +493,6 @@ class ChatActivity : } override fun onSaveInstanceState(outState: Bundle) { - if (currentlyPlayedVoiceMessage != null) { - outState.putString(CURRENT_AUDIO_MESSAGE_KEY, currentlyPlayedVoiceMessage!!.id) - outState.putInt(CURRENT_AUDIO_POSITION_KEY, currentlyPlayedVoiceMessage!!.voiceMessagePlayedSeconds) - outState.putBoolean(CURRENT_AUDIO_WAS_PLAYING_KEY, currentlyPlayedVoiceMessage!!.isPlayingVoiceMessage) - Log.d(RESUME_AUDIO_TAG, "Stored current audio message ID: " + currentlyPlayedVoiceMessage!!.id) - Log.d( - RESUME_AUDIO_TAG, - "Audio Position: " + currentlyPlayedVoiceMessage!!.voiceMessagePlayedSeconds - .toString() + " | isPLaying: " + currentlyPlayedVoiceMessage!!.isPlayingVoiceMessage - ) - } chatViewModel.handleOrientationChange() super.onSaveInstanceState(outState) } @@ -933,6 +864,12 @@ class ChatActivity : }.collect() } + this.lifecycleScope.launch { + chatViewModel.mediaPlayerSeekbarObserver.onEach { msg -> + adapter?.update(msg) + }.collect() + } + chatViewModel.reactionDeletedViewState.observe(this) { state -> when (state) { is ChatViewModel.ReactionDeletedSuccessState -> { @@ -1171,8 +1108,6 @@ class ChatActivity : setupSwipeToReply() - chatViewModel.voiceMessagePlaybackSpeedPreferences.observe(this, playbackSpeedPreferencesObserver) - binding.unreadMessagesPopup.setOnClickListener { binding.messagesListView.smoothScrollToPosition(0) binding.unreadMessagesPopup.visibility = View.GONE @@ -1267,13 +1202,15 @@ class ChatActivity : val file = File(context.cacheDir, filename!!) if (file.exists()) { if (message.isPlayingVoiceMessage) { - pausePlayback(message) + chatViewModel.pauseMediaPlayer(true) + message.isPlayingVoiceMessage = false + adapter?.update(message) } else { val retrieved = appPreferences.getWaveFormFromFile(filename) if (retrieved.isEmpty()) { setUpWaveform(message) } else { - startPlayback(message) + startPlayback(file, message) } } } else { @@ -1286,11 +1223,8 @@ class ChatActivity : adapter?.registerViewClickListener(R.id.playbackSpeedControlBtn) { button, message -> val nextSpeed = (button as PlaybackSpeedControl).getSpeed().next() - HashMap(appPreferences.readVoiceMessagePlaybackSpeedPreferences()).let { playbackSpeedPreferences -> - playbackSpeedPreferences[message.user.id] = nextSpeed - chatViewModel.applyPlaybackSpeedPreferences(playbackSpeedPreferences) - appPreferences.saveVoiceMessagePlaybackSpeedPreferences(playbackSpeedPreferences) - } + chatViewModel.setPlayBack(nextSpeed) + appPreferences.savePreferredPlayback(conversationUser!!.userId, nextSpeed) } } @@ -1305,14 +1239,37 @@ class ChatActivity : appPreferences.saveWaveFormForFile(filename, r.toTypedArray()) message.voiceMessageFloatArray = r withContext(Dispatchers.Main) { - startPlayback(message, thenPlay, backgroundPlayAllowed) + startPlayback(file, message) } } } else { - startPlayback(message, thenPlay, backgroundPlayAllowed) + startPlayback(file, message) } } + private fun startPlayback(file: File, message: ChatMessage) { + chatViewModel.clearMediaPlayerQueue() + chatViewModel.queueInMediaPlayer(file.canonicalPath, message) + chatViewModel.startCyclingMediaPlayer() + message.isPlayingVoiceMessage = true + adapter?.update(message) + + var pos = adapter?.getMessagePositionById(message.id)!! - 1 + do { + if (pos < 0) break + val nextItem = (adapter?.items?.get(pos)?.item) ?: break + val nextMessage = if (nextItem is ChatMessage) nextItem else break + if (!nextMessage.isVoiceMessage) break + + downloadFileToCache(nextMessage, false) { + val newFilename = nextMessage.selectedIndividualHashMap!!["name"] + val newFile = File(context.cacheDir, newFilename!!) + chatViewModel.queueInMediaPlayer(newFile.canonicalPath, nextMessage) + } + pos-- + } while (true && pos >= 0) + } + private fun initMessageHolders(): MessageHolders { val messageHolders = MessageHolders() val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!, viewThemeUtils) @@ -1692,253 +1649,20 @@ class ChatActivity : } } - @Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod", "Detekt.NestedBlockDepth") - private fun startPlayback(message: ChatMessage, doPlay: Boolean = true, backgroundPlayAllowed: Boolean = false) { - if (!active && !backgroundPlayAllowed) { - // don't begin to play voice message if screen is not visible anymore. - // this situation might happen if file is downloading but user already left the chatview. - // If user returns to chatview, the old chatview instance is not attached anymore - // and he has to click the play button again (which is considered to be okay) - return - } - - initMediaPlayer(message) - - val id = message.id.toString() - val index = adapter?.getMessagePositionById(id) ?: 0 - - var nextMessage: ChatMessage? = null - for (i in VOICE_MESSAGE_CONTINUOUS_BEFORE..VOICE_MESSAGE_CONTINUOUS_AFTER) { - if (index - i < 0) { - break - } - if (i == 0 || index - i >= (adapter?.items?.size ?: 0)) { - continue - } - val curMsg = adapter?.items?.getOrNull(index - i)?.item - if (curMsg is ChatMessage) { - if (nextMessage == null && i > 0) { - nextMessage = curMsg - } - - if (curMsg.isVoiceMessage) { - if (curMsg.selectedIndividualHashMap == null) { - // WORKAROUND TO FETCH FILE INFO: - curMsg.getImageUrl() - } - val filename = curMsg.selectedIndividualHashMap!!["name"] - val file = File(context.cacheDir, filename!!) - if (!file.exists()) { - downloadFileToCache(curMsg, false) { - curMsg.isDownloadingVoiceMessage = false - curMsg.voiceMessageDuration = try { - val retriever = MediaMetadataRetriever() - retriever.setDataSource(file.absolutePath) // Set the audio file as the data source - val durationStr = - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - retriever.release() // Always release the retriever to free resources - (durationStr?.toIntOrNull() ?: 0) / ONE_SECOND_IN_MILLIS // Convert to int (seconds) - } catch (e: RuntimeException) { - Log.e( - TAG, - "An exception occurred while computing " + - "voice message duration for " + filename, - e - ) - 0 - } - adapter?.update(curMsg) - } - } else { - if (curMsg.voiceMessageDuration == 0) { - curMsg.voiceMessageDuration = try { - val retriever = MediaMetadataRetriever() - retriever.setDataSource(file.absolutePath) // Set the audio file as the data source - val durationStr = - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - retriever.release() // Always release the retriever to free resources - (durationStr?.toIntOrNull() ?: 0) / ONE_SECOND_IN_MILLIS // Convert to int (seconds) - } catch (e: RuntimeException) { - Log.e( - TAG, - "An exception occurred while computing " + - "voice message duration for " + filename, - e - ) - 0 - } - adapter?.update(curMsg) - } - } - } - } - } - - val hasConsecutiveVoiceMessage = if (nextMessage != null) nextMessage.isVoiceMessage else false - - mediaPlayer?.let { - if (!it.isPlaying && doPlay) { - chatViewModel.audioRequest(true) { - it.playbackParams = it.playbackParams.apply { - setSpeed(chatViewModel.getPlaybackSpeedPreference(message).value) - } - it.start() - } - } - - mediaPlayerHandler = Handler() - runOnUiThread(object : Runnable { - override fun run() { - if (mediaPlayer != null) { - if (message.isPlayingVoiceMessage) { - val pos = mediaPlayer!!.currentPosition.toFloat() / VOICE_MESSAGE_SEEKBAR_BASE - if (pos + VOICE_MESSAGE_PLAY_ADD_THRESHOLD < ( - mediaPlayer!!.duration.toFloat() / VOICE_MESSAGE_SEEKBAR_BASE - ) - ) { - lastRecordMediaPosition = mediaPlayer!!.currentPosition - message.voiceMessagePlayedSeconds = pos.toInt() - message.voiceMessageSeekbarProgress = mediaPlayer!!.currentPosition - if (mediaPlayer!!.currentPosition * VOICE_MESSAGE_MARK_PLAYED_FACTOR > - mediaPlayer!!.duration - ) { - // a voice message is marked as played when the mediaplayer position - // is at least at 5% of its duration - message.wasPlayedVoiceMessage = true - } - adapter?.update(message) - } else { - message.resetVoiceMessage = true - message.voiceMessagePlayedSeconds = 0 - message.voiceMessageSeekbarProgress = 0 - adapter?.update(message) - stopMediaPlayer(message) - if (hasConsecutiveVoiceMessage) { - val defaultMediaPlayer = MediaPlayer.create( - context, - R.raw - .next_voice_message_doodle - ) - defaultMediaPlayer.setOnCompletionListener { - defaultMediaPlayer.release() - setUpWaveform(nextMessage as ChatMessage, doPlay, true) - } - defaultMediaPlayer.start() - } - } - } - } - mediaPlayerHandler?.postDelayed(this, MILLISEC_15) - } - }) - - message.isDownloadingVoiceMessage = false - message.isPlayingVoiceMessage = doPlay - // message.voiceMessagePlayedSeconds = lastRecordMediaPosition / VOICE_MESSAGE_SEEKBAR_BASE - // message.voiceMessageSeekbarProgress = lastRecordMediaPosition - // the commented instructions objective was to update audio seekbarprogress - // in the case in which audio status is paused when the position is resumed - adapter?.update(message) - } - } - - private fun pausePlayback(message: ChatMessage) { - if (mediaPlayer!!.isPlaying) { - chatViewModel.audioRequest(false) { - mediaPlayer!!.pause() - } - } - - message.isPlayingVoiceMessage = false - adapter?.update(message) - } - - @Suppress("Detekt.TooGenericExceptionCaught") - private fun initMediaPlayer(message: ChatMessage) { - if (message != currentlyPlayedVoiceMessage) { - currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) } - } - - if (mediaPlayer == null) { - val fileName = message.selectedIndividualHashMap!!["name"] - val absolutePath = context.cacheDir.absolutePath + "/" + fileName - - try { - mediaPlayer = MediaPlayer().apply { - setDataSource(absolutePath) - prepare() - setOnPreparedListener { - currentlyPlayedVoiceMessage = message - message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE - lastRecordedSeeked = false - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setOnMediaTimeDiscontinuityListener { mp, _ -> - if (lastRecordMediaPosition > ONE_SECOND_IN_MILLIS && !lastRecordedSeeked) { - mp.seekTo(lastRecordMediaPosition) - lastRecordedSeeked = true - } - } - // this ensures that audio can be resumed at a given position - this.seekTo(lastRecordMediaPosition) - } - setOnCompletionListener { - stopMediaPlayer(message) - } - } - } catch (e: Exception) { - Log.e(TAG, "failed to initialize mediaPlayer", e) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - } - } - - private fun stopMediaPlayer(message: ChatMessage) { - message.isPlayingVoiceMessage = false - message.resetVoiceMessage = true - adapter?.update(message) - - currentlyPlayedVoiceMessage = null - lastRecordMediaPosition = 0 // this ensures that if audio track is changed, then it is played from the beginning - - mediaPlayerHandler?.removeCallbacksAndMessages(null) - - try { - mediaPlayer?.let { - if (it.isPlaying) { - Log.d(TAG, "media player is stopped") - chatViewModel.audioRequest(false) { - it.stop() - } - } - } - } catch (e: IllegalStateException) { - Log.e(TAG, "mediaPlayer was not initialized", e) - } finally { - mediaPlayer?.release() - mediaPlayer = null - } - } - - override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) { - if (mediaPlayer != null) { - if (messageWithSlidedProgress == currentlyPlayedVoiceMessage) { - mediaPlayer!!.seekTo(progress) - } - } + override fun updateMediaPlayerProgressBySlider(message: ChatMessage, progress: Int) { + chatViewModel.seekToMediaPlayer(progress) } override fun registerMessageToObservePlaybackSpeedPreferences( userId: String, listener: (speed: PlaybackSpeed) -> Unit ) { - chatViewModel.voiceMessagePlaybackSpeedPreferences.let { liveData -> - liveData.observe(this) { playbackSpeedPreferences -> - listener(playbackSpeedPreferences[userId] ?: PlaybackSpeed.NORMAL) - } - liveData.value?.let { playbackSpeedPreferences -> - listener(playbackSpeedPreferences[userId] ?: PlaybackSpeed.NORMAL) - } + CoroutineScope(Dispatchers.Default).launch { + chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed -> + withContext(Dispatchers.Main) { + listener(speed) + } + }.collect() } } @@ -2610,8 +2334,6 @@ class ChatActivity : if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() } - - chatViewModel.voiceMessagePlaybackSpeedPreferences.removeObserver(playbackSpeedPreferencesObserver) } private fun isActivityNotChangingConfigurations(): Boolean = !isChangingConfigurations @@ -2677,8 +2399,7 @@ class ChatActivity : actionBar?.setIcon(null) } - currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) } // FIXME, mediaplayer can sometimes be null here - + adapter = null disposables.dispose() } @@ -2972,8 +2693,6 @@ class ChatActivity : adapter?.addToEnd(chatMessageList, false) } scrollToRequestedMessageIfNeeded() - // FENOM: add here audio resume policy - resumeAudioPlaybackIfNeeded() } private fun scrollToFirstUnreadMessage() { @@ -3036,37 +2755,6 @@ class ChatActivity : } } - /** - * this method must be called after that the adapter has finished loading ChatMessages items - * it searches by ID the message that was playing,s - * then, if it finds it, it restores audio position - * and eventually resumes audio playback - * @author Giacomo Pacini - */ - private fun resumeAudioPlaybackIfNeeded() { - if (voiceMessageToRestoreId != "") { - Log.d(RESUME_AUDIO_TAG, "begin method to resume audio playback") - - val pair = getItemFromAdapter(voiceMessageToRestoreId) - currentlyPlayedVoiceMessage = pair?.first - val voiceMessagePosition = pair?.second!! - - lastRecordMediaPosition = voiceMessageToRestoreAudioPosition * ONE_SECOND_IN_MILLIS - Log.d(RESUME_AUDIO_TAG, "trying to resume audio") - binding.messagesListView.scrollToPosition(voiceMessagePosition) - // WORKAROUND TO FETCH FILE INFO: - currentlyPlayedVoiceMessage!!.getImageUrl() - // see getImageUrl() source code - setUpWaveform(currentlyPlayedVoiceMessage!!, voiceMessageToRestoreWasPlaying) - Log.d(RESUME_AUDIO_TAG, "resume audio procedure completed") - } else { - Log.d(RESUME_AUDIO_TAG, "No voice message to restore") - } - voiceMessageToRestoreId = "" - voiceMessageToRestoreAudioPosition = 0 - voiceMessageToRestoreWasPlaying = false - } - private fun getItemFromAdapter(messageId: String): Pair? { if (adapter != null) { val messagePosition = adapter!!.items!!.indexOfFirst { diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt index a5dc768c6..945622eec 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -16,6 +16,7 @@ import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.R @@ -24,6 +25,9 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.databinding.FragmentMessageInputVoiceRecordingBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -60,6 +64,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() + chatActivity.messageInputViewModel.stopMediaPlayer() // if it wasn't stopped already this.lifecycle.removeObserver(chatActivity.messageInputViewModel) } @@ -68,13 +73,16 @@ class MessageInputVoiceRecordingFragment : Fragment() { chatActivity.messageInputViewModel.micInputAudioObserver.observe(viewLifecycleOwner) { binding.micInputCloud.setRotationSpeed(it.first, it.second) } - chatActivity.messageInputViewModel.mediaPlayerSeekbarObserver.observe(viewLifecycleOwner) { progress -> - if (progress >= SEEK_LIMIT) { - togglePausePlay() - binding.seekbar.progress = 0 - } else if (!pause) { - binding.seekbar.progress = progress - } + + lifecycleScope.launch { + chatActivity.messageInputViewModel.mediaPlayerSeekbarObserver.onEach { progress -> + if (progress >= SEEK_LIMIT) { + togglePausePlay() + binding.seekbar.progress = 0 + } else if (!pause && chatActivity.messageInputViewModel.isVoicePreviewPlaying.value == true) { + binding.seekbar.progress = progress + } + }.collect() } chatActivity.messageInputViewModel.getAudioFocusChange.observe(viewLifecycleOwner) { state -> @@ -107,7 +115,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { binding.sendVoiceRecording.setOnClickListener { chatActivity.chatViewModel.stopAndSendAudioRecording( chatActivity.roomToken, - chatActivity.currentConversation!!.displayName!!, + chatActivity.currentConversation!!.displayName, MessageInputFragment.VOICE_MESSAGE_META_DATA ) clear() diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt index e610d2358..221b5228a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt @@ -9,44 +9,120 @@ package com.nextcloud.talk.chat.data.io import android.media.MediaPlayer import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.ui.PlaybackSpeed +import com.nextcloud.talk.utils.preferences.AppPreferences +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileNotFoundException +import kotlin.math.ceil /** * Abstraction over the [MediaPlayer](https://developer.android.com/reference/android/media/MediaPlayer) class used * to manage the MediaPlayer instance. */ +@Suppress("TooManyFunctions", "TooGenericExceptionCaught") class MediaPlayerManager : LifecycleAwareManager { companion object { val TAG: String = MediaPlayerManager::class.java.simpleName - private const val SEEKBAR_UPDATE_DELAY = 15L - const val DIVIDER = 100f + private const val SEEKBAR_UPDATE_DELAY = 150L + private const val ONE_SEC = 1000 + private const val DIVIDER = 100f + private const val IS_PLAYED_CUTOFF = 5 + + @JvmStatic + private val manager: MediaPlayerManager = MediaPlayerManager() + + fun sharedInstance(preferences: AppPreferences): MediaPlayerManager = + manager.apply { + appPreferences = preferences + } } + lateinit var appPreferences: AppPreferences + + enum class MediaPlayerManagerState { + DEFAULT, + SETUP, + STARTED, + STOPPED, + RESUMED, + PAUSED, + ERROR + } + + val backgroundPlayUIFlow: StateFlow + get() = _backgroundPlayUIFlow + private val _backgroundPlayUIFlow = MutableStateFlow(null) + + val managerState: Flow + get() = _managerState + private val _managerState = MutableStateFlow(MediaPlayerManagerState.DEFAULT) + + private val playQueue = mutableListOf>() + + val mediaPlayerSeekBarPositionMsg: Flow + get() = _mediaPlayerSeekBarPositionMsg + private val _mediaPlayerSeekBarPositionMsg: MutableSharedFlow = MutableSharedFlow() + + val mediaPlayerSeekBarPosition: Flow + get() = _mediaPlayerSeekBarPosition + private val _mediaPlayerSeekBarPosition: MutableSharedFlow = MutableSharedFlow() + private var mediaPlayer: MediaPlayer? = null - private var mediaPlayerPosition: Int = 0 private var loop = false private var scope = MainScope() + private var currentCycledMessage: ChatMessage? = null + private var currentDataSource: String = "" var mediaPlayerDuration: Int = 0 - private val _mediaPlayerSeekBarPosition: MutableLiveData = MutableLiveData() - val mediaPlayerSeekBarPosition: LiveData - get() = _mediaPlayerSeekBarPosition + var mediaPlayerPosition: Int = 0 /** * Starts playing audio from the given path, initializes or resumes if the player is already created. */ fun start(path: String) { + if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + stop() + } + if (mediaPlayer == null || !scope.isActive) { init(path) } else { + _managerState.value = MediaPlayerManagerState.RESUMED + mediaPlayer!!.start() + loop = true + scope.launch { seekbarUpdateObserver() } + } + } + + /** + * Starting cycling through the playQueue, playing messages automatically unless stop() is called. + * + */ + fun startCycling() { + if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + stop() + } + + val shouldReset = playQueue.first().first != currentDataSource + + if (mediaPlayer == null || !scope.isActive || shouldReset) { + initCycling() + } else { + _managerState.value = MediaPlayerManagerState.RESUMED mediaPlayer!!.start() loop = true scope.launch { seekbarUpdateObserver() } @@ -60,19 +136,28 @@ class MediaPlayerManager : LifecycleAwareManager { if (mediaPlayer != null) { Log.d(TAG, "media player destroyed") loop = false + scope.cancel() mediaPlayer!!.stop() mediaPlayer!!.release() mediaPlayer = null + currentCycledMessage = null + _backgroundPlayUIFlow.tryEmit(null) + _managerState.value = MediaPlayerManagerState.STOPPED } } /** * Pauses the player. */ - fun pause() { + fun pause(notifyUI: Boolean) { if (mediaPlayer != null) { Log.d(TAG, "media player paused") + _managerState.value = MediaPlayerManagerState.PAUSED mediaPlayer!!.pause() + loop = false + if (notifyUI) { + _backgroundPlayUIFlow.tryEmit(null) + } } } @@ -89,14 +174,29 @@ class MediaPlayerManager : LifecycleAwareManager { private suspend fun seekbarUpdateObserver() { withContext(Dispatchers.IO) { + currentCycledMessage?.voiceMessageDuration = mediaPlayerDuration / ONE_SEC + currentCycledMessage?.resetVoiceMessage = false while (true) { if (!loop) { - return@withContext + // NOTE: ok so this doesn't stop the loop, but rather stop the update. Wasteful, but minimal + delay(SEEKBAR_UPDATE_DELAY) + continue } - if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + + if (mediaPlayer != null && mediaPlayer?.isPlaying == true) { val pos = mediaPlayer!!.currentPosition + mediaPlayerPosition = pos val progress = (pos.toFloat() / mediaPlayerDuration) * DIVIDER - _mediaPlayerSeekBarPosition.postValue(progress.toInt()) + val progressI = ceil(progress).toInt() + val seconds = (pos / ONE_SEC) + _mediaPlayerSeekBarPosition.emit(progressI) + currentCycledMessage?.let { + it.isPlayingVoiceMessage = true + it.voiceMessageSeekbarProgress = progressI + it.voiceMessagePlayedSeconds = seconds + if (progressI >= IS_PLAYED_CUTOFF) it.wasPlayedVoiceMessage = true + _mediaPlayerSeekBarPositionMsg.emit(it) + } } delay(SEEKBAR_UPDATE_DELAY) @@ -104,35 +204,140 @@ class MediaPlayerManager : LifecycleAwareManager { } } - @Suppress("Detekt.TooGenericExceptionCaught") + /** + * Adds a audio file to the play queue. for cycling through + * + * @throws FileNotFoundException if the file is not downloaded to cache first + */ + fun addToPlayList(path: String, chatMessage: ChatMessage) { + val file = File(path) + if (!file.exists()) { + throw FileNotFoundException("Cannot add to playlist without downloading to cache first for path\n$path") + } + + for (pair in playQueue) { + if (pair.first == path) return + } + + playQueue.add(Pair(path, chatMessage)) + } + + fun clearPlayList() { + playQueue.clear() + } + + /** + * Sets the player speed. + */ + fun setPlayBackSpeed(speed: PlaybackSpeed) { + if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + mediaPlayer!!.playbackParams.let { params -> + params.setSpeed(speed.value) + mediaPlayer!!.playbackParams = params + } + } + } + private fun init(path: String) { try { mediaPlayer = MediaPlayer().apply { + _managerState.value = MediaPlayerManagerState.SETUP setDataSource(path) + currentDataSource = path prepareAsync() setOnPreparedListener { - mediaPlayerDuration = it.duration - start() - loop = true - scope = MainScope() - scope.launch { seekbarUpdateObserver() } + onPrepare() } } } catch (e: Exception) { Log.e(ChatActivity.TAG, "failed to initialize mediaPlayer", e) + _managerState.value = MediaPlayerManagerState.ERROR } } + private fun initCycling() { + try { + mediaPlayer = MediaPlayer().apply { + _managerState.value = MediaPlayerManagerState.SETUP + val pair = playQueue.iterator().next() + setDataSource(pair.first) + currentDataSource = pair.first + currentCycledMessage = pair.second + playQueue.removeAt(0) + prepareAsync() + setOnPreparedListener { + onPrepare() + } + + setOnCompletionListener { + if (playQueue.iterator().hasNext() && playQueue.first().first != currentDataSource) { + _managerState.value = MediaPlayerManagerState.SETUP + val nextPair = playQueue.iterator().next() + playQueue.removeAt(0) + mediaPlayer?.reset() + mediaPlayer?.setDataSource(nextPair.first) + currentCycledMessage = nextPair.second + prepare() + } else { + mediaPlayer?.release() + mediaPlayer = null + _backgroundPlayUIFlow.tryEmit(null) + currentCycledMessage?.let { + it.resetVoiceMessage = true + it.isPlayingVoiceMessage = false + } + runBlocking { + _mediaPlayerSeekBarPositionMsg.emit(currentCycledMessage!!) + } + currentCycledMessage = null + loop = false + _managerState.value = MediaPlayerManagerState.STOPPED + } + } + } + } catch (e: Exception) { + Log.e(ChatActivity.TAG, "failed to initialize mediaPlayer", e) + _managerState.value = MediaPlayerManagerState.ERROR + } + } + + private fun MediaPlayer.onPrepare() { + mediaPlayerDuration = this.duration + + val playBackSpeed = if (currentCycledMessage?.actorId == null) { + PlaybackSpeed.NORMAL.value + } else { + appPreferences.getPreferredPlayback(currentCycledMessage?.actorId).value + } + mediaPlayer!!.playbackParams.setSpeed(playBackSpeed) + + start() + _managerState.value = MediaPlayerManagerState.STARTED + currentCycledMessage?.let { + it.isPlayingVoiceMessage = true + _backgroundPlayUIFlow.tryEmit(it) + } + loop = true + scope = MainScope() + scope.launch { seekbarUpdateObserver() } + } + override fun handleOnPause() { // unused atm } override fun handleOnResume() { - // unused atm + if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + loop = true + } } override fun handleOnStop() { - stop() - scope.cancel() + loop = false + if (mediaPlayer != null && currentCycledMessage != null && mediaPlayer!!.isPlaying) { + CoroutineScope(Dispatchers.Default).launch { + _backgroundPlayUIFlow.tryEmit(currentCycledMessage!!) + } + } } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index f3097250a..0a2b4a060 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.chat.data.ChatMessageRepository import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager +import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource @@ -41,11 +42,15 @@ import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew +import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -57,6 +62,7 @@ import javax.inject.Inject @Suppress("TooManyFunctions", "LongParameterList") class ChatViewModel @Inject constructor( // should be removed here. Use it via RetrofitChatNetwork + private val appPreferences: AppPreferences, private val chatNetworkDataSource: ChatNetworkDataSource, private val chatRepository: ChatMessageRepository, private val conversationRepository: OfflineConversationsRepository, @@ -73,8 +79,11 @@ class ChatViewModel @Inject constructor( STOPPED } + private val mediaPlayerManager: MediaPlayerManager = MediaPlayerManager.sharedInstance(appPreferences) lateinit var currentLifeCycleFlag: LifeCycleFlag val disposableSet = mutableSetOf() + var mediaPlayerDuration = mediaPlayerManager.mediaPlayerDuration + val mediaPlayerPosition = mediaPlayerManager.mediaPlayerPosition fun getChatRepository(): ChatMessageRepository { return chatRepository @@ -85,6 +94,7 @@ class ChatViewModel @Inject constructor( currentLifeCycleFlag = LifeCycleFlag.RESUMED mediaRecorderManager.handleOnResume() chatRepository.handleOnResume() + mediaPlayerManager.handleOnResume() } override fun onPause(owner: LifecycleOwner) { @@ -94,6 +104,7 @@ class ChatViewModel @Inject constructor( disposableSet.clear() mediaRecorderManager.handleOnPause() chatRepository.handleOnPause() + mediaPlayerManager.handleOnPause() } override fun onStop(owner: LifecycleOwner) { @@ -101,8 +112,21 @@ class ChatViewModel @Inject constructor( currentLifeCycleFlag = LifeCycleFlag.STOPPED mediaRecorderManager.handleOnStop() chatRepository.handleOnStop() + mediaPlayerManager.handleOnStop() } + val backgroundPlayUIFlow = mediaPlayerManager.backgroundPlayUIFlow + + val mediaPlayerSeekbarObserver: Flow + get() = mediaPlayerManager.mediaPlayerSeekBarPositionMsg + + val managerStateFlow: Flow + get() = mediaPlayerManager.managerState + + val voiceMessagePlayBackUIFlow: Flow + get() = _voiceMessagePlayBackUIFlow + private val _voiceMessagePlayBackUIFlow: MutableSharedFlow = MutableSharedFlow() + val getAudioFocusChange: LiveData get() = audioFocusRequestManager.getManagerState @@ -122,10 +146,6 @@ class ChatViewModel @Inject constructor( val outOfOfficeViewState: LiveData get() = _outOfOfficeViewState - private val _voiceMessagePlaybackSpeedPreferences: MutableLiveData> = MutableLiveData() - val voiceMessagePlaybackSpeedPreferences: LiveData> - get() = _voiceMessagePlaybackSpeedPreferences - val getMessageFlow = chatRepository.messageFlow .onEach { _chatMessageViewState.value = if (_chatMessageViewState.value == ChatMessageInitialState) { @@ -665,12 +685,34 @@ class ChatViewModel @Inject constructor( emit(message.first()) } - fun applyPlaybackSpeedPreferences(speeds: Map) { - _voiceMessagePlaybackSpeedPreferences.postValue(speeds) + fun setPlayBack(speed: PlaybackSpeed) { + mediaPlayerManager.setPlayBackSpeed(speed) + CoroutineScope(Dispatchers.Default).launch { + _voiceMessagePlayBackUIFlow.emit(speed) + } } - fun getPlaybackSpeedPreference(message: ChatMessage) = - _voiceMessagePlaybackSpeedPreferences.value?.get(message.user.id) ?: PlaybackSpeed.NORMAL + fun startMediaPlayer(path: String) { + audioRequest(true) { + mediaPlayerManager.start(path) + } + } + + fun startCyclingMediaPlayer() = audioRequest(true, mediaPlayerManager::startCycling) + + fun pauseMediaPlayer(notifyUI: Boolean) { + audioRequest(false) { + mediaPlayerManager.pause(notifyUI) + } + } + + fun seekToMediaPlayer(progress: Int) = mediaPlayerManager.seekTo(progress) + + fun stopMediaPlayer() = audioRequest(false, mediaPlayerManager::stop) + + fun queueInMediaPlayer(path: String, msg: ChatMessage) = mediaPlayerManager.addToPlayList(path, msg) + + fun clearMediaPlayerQueue() = mediaPlayerManager.clearPlayList() inner class JoinRoomObserver : Observer { override fun onSubscribe(d: Disposable) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt index 02299d309..12ced9f46 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt @@ -24,6 +24,7 @@ import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.utils.message.SendMessageUtils import com.stfalcon.chatkit.commons.models.IMessage import io.reactivex.disposables.Disposable +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import javax.inject.Inject @@ -82,7 +83,7 @@ class MessageInputViewModel @Inject constructor( val micInputAudioObserver: LiveData> get() = audioRecorderManager.getAudioValues - val mediaPlayerSeekbarObserver: LiveData + val mediaPlayerSeekbarObserver: Flow get() = mediaPlayerManager.mediaPlayerSeekBarPosition private val _getEditChatMessage: MutableLiveData = MutableLiveData() @@ -231,7 +232,7 @@ class MessageInputViewModel @Inject constructor( fun pauseMediaPlayer() { audioFocusRequestManager.audioFocusRequest(false) { - mediaPlayerManager.pause() + mediaPlayerManager.pause(false) _isVoicePreviewPlaying.postValue(false) } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 3fafc116b..2c5e7c940 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -41,6 +41,7 @@ import androidx.activity.OnBackPressedCallback import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.MenuItemCompat import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment @@ -80,6 +81,7 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contacts.ContactsActivityCompose import com.nextcloud.talk.contacts.ContactsUiState import com.nextcloud.talk.contacts.ContactsViewModel @@ -104,6 +106,7 @@ import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.settings.SettingsActivity +import com.nextcloud.talk.ui.BackgroundVoiceMessageCard import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment import com.nextcloud.talk.ui.dialog.ChooseAccountShareToDialogFragment import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog @@ -151,6 +154,7 @@ import org.apache.commons.lang3.builder.CompareToBuilder import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import retrofit2.HttpException +import java.io.File import java.util.Objects import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -185,6 +189,9 @@ class ConversationsListActivity : @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var chatViewModel: ChatViewModel + @Inject lateinit var contactsViewModel: ContactsViewModel @@ -283,7 +290,7 @@ class ConversationsListActivity : if (adapter == null) { adapter = FlexibleAdapter(conversationItems, this, true) } else { - binding.loadingContent?.visibility = View.GONE + binding.loadingContent.visibility = View.GONE } adapter!!.addListener(this) prepareViews() @@ -455,6 +462,55 @@ class ConversationsListActivity : } }.collect() } + + lifecycleScope.launch { + chatViewModel.backgroundPlayUIFlow.onEach { msg -> + binding.composeViewForBackgroundPlay.apply { + // Dispose of the Composition when the view's LifecycleOwner is destroyed + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + msg?.let { + val duration = chatViewModel.mediaPlayerDuration + val position = chatViewModel.mediaPlayerPosition + val offset = position.toFloat() / duration + val imageURI = ApiUtils.getUrlForAvatar( + currentUser?.baseUrl, + msg.actorId, + true + ) + val conversationImageURI = ApiUtils.getUrlForConversationAvatar( + ApiUtils.API_V1, + currentUser?.baseUrl, + msg.token + ) + + if (duration > 0) { + BackgroundVoiceMessageCard( + msg.actorDisplayName!!, + duration - position, + offset, + imageURI, + conversationImageURI, + viewThemeUtils, + context + ) + .GetView({ isPaused -> + if (isPaused) { + chatViewModel.pauseMediaPlayer(false) + } else { + val filename = msg.selectedIndividualHashMap!!["name"] + val file = File(context.cacheDir, filename!!) + chatViewModel.startMediaPlayer(file.canonicalPath) + } + }) { + chatViewModel.stopMediaPlayer() + } + } + } + } + } + }.collect() + } } private fun setConversationList(list: List) { @@ -770,8 +826,8 @@ class ConversationsListActivity : initSearchDisposable() adapter?.setHeadersShown(true) if (!hasFilterEnabled()) filterableConversationItems = searchableConversationItems - adapter?.updateDataSet(filterableConversationItems, false) - adapter?.showAllHeaders() + adapter!!.updateDataSet(filterableConversationItems, false) + adapter!!.showAllHeaders() binding.swipeRefreshLayoutView?.isEnabled = false searchBehaviorSubject.onNext(true) return true @@ -786,9 +842,9 @@ class ConversationsListActivity : // cancel any pending searches searchHelper!!.cancelSearch() } - binding.swipeRefreshLayoutView?.isRefreshing = false + binding.swipeRefreshLayoutView.isRefreshing = false searchBehaviorSubject.onNext(false) - binding.swipeRefreshLayoutView?.isEnabled = true + binding.swipeRefreshLayoutView.isEnabled = true searchView!!.onActionViewCollapsed() binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( @@ -801,7 +857,7 @@ class ConversationsListActivity : viewThemeUtils.platform.resetStatusBar(this@ConversationsListActivity) } - val layoutManager = binding.recyclerView?.layoutManager as SmoothScrollLinearLayoutManager? + val layoutManager = binding.recyclerView.layoutManager as SmoothScrollLinearLayoutManager? layoutManager?.scrollToPositionWithOffset(0, 0) return true } @@ -894,18 +950,18 @@ class ConversationsListActivity : private fun initOverallLayout(isConversationListNotEmpty: Boolean) { if (isConversationListNotEmpty) { - if (binding.emptyLayout?.visibility != View.GONE) { - binding.emptyLayout?.visibility = View.GONE + if (binding.emptyLayout.visibility != View.GONE) { + binding.emptyLayout.visibility = View.GONE } - if (binding.swipeRefreshLayoutView?.visibility != View.VISIBLE) { - binding.swipeRefreshLayoutView?.visibility = View.VISIBLE + if (binding.swipeRefreshLayoutView.visibility != View.VISIBLE) { + binding.swipeRefreshLayoutView.visibility = View.VISIBLE } } else { - if (binding.emptyLayout?.visibility != View.VISIBLE) { - binding.emptyLayout?.visibility = View.VISIBLE + if (binding.emptyLayout.visibility != View.VISIBLE) { + binding.emptyLayout.visibility = View.VISIBLE } - if (binding.swipeRefreshLayoutView?.visibility != View.GONE) { - binding.swipeRefreshLayoutView?.visibility = View.GONE + if (binding.swipeRefreshLayoutView.visibility != View.GONE) { + binding.swipeRefreshLayoutView.visibility = View.GONE } } } @@ -1092,24 +1148,24 @@ class ConversationsListActivity : } } }) - binding.recyclerView?.setOnTouchListener { v: View, _: MotionEvent? -> + binding.recyclerView.setOnTouchListener { v: View, _: MotionEvent? -> if (!isDestroyed) { val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(v.windowToken, 0) } false } - binding.swipeRefreshLayoutView?.setOnRefreshListener { + binding.swipeRefreshLayoutView.setOnRefreshListener { fetchRooms() fetchPendingInvitations() } - binding.swipeRefreshLayoutView?.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } - binding.emptyLayout?.setOnClickListener { showNewConversationsScreen() } - binding.floatingActionButton?.setOnClickListener { + binding.swipeRefreshLayoutView.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } + binding.emptyLayout.setOnClickListener { showNewConversationsScreen() } + binding.floatingActionButton.setOnClickListener { run(context) showNewConversationsScreen() } - binding.floatingActionButton?.let { viewThemeUtils.material.themeFAB(it) } + binding.floatingActionButton.let { viewThemeUtils.material.themeFAB(it) } binding.switchAccountButton.setOnClickListener { if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) { @@ -1284,7 +1340,7 @@ class ConversationsListActivity : @SuppressLint("CheckResult") // handled by helper private fun startMessageSearch(search: String?) { - binding.swipeRefreshLayoutView?.isRefreshing = true + binding.swipeRefreshLayoutView.isRefreshing = true searchHelper?.startMessageSearch(search!!) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) @@ -1539,8 +1595,8 @@ class ConversationsListActivity : filesToShare?.forEach { UploadAndShareFilesWorker.upload( it, - selectedConversation!!.token!!, - selectedConversation!!.displayName!!, + selectedConversation!!.token, + selectedConversation!!.displayName, null ) } @@ -2016,7 +2072,7 @@ class ConversationsListActivity : binding.recyclerView?.scrollToPosition(0) } } - binding.swipeRefreshLayoutView?.isRefreshing = false + binding.swipeRefreshLayoutView.isRefreshing = false } private fun onMessageSearchError(throwable: Throwable) { diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt index 3ce6cdf19..c98b4ba8c 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt @@ -12,6 +12,7 @@ import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.data.io.AudioRecorderManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.io.MediaRecorderManager +import com.nextcloud.talk.utils.preferences.AppPreferences import dagger.Module import dagger.Provides @@ -29,8 +30,10 @@ class ManagerModule { } @Provides - fun provideMediaPlayerManager(): MediaPlayerManager { - return MediaPlayerManager() + fun provideMediaPlayerManager(preferences: AppPreferences): MediaPlayerManager { + return MediaPlayerManager().apply { + appPreferences = preferences + } } @Provides diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index e4c760050..823cab6a5 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -10,8 +10,8 @@ package com.nextcloud.talk.dagger.modules import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.chat.viewmodels.ChatViewModel -import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel +import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.conversation.viewmodel.ConversationViewModel import com.nextcloud.talk.conversationcreation.ConversationCreationViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel @@ -125,8 +125,6 @@ abstract class ViewModelModule { @ViewModelKey(MessageInputViewModel::class) abstract fun messageInputViewModel(viewModel: MessageInputViewModel): ViewModel - // TODO I had a merge conflict here that went weird. choose their version - @Binds @IntoMap @ViewModelKey(ConversationInfoViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt b/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt new file mode 100644 index 000000000..88e75cf90 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt @@ -0,0 +1,195 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui + +import android.animation.ValueAnimator +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.ui.theme.ViewThemeUtils + +@Suppress("LongParameterList") +class BackgroundVoiceMessageCard( + val name: String, + val duration: Int, + private val offset: Float, + private val imageURI: String, + private val conversationImageURI: String, + private var viewThemeUtils: ViewThemeUtils, + private var context: Context +) { + + private val progressState = mutableFloatStateOf(0.0f) + private val animator = ValueAnimator.ofFloat(offset, 1.0f) + + init { + animator.duration = duration.toLong() + animator.addUpdateListener { animation -> + progressState.floatValue = animation.animatedValue as Float + } + + animator.start() + } + + companion object { + private const val ACCOUNT_WEIGHT = .8f + } + + @Suppress("FunctionNaming", "LongMethod") + @Composable + fun GetView(onPlayPaused: (isPaused: Boolean) -> Unit, onClosed: () -> Unit) { + MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(context)) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .padding(16.dp, 0.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(8.dp)) + .fillMaxWidth(progressState.floatValue) + .height(4.dp) + ) + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.CenterVertically) + ) { + var isPausedIcon by remember { mutableStateOf(false) } + + IconButton( + onClick = { + isPausedIcon = !isPausedIcon + onPlayPaused(isPausedIcon) + if (isPausedIcon) { + animator.pause() + } else { + animator.resume() + } + } + ) { + Icon( + imageVector = if (isPausedIcon) { + Icons.Filled.PlayArrow + } else { + ImageVector.vectorResource(R.drawable.ic_baseline_pause_voice_message_24) + }, + contentDescription = "contentDescription", + modifier = Modifier + .size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + Spacer(modifier = Modifier.size(16.dp)) + + Box( + modifier = Modifier + .weight(ACCOUNT_WEIGHT) + .align(Alignment.CenterVertically), + contentAlignment = Alignment.Center + ) { + Row { + Box { + val errorPlaceholderImage: Int = R.drawable.account_circle_96dp + val loadedImage = loadImage(imageURI, context, errorPlaceholderImage) + val conversationImage = loadImage( + conversationImageURI, + context, + errorPlaceholderImage + ) + AsyncImage( + model = conversationImage, + contentDescription = stringResource(R.string.user_avatar), + modifier = Modifier + .size(width = 45.dp, height = 45.dp) + .padding(8.dp) + .offset(10.dp, 10.dp) + ) + + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.user_avatar), + modifier = Modifier + .size(width = 45.dp, height = 45.dp) + .padding(8.dp) + ) + } + + Text( + name, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(8.dp), + color = MaterialTheme.colorScheme.onBackground + ) + } + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.CenterVertically) + ) { + IconButton( + onClick = { + onClosed() + } + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "contentDescription", + modifier = Modifier + .size(24.dp) + .padding(2.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index beb152a69..60699b78c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -11,12 +11,8 @@ package com.nextcloud.talk.utils.preferences; import android.annotation.SuppressLint; -import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel; import com.nextcloud.talk.ui.PlaybackSpeed; -import java.util.List; -import java.util.Map; - @SuppressLint("NonConstantResourceId") public interface AppPreferences { @@ -175,9 +171,11 @@ public interface AppPreferences { int getLastKnownId(String internalConversationId, int defaultValue); - void saveVoiceMessagePlaybackSpeedPreferences(Map speeds); + void deleteAllMessageQueuesFor(String userId); - Map readVoiceMessagePlaybackSpeedPreferences(); + void savePreferredPlayback(String userId, PlaybackSpeed speed); + + PlaybackSpeed getPreferredPlayback(String userId); Long getNotificationWarningLastPostponedDate(); diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index d0867b6f8..ec92560cf 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -8,7 +8,6 @@ package com.nextcloud.talk.utils.preferences import android.content.Context -import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -24,9 +23,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking -import kotlinx.serialization.SerializationException -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json @ExperimentalCoroutinesApi @Suppress("TooManyFunctions", "DeferredResultUnused", "EmptyFunctionBlock") @@ -500,26 +496,42 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { return if (lastReadId.isNotEmpty()) lastReadId.toInt() else defaultValue } - override fun saveVoiceMessagePlaybackSpeedPreferences(speeds: Map) { - Json.encodeToString(speeds).let { - runBlocking { async { writeString(VOICE_MESSAGE_PLAYBACK_SPEEDS, it) } } - } - } + override fun deleteAllMessageQueuesFor(userId: String) { + runBlocking { + async { + val keyList = mutableListOf>() + val preferencesMap = context.dataStore.data.first().asMap() + for (preference in preferencesMap) { + if (preference.key.name.contains("$userId@")) { + keyList.add(preference.key) + } + } - override fun readVoiceMessagePlaybackSpeedPreferences(): Map { - return runBlocking { - async { readString(VOICE_MESSAGE_PLAYBACK_SPEEDS, "{}").first() } - }.getCompleted().let { - try { - Json.decodeFromString>(it) - .map { entry -> entry.key to PlaybackSpeed.byName(entry.value) }.toMap() - } catch (e: SerializationException) { - Log.e(TAG, "ignoring invalid json format in voice message playback speed preferences", e) - emptyMap() + for (key in keyList) { + context.dataStore.edit { + it.remove(key) + } + } } } } + override fun savePreferredPlayback(userId: String, speed: PlaybackSpeed) { + runBlocking { + async { + writeString(userId + PLAY_BACK, speed.name) + } + } + } + + override fun getPreferredPlayback(userId: String): PlaybackSpeed = + runBlocking { + async { + val name = readString(userId + PLAY_BACK).first() + return@async if (name == "") PlaybackSpeed.NORMAL else PlaybackSpeed.byName(name) + } + }.getCompleted() + override fun getNotificationWarningLastPostponedDate(): Long = runBlocking { async { readLong(LAST_NOTIFICATION_WARNING).first() } @@ -609,6 +621,8 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val DB_ROOM_MIGRATED = "db_room_migrated" const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run" const val TYPING_STATUS = "typing_status" + const val MESSAGE_QUEUE = "@message_queue" + const val PLAY_BACK = "_playback" const val VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds" const val SHOW_REGULAR_NOTIFICATION_WARNING = "show_regular_notification_warning" const val LAST_NOTIFICATION_WARNING = "last_notification_warning" diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index 5a30ddb16..25084501c 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -143,6 +143,11 @@ app:popupTheme="@style/appActionBarPopupMenu" app:titleTextColor="@color/fontAppbar" tools:title="@string/nc_app_product_name" /> + + Done User avatar Back button - New Conversation Creation Icon - Join Open Conversations Icon Please allow permissions Some permissions were denied. diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys index c106fdcb9..8049ad1ff 100644 --- a/gradle/verification-keyring.keys +++ b/gradle/verification-keyring.keys @@ -1339,6 +1339,8 @@ vCeonVI7Q1CkIHt8u7eMgzfEkaiPLZlI0l0RpfT4pnNieqg= -----END PGP PUBLIC KEY BLOCK----- pub BAC30622339994C4 +uid Chris Povirk + sub FC9BDC25FB378008 -----BEGIN PGP PUBLIC KEY BLOCK----- @@ -1347,20 +1349,21 @@ Gyoc9ZmChrhLoim7z4ILqmNo8eegknepQ3dGdUij4NVIhR+m+8irayTbsNHvo3UG 9y7eM5tTSjyNYkyk5fAVuT7OhzIzMA+qtc3GRVxNYRKnaHajt+pOSqr+uoDtMG3n 6eAMHCAnhgh5Nd+dCFcNT+syl3zCwolA1wrzGxxOaif+xi5wwXjmF/lAt4PDIuDT etA2/AqPM4zAC0BtC0iqVgVypjFV3EAexm/g0LNMiG/M/krzwjPq5gf1DY/57jU0 -02FpKd79HmR7bHdc4e2olEf9NlHxfbPXDDsHABEBAAG5AQ0EWUwTFgEIANmMpV3N -K8aLrLgQTyh5++det8C3D3T5tkEdljHOuN31/qdKNge8H6uKH8zXRZsj5pd8adpW -kD4TzIMvzIwzizsGw34O9hf1E2XPoDqvQr39p1sovX3PeDvRJY/7JFNt9DsphVc3 -xWQfNkC7JdMPa6JRiFHd3ynfbQ+wplf4tfaDVn1JXAWp0NSGgMtXfn5i19hHQWjm -RNAKNQLdVn8UczI8XdVM7bS4giDpQMukSyjsjgAo466iRK2+8f8BwIRe1JRvF37B -dnbvTg/dzoi1/E4ukwVJD6YE2LlDwzdGno9KxPlRsuY3nnheVgjbrGJ2XKRJkIk8 -7cMGh41VKw6L4usAEQEAAYkBHwQYAQIACQUCWUwTFgIbDAAKCRC6wwYiM5mUxEiH -CACQViGOHi0BoZ78ZJz6L48YNMx8fSdSv3YJ83Ih1n5DWCJgrDV5S3/edYinkoVI -0Lusy3MdftRg6OWaYOuOTf6MYcddO/mY363jiMByf9Uh3Dqq4sKqVLRnZbAqgD1o -dRoj2NkEQfgEH/H4JRVrxquzAKoWwJh3MhY+kajYJRJyWfc1/Bm3Bj1tcMGlGeIQ -fgWheeMg3kxrxJ9TXPqVi6VVPaPKIU5i8l46S+Wg3uvMs8vC3XzOIvhY6cwguJv9 -UkjZwGDSI952wLqnREMy0gFZ+OAB0qJpYM3nDEekWZP38G80kojnN61tZjRThu9I -i8/b+PwSW+nW3EpQZdLqZtOU -=2H2i +02FpKd79HmR7bHdc4e2olEf9NlHxfbPXDDsHABEBAAG0IUNocmlzIFBvdmlyayA8 +Y3Bvdmlya0Bnb29nbGUuY29tPrkBDQRZTBMWAQgA2YylXc0rxousuBBPKHn75163 +wLcPdPm2QR2WMc643fX+p0o2B7wfq4ofzNdFmyPml3xp2laQPhPMgy/MjDOLOwbD +fg72F/UTZc+gOq9Cvf2nWyi9fc94O9Elj/skU230OymFVzfFZB82QLsl0w9rolGI +Ud3fKd9tD7CmV/i19oNWfUlcBanQ1IaAy1d+fmLX2EdBaOZE0Ao1At1WfxRzMjxd +1UzttLiCIOlAy6RLKOyOACjjrqJErb7x/wHAhF7UlG8XfsF2du9OD93OiLX8Ti6T +BUkPpgTYuUPDN0aej0rE+VGy5jeeeF5WCNusYnZcpEmQiTztwwaHjVUrDovi6wAR +AQABiQEfBBgBAgAJBQJZTBMWAhsMAAoJELrDBiIzmZTESIcIAJBWIY4eLQGhnvxk +nPovjxg0zHx9J1K/dgnzciHWfkNYImCsNXlLf951iKeShUjQu6zLcx1+1GDo5Zpg +645N/oxhx107+ZjfreOIwHJ/1SHcOqriwqpUtGdlsCqAPWh1GiPY2QRB+AQf8fgl +FWvGq7MAqhbAmHcyFj6RqNglEnJZ9zX8GbcGPW1wwaUZ4hB+BaF54yDeTGvEn1Nc ++pWLpVU9o8ohTmLyXjpL5aDe68yzy8LdfM4i+FjpzCC4m/1SSNnAYNIj3nbAuqdE +QzLSAVn44AHSomlgzecMR6RZk/fwbzSSiOc3rW1mNFOG70iLz9v4/BJb6dbcSlBl +0upm05Q= +=Gf3Y -----END PGP PUBLIC KEY BLOCK----- pub BCF4173966770193 @@ -1449,42 +1452,6 @@ lQyC8nl8P5PgkEZ5CHcGymZlpzihR3ECrPJTk39Sb7D3SxCW4WrChV3kVfmLgvc= =WqT9 -----END PGP PUBLIC KEY BLOCK----- -pub C020E96222A31FB3 -uid Eric Li - -sub 55CDD67958ACCA47 ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQGNBGAephsBDADH0j84tkTcmvOYskQWjA3M8hLNJI5QdagcYviR2yDTBq7paSP6 -hLDrcCwTfvCNIatYI4hGau31RlkNKJHZumMZzF8OarWuKKkwwik3Z8pulMHgC8kR -SX5QM8k4wIeZLbD0UzW22gr0oLDilb3cFr86Y9T4AD6Ke0JATFRU+TrMAT5e5iYe -iVcNGuRQMvjncJobNIt4AeP12GV14p0GhAlka8Hwq24dTue5xwBJ9GwYWwPz6dbo -k83dZJdhLDfvL6ojG4umByeqCn/ARuJCuI0YABLO7BoqsAJvMfCMciP6Upu4iVT8 -DduCd7mV7YaByqtktRJDzaNiJa36riYOnzAVsKB1QbnWD2tP2kcR0N37104+WtkH -GYkSfnZujfvmoHf4hws+6oUgfPs+1vMMYj0AnlotcDVez8sHSAwQN+rzfqqii8lj -9DdpScq+yamQratHBHIkdyx1xyd+Xy00Vs9NY03gIeFFM2Rjat+XDfK+uO/Mpkkx -Gcf9d6lC+OGUO88AEQEAAbQbRXJpYyBMaSA8ZXJpY0Bzd2lmdHplci5uZXQ+uQGN -BGAephsBDACvxELzqfLQrmLHOlpJru6cCqQgPCE6DIQnNYJ6nTyAow6tBLQ7b2MP -ACn1yqRskE1qzh415B2tcZqN8IpguN9NssqINyKdxcOYogmcdfnhN8TWYYUCKBD9 -DssMhz24bq2GjcLxyPagrvACI5O+k+LYE3TQbLE/t/6oG3grgkWHJWKLA/ou812+ -eDOI+/HaME/1uT3DwsGv57zoZaIwADWmdotoEyU/d+cK+5A2727PCS0hfDleDJ1T -rJoT2YRN9YJhGTl3xq/XmhfmcYX3KlTKakENZXsi/x9n2sxpCpE1xMa1kIB6SmZi -l88oxa7+zunRVNK3ymGxVxnLlltTyO/VyxQh1AJFhI6n35ls3l4BSEmUjNRi76VX -cuFH0TnyKEIHxZYv1K5FjTF84yukKrLbWKgWsvr8exHQuKjz+iU5NvmhY0g2CLwa -9P2fjA+nGWuKhLZyDh8M0+sXEuVTbiHgq8dkr94yBwqCs8quqC4PHW4bSivZj5ml -nrgujB35H/EAEQEAAYkBtgQYAQgAIBYhBNvV4c9Kf/PYynRZ2MAg6WIiox+zBQJg -HqYbAhsMAAoJEMAg6WIiox+zi/UL/1/OT875lTWbpoPIIi63ymL/dpkinRZQMQWY -jEsMd6Ea2/tVCbYt6zFXZNBIbJ3WmPN1/ZWFh+PWHla/GlUhksPuSFt8Jf3YL0QQ -0vHwErdulWBLssWMGmlQmISeRVYPkjha1gpBcbKCaWHhXRuf/FsrYpb0NqkArRf1 -+fdhRdsOdy9avioy5l+/Ld+puaJWMKJbet2ARzQ9lOWCyOK6JoxO8U11jupKOMfa -gp5iowztQHcZ53IvIFJGPDCe0pb8l5owFpG8rmOrLuPVlRTMvR/n+MnI3hkswg+4 -2Y9hslJKenF2utD0q0eU6VYnsquSHbsypDjx6zwUZvaa6olCIxNZoVJw/wSv1ZDe -/8U0TEL7OXe9jA6QLEDYAPytSF/mVIqSp4dgAPrADXSt8UOvq3jQoNMTESbJWWX8 -169pc1yMD6HBdGShunnJ+slCQ/nJ6zFSMKLTJgOp3pRJYxfDlWoZVm5mSLsif8yD -yc0PGiCz66h9jPMKXJJ1o9MGQKvydw== -=+Ahq ------END PGP PUBLIC KEY BLOCK----- - pub C21CE653B639E41A sub 4F80368F9034B8D0 -----BEGIN PGP PUBLIC KEY BLOCK----- @@ -5670,13 +5637,15 @@ xOcUt3JhIGtKwRMO4mte4wmT6Ko+Nj4uy6tFjbTfN2eBins/1F9qLU4YJUqC4QD4 -----END PGP PUBLIC KEY BLOCK----- pub 7721F63BD38B4796 -sub 4EB27DB2A3B88B8B -sub 1397BC53640DB551 -sub 78BD65473CB3BD13 -sub 6494C6D6997C215E +uid Google Inc. (Linux Packages Signing Authority) + sub FD533C07C264648F sub 32EE5355A6BC6E42 sub E88979FB9B30ACF2 +sub 1397BC53640DB551 +sub 6494C6D6997C215E +sub 78BD65473CB3BD13 +sub 4EB27DB2A3B88B8B -----BEGIN PGP PUBLIC KEY BLOCK----- mQINBFcMjNMBEAC6Wr5QuLIFgz1V1EFPlg8ty2TsjQEl4VWftUAqWlMevJFWvYEx @@ -5690,295 +5659,411 @@ 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 -c8LNGrDaCFdXnOdlNV/zT9VvBk/RkV+Tl/Lk4g== -=AP/d +tFRHb29nbGUgSW5jLiAoTGludXggUGFja2FnZXMgU2lnbmluZyBBdXRob3JpdHkp +IDxsaW51eC1wYWNrYWdlcy1rZXltYXN0ZXJAZ29vZ2xlLmNvbT65Ag0EZ31b7QEQ +AMEWM0g1KKIxE7q8JK7QblKom7++NYn92E3suHv3WxqzrhRT9tYNDSaoOazQP+ha +NA2BqkdFcB7G4jKdtK2VLFc7RBpcR2rnQEJWgpeP03DHrdZYFdpH9zABoFsotgZR +KwwTOoxdm6XtV+47LEY9yAefPrt1gPJQ4h1SKwWIFSRPChQ1cThBz2QD2LaPAGtj +Wzr6+0xf0nm4xTDvya0EbdTpMOvtyDCUp7qe41u34RelGxoo0+rmoL/0cTJGCr2L +3xWijlvWCMLhp2dgVnvRIpvo+tOSSl/pvTCLgE0nFjNQFbNh3D1Qo/AHhEz3MzbQ +9JbEy7MF+fiw83YULRKN1kZ673Z1ng1wLA0m4+EWrh/PpMPp1raYQT0fSqUkiGBo +25MfIjdheAuTgUf/4aHKU7vi4yFwxr8DWcKrxiv7g6xxbFvI3p+/wmyDbLXBBh2c +iqwQfW/H32TlfL+cXqtapB93L1xR4IPTRvMnIVJBA+J6I/jlqx6RqPABemHudFvL +2sAJu91lQjL5GxEmgVNL7l+UKGsy+h8mg4Sonnw3MI1c1KrvvIhJlkTpqiCqCnaG +BBjZQWkuiyVJSq8VLHq2LxlWJd9nt73MpOLgj79ylD5G7OQEVgGBbvKwqdRYvKik +8UTYYu7sTolNFVNMvQoCIJxropO/xk17qK0a4LtaZ8oVABEBAAGJBHIEGAEKACYW +IQTrTBv9TwQvbd3M7JF3IfY704tHlgUCZ31b7QIbAgUJBaOagAJACRB3IfY704tH +lsF0IAQZAQoAHRYhBA4iWRdBRnD0RCwlDf1TPAfCZGSPBQJnfVvtAAoJEP1TPAfC +ZGSPphkP/iHWjnYuEXC7uKzt+zvsqjkGkGkXVApXgZGm9h1/ujlab0FK+9VA1JlV +uzqw9SBYuwUwkGX0TVVCCc5KAxDa6sYH2IggcC+dN4ZjCMiUrJlEHNVE6f7Fjg59 +STScr+jWOckKnP5p2x4xmH0kZX/rkZ+90lfniPUvVt/g5aunoEQDvtMOZBn3Opgx +4zOWxqK4vGMtF1bhFUieMtg5B3E5jlNeNwmkDYV3MHGu+oMYy1TFMA3OQuTOz5de +D86xE73hK9HQ3DBoETPIuzlYXP0qoQswVKBI4z7HjRLmfBQagXCXj+64LEUaumAZ +lWWV3zzxZnAk4kzJv51+vESxaMm6Ll5VG18MLrzv3Zi4Ez4BMr7OjbAnxfcgrsIT +DlwrweCYC7Rq9fWw5USyk93h0kNJ8AVT6CG2a5/LsCztfW4jkg7LFDWWkMISoN/r +CaR2sJfvy2aijZ33yAWUnEpWZG6+8811YAFdn9g1bnuWLHx9Z7q8VjalzJhVFe9f +8Mhwk2K764VUL7pnNNYxl4Kj0oTAqVVGDZoMWHCcE7nxqGbzjn8H/unTY0vtK5k8 +BPyuUt0Dtel8fspjlDl5o5VbeBQo6cFBEzZSd5rbavXmtixhL7CGKqCWaHbJ6OYT +a85W14ndUmRJ5qPdLcYakdl16Uj/DrFuKHOPrulgAbs+hmgm1q/nYdgP/3UUMgaq +xU6efsiWi3M3pz9nTu0mcI8kpJzvfov7WINjLLu2+yVRyRbS98473Zr6KS49BFqO +XNQBmtZl77bcz5shfhPxoKLd8YJVayvnBQrjCIE1CACvmJBZGDZSNY0vEa4G+n6W +T3DwEan6plZ3/xM6s4caZfP43ZZkEiu1M1svQtgzF98HFxAhX5oLyMPwx8R61X0X +tKxmjVbNrTBwRfJf/FRDmmv5qSxoO2g1gpbCcm2VGoGBvoDws95GvPFNlWUes4xQ +MclIo01JuynJGLyOaEm19TXu4T36ulCTO/b1lGIOLi+25vpFKlwBrD6yq4yrt81t +6vGvtI1pZrt1Wcm7hce8CFLrzfzo2D28yHPIsT0YvK7AnwUK/SMKIV1EUNrLIRhn +TMBnP+BaOF0HxcAYnlRLSwScPx2pATglHmIvNcRkCsfIZQOXjn8lvFXs7lnkP5KU +F5/+ccsJj8kEdzcsYaczQe3wY2N36ibqRPOfPeVmPFAKPQsUdgx47cAPKTm6PxIp +73IxceGXbkOXJ7W1lqVPKiwQh09RkwWpMtJA2HSdbmInxBiE71tJqSxQpp2EfNXO +gWTTrs46lkuW39I7dr0NOAGomeaNnOuexMmlTZy2Rf77BfozRZIZ+RAYlZu5/W1E +d2X4iR8i76QET6ICFmgxADKzB4WnpeBaBqAruQINBGW5WnYBEADEptUD7cowK13T +NYtmOuN/SXPwCct8pFY8U2g4up+c+5YEIqWkAUqVm8Lp+DqdFuX7NbfK2BNojwPy +iqKlaBAN6Nx7bO9bjSvlbbZBrVJ3mL+k7xrFdTGLeDSSTlEBesj7FXK1zK7SW9AV +3n45lmOLAXTC13VQ3K0SEXb+69FwXr31/i54NgTfM/1LcJZhIoR6MutHJCSYKuO6 +ZHxYaOdfj48BGSWj7RIEbF59rIEzDR7pBk61fLm/2illTQTxdMGGBeSwNjR4Fade +PJ0tdReDoIjaQpAGZiUAj5cqeHpmH8ZIoY4fk2SsOinK8cDgse2HnHiiusFr6xx6 +8IycZdp4WrQyGDSJZ1ZKk5QaeAPYE3QsZnImdcV2/kJZS8nAWDFoLtpaNSpONDTn +1ZYyCi2rWPPF8JiVMkmCxsl+ZHjyNvZHPslRsnGB7EoKDpcjP1cPhl37o/wUYpyi +LYyE8W+mDNucH4YHLVHq/zQGqO3V6axTA1Ds+gu9tHV/3+yErIqpou19VOfPKJjY +C7yfzvID58jLZNYFq8IRWQ5VWCOQJWMcdzMKB57S2Zb5vIhJkfl/S5ISMGXDXb32 +nyKVfvC5TmiMbfrszc3DLAxwhqJlAH/xmF2yPP8dYh6KKWSIffVGTm38scs9kkm1 +bVBAR18ilx6dGxVjNSM9i30MMXjUUQARAQABiQRyBBgBCgAmFiEE60wb/U8EL23d +zOyRdyH2O9OLR5YFAmW5WnYCGwIFCQWjmoACQAkQdyH2O9OLR5bBdCAEGQEKAB0W +IQQPBv+Gvur05xhm7lIy7lNVprxuQgUCZbladgAKCRAy7lNVprxuQpgeD/9UZ1yh +68qd1JWjgKv1ABdmChUvTQIIFcB/D4bBXOp2aa7nPghVNbOY8ArlvWoloEPk4wRb +0NIf2xy27o5U0pn8ssqPyI/uL0sUc5ZlGvJRz6sqr+3yLEPNgALPFhP5lfA4n9uI +bMSUmRB9d9A05EarZ10tBQerDkDxo+RCgBbd79Lzf2dUEV9ni5mXNozu0H5HUbLa +7xd26K2+8XbduvzzPIPEYGGhvn6iCfzbRkBdFyPllMbkPprURhMlaS+Kp8MJ6JMs +Y7DMbCb7xiGj1CykdQmoRiQ+LQJchNw7zYpDESNkg6I2hsoeXMNuJiSVWZseOu0N +7ROHcJgLvKQhgpXVEARunqa0y/1mrsiXJpCa9arEiu4MsflMvJsFxEmP7OW8A5M4 +Zc2idFowcal36BxSBJRyX4S0tHn6iK+jRZj1LuHASiSqGaBQcEz00xJls+7RNpg4 +FTvWekPX6uwsGUuifxnIYnkIAMt0cZuQYswWbForGNwUOCV9cOsB9AmnuK2Anm5L +rRRVgYOyQN49p5IH87A95GB2H+QZZS9slefPXRKHDYz4qLOerz4uZIPVEDhTtUml ++BH7tXgKzXDbXgcSn+aR/KJA3II6o/cl2UbOnyNlJPc889FC+t/okUqko/Cr+onq +SwszqYdQx52NrRYIaUhWKpWsoXaazCVZOxpixaImD/9Z0+sKvJP0Q0t/1uxxASfE +bcbNby+dZ1NHuSu44G1E/ZGhtigZpZ0W4JBC477tJV+syxYsj3HOSZLxNgMn2e7e +XCH2pzkuIXNFvQuukUKNnL1MZJ9oLQqzOygk3NeiMHv7jAtkTRJ3jS4gnrcHOQ4a +Y2BMUefbM80PTacd9aXn7JpEsCnbrRM/Fvepdvch2ICA6C2Ft2+p7gUQX6eJwF0N +YFqnxJhTRZSmQWtqT7CjZRYKXIQvhIjEP3W9RJVTclt/CuyDseoTRqRAScGz7hzX +n/VlN59gyCIDAR0xz72zR4gjU37jjNfvwTG5iufUZluuk0tFGsWbLMBxy2be7zTe +0is17L3k/fgNXesGZVaMvGN4MpASpwRxkWhjtM2l3Mx7eGLFAOC94rOpQI2yKKVO +TN49hdURSka/efh2U/zVzXhmxb7HSprz+BC7QwLmFTIGEfBqoyOcj/3HFTLIv5oU +/nFMTvxe/u54scNRqOt+yaq/zReZI46wQ/BIhsvEKxpurgUdzRmndAZzFimtmKGC +hdZtxbP3rwZHMpkfoUsBc5F5ulkjm/IcySGshWupAHO7kiskeYhtNKf53om26jNW +lAEAwcFe4PcD5nHUSIyUtjLStSVx2QkdYFSm/hDm3LekSTlcPLOfBEuTvcBmZm1b +SGfiR027h/rYNBKETXAm87kCDQRj7PlYARAAym4Cy/rwGmyldqxkg4GPiLbUwLYP +cPVKK9fkPwiFhsYvyUMu5e10yo5ktML3cFPvX/3hrI8YoG7wHErFdbM8in1UEBU9 +pvSoQ6wajQgL0OU4OmUHTXudaB8I4iENYu8/EKE9tlbnHU2KnDCwB3voNGjaiy0k +liwIluM/p3q3JYp44k0QsP2lmSUdaM0HdnisAMOq5NfWx3IoV6NhNCtRA5nR3DQP +MrcqccFllwX7QmEVl4uSdNhnmWs8Zsfw1C5xYMtieBtFC06hlrG3/7Qrdto6oMl/ +rxY/7CT5/pdyCaqcjWOcgRuhnHo3j/b0aEK2qRqh5HMft+39r48JqY+eePCSOdih +AtcmXhcKfB4xi6fsxmo6Z6JKyneFyR54lvfmzy6u2KezzZ+uTGmL2VI7+XpyneOX +xSuryd2LP38ejwVyigbPX5USIHVzikr7VfmxiBtCP6fyRGf8D8UYMRzwyuY23COi +gVZt1JPghxQuCAQLc3Uoeh+GX8NRB93UGTC1QQf0o9+FEIZcADpQF7WR975LPyqX +JVivIJ6s94vBzdHs73J0JUYkTvCKpTffz5hamXjU3q5JU+07dI16oqKSxy9BV0di +7J3XjkX8QxNa4VTlMZaHriLGPMeoDvIdmxoONWGqUTo2MWWRHGpIPTXIeFwJcXqg +eCErbX24lcXi9g0AEQEAAYkERAQYAQgADwUCY+z5WAIbAgUJBaOagAIpCRB3IfY7 +04tHlsFdIAQZAQgABgUCY+z5WAAKCRDoiXn7mzCs8kblD/48yE3Wpi6Cw8RBzq2u +zLdkuqXh691zG6VhHUZQNb85ewGjGDu/D25u2JFrhAcmlzOrxggvL4a8WatPXQaP +qDZaSh41elM1Ya0C7cNQq7xNVA0pcN5bQ+KXXZMuQaA89BClTSXITz6j4O4pvhAG +8y8Q2E9Mv7UYas0OhDgzVIry2s1o2Pml1qjlb9jctO9crRUiF6v9Ru9aQkgGHYt4 +uyP3HzKDfoNuzX/WX3O0Fm8NNpnJk6qZsLKwg7ukUdJOIEIbLLNLU9ZYmys3wNtD +KMfm4T79abSNwNIn4dd5hapH9BAuDJnk4WnFOap9AQZPgJX2WXKC2DXQZeSX1VXp +I3rr7FSbSec8d5bitw7s20XWyQB2+ZoetRxNgR104GIh/LajtatLKFc9NnP9Smhe +y8nrxVZFx6HuXsnGOPkbjsiFYMsxtPVYnO72nBDTDP4ZejLOaay2KtCb8pJkCH8U +0guquDGVd+S02Xx947evyvHqGt5V0yVFPD7uAu7A5QBYXvtctzq93S1jZDIoMP93 +Oe8VpUrXBBfizzHVxP6VUmxM97IE+gjVRqN9PuMrp2D9yEBUGk44fQW5zyuuomYa +c7Mpx2fnWgGA/Al9ug2uvS4oIzUyLEJxpc6M8RYluacSIjFgCigucRsvTBy6lobG +1FMvnQyze6+fAeKbbrK85OuA1KW3EACfsMyLwntqn+Qu8r3k/6IRn0i9XV/bhStE +2y6iHUmqs5sd7dfkmVI7bspoOuDKFIErdTephH09E0hvQDJERnMm+rh8TlZtOS/w +Yywx+2ahSh5Jt3dI5L48ozR+WJbExiXq8ZqTnpn/EQGQ8MoM+S2dS+czX85ZL+m3 +ig+tKHwaaXdvGcYI3h8WwQnX3IBUFCur8WSdfcoGyiQ4cpTXcI11GgGgkypxM8wx +xoLVCTttpCBRCpPf8/PLKMCK0/k3u4QShtp1WDDQVhFm/E6ofG9TSGIKcJmsHHQY +7rukEp6lSIvmL0ZjByRah4nK5zoc2j89sNpyuemZwr9X+V9LOjF7vQTO/8y3cBBN +Ct0R5lrxeBvRze15k0DzShuHyPhg2PBqfPOS7RnUiF2FeI+zQ7xFnLqoD6ckI76R +RAf7w0sqnvMlDRpjVU+cDyupR5NdB79oPXJpHltKg4kaQ4O5x6BXHVEpAMhJc8bP +vmfAiTFac5f0ycibf2R5tNlzbKMD/BxVrzXMghsJ5PWmAiUbqPv1II5kLw51b6Bz +vl8KzJI0h+ySiUGb86yecfHGbF7zPRch2Kt5+7t0fgEjAVcMRfcgHsfQn8EYP9zo +czp5Gw7LvR8BBDq1dsTEEEPTDre+HyGxpDN4c8LNGrDaCFdXnOdlNV/zT9VvBk/R +kV+Tl/Lk4okEWwQYAQgAJgIbAhYhBOtMG/1PBC9t3czskXch9jvTi0eWBQJj7PlY +BQkFo5qAAinBXSAEGQEIAAYFAmPs+VgACgkQ6Il5+5swrPJG5Q/+PMhN1qYugsPE +Qc6trsy3ZLql4evdcxulYR1GUDW/OXsBoxg7vw9ubtiRa4QHJpczq8YILy+GvFmr +T10Gj6g2WkoeNXpTNWGtAu3DUKu8TVQNKXDeW0Pil12TLkGgPPQQpU0lyE8+o+Du +Kb4QBvMvENhPTL+1GGrNDoQ4M1SK8trNaNj5pdao5W/Y3LTvXK0VIher/UbvWkJI +Bh2LeLsj9x8yg36Dbs1/1l9ztBZvDTaZyZOqmbCysIO7pFHSTiBCGyyzS1PWWJsr +N8DbQyjH5uE+/Wm0jcDSJ+HXeYWqR/QQLgyZ5OFpxTmqfQEGT4CV9llygtg10GXk +l9VV6SN66+xUm0nnPHeW4rcO7NtF1skAdvmaHrUcTYEddOBiIfy2o7WrSyhXPTZz +/UpoXsvJ68VWRceh7l7Jxjj5G47IhWDLMbT1WJzu9pwQ0wz+GXoyzmmstirQm/KS +ZAh/FNILqrgxlXfktNl8feO3r8rx6hreVdMlRTw+7gLuwOUAWF77XLc6vd0tY2Qy +KDD/dznvFaVK1wQX4s8x1cT+lVJsTPeyBPoI1UajfT7jK6dg/chAVBpOOH0Fuc8r +rqJmGnOzKcdn51oBgPwJfboNrr0uKCM1MixCcaXOjPEWJbmnEiIxYAooLnEbL0wc +upaGxtRTL50Ms3uvnwHim26yvOTrgNQJEHch9jvTi0eWzxkQAJoEooabuFEvyaFp +0f2nohX/bqaG11Q5wZ6jgF4jFGhXkvoVLoeRFlIQyyFmL114T2nL26VDpccC7CyH +T0UBhkqdf66oVUZ5lrCd+A6ACsRuxJavBAKyv6Rfr+MElDHoIwDyUHryHC75vN8/ +ox5m5NQBHoqAWE6uOUW85R5si5hiv809dypwVFhN7BZBAqHKPrzJYvKD3i/iTH4j +ID29rw7PufGJR6uVtuqXtPAcBs2OS0DOybedqMbKoFxF/zfeUKoEnLHOtucAiBPP +0KOaV09EypPuVYhaI7NhIt5oFxwVxYCEnQLVgRJqjfUxEqjz6x835xZPbepj4Na+ +Tbd+yCju6E87u/0l6yZVzyEPfTZauhzv5jFXWI21hQT8PPjRlRnpkHITjg2bJLLx +yRleIIzKVtRQt+zETbImotVDK2lcc7KwrXuP6KqWu22PFXVsOqeZr33a6C5MB1tn +EtpYAvH7e3uJ6Yh11ywCIm/rBR3KyJGbtLicRgiTpFMJGg6wBSls2WB3NmFK1uVz +ewjQaP33vdK9Vvf+HrJ+fUjNpkzGq61J9X4hMcBYlHIuFPt/+1OCIlYjXjaGdidf +oasbnZcdTk+wHtloOHSwEqBB2jCm8uPiVVYnAPI3ZaHKwm6RL9YVVeO4cIinPlU0 +BrwmarPHk/qW58NUXnHddyfTcu2ziQRbBBgBCAAPBQJj7PlYAhsCBQkFo5qAAkAJ +EHch9jvTi0eWwV0gBBkBCAAGBQJj7PlYAAoJEOiJefubMKzyRuUP/jzITdamLoLD +xEHOra7Mt2S6peHr3XMbpWEdRlA1vzl7AaMYO78Pbm7YkWuEByaXM6vGCC8vhrxZ +q09dBo+oNlpKHjV6UzVhrQLtw1CrvE1UDSlw3ltD4pddky5BoDz0EKVNJchPPqPg +7im+EAbzLxDYT0y/tRhqzQ6EODNUivLazWjY+aXWqOVv2Ny071ytFSIXq/1G71pC +SAYdi3i7I/cfMoN+g27Nf9Zfc7QWbw02mcmTqpmwsrCDu6RR0k4gQhsss0tT1lib +KzfA20Mox+bhPv1ptI3A0ifh13mFqkf0EC4MmeThacU5qn0BBk+AlfZZcoLYNdBl +5JfVVekjeuvsVJtJ5zx3luK3DuzbRdbJAHb5mh61HE2BHXTgYiH8tqO1q0soVz02 +c/1KaF7LyevFVkXHoe5eycY4+RuOyIVgyzG09Vic7vacENMM/hl6Ms5prLYq0Jvy +kmQIfxTSC6q4MZV35LTZfH3jt6/K8eoa3lXTJUU8Pu4C7sDlAFhe+1y3Or3dLWNk +Migw/3c57xWlStcEF+LPMdXE/pVSbEz3sgT6CNVGo30+4yunYP3IQFQaTjh9BbnP +K66iZhpzsynHZ+daAYD8CX26Da69LigjNTIsQnGlzozxFiW5pxIiMWAKKC5xGy9M +HLqWhsbUUy+dDLN7r58B4ptusrzk64DUFiEE60wb/U8EL23dzOyRdyH2O9OLR5al +txAAn7DMi8J7ap/kLvK95P+iEZ9IvV1f24UrRNsuoh1JqrObHe3X5JlSO27KaDrg +yhSBK3U3qYR9PRNIb0AyREZzJvq4fE5WbTkv8GMsMftmoUoeSbd3SOS+PKM0fliW +xMYl6vGak56Z/xEBkPDKDPktnUvnM1/OWS/pt4oPrSh8Gml3bxnGCN4fFsEJ19yA +VBQrq/FknX3KBsokOHKU13CNdRoBoJMqcTPMMcaC1Qk7baQgUQqT3/PzyyjAitP5 +N7uEEobadVgw0FYRZvxOqHxvU0hiCnCZrBx0GO67pBKepUiL5i9GYwckWoeJyuc6 +HNo/PbDacrnpmcK/V/lfSzoxe70Ezv/Mt3AQTQrdEeZa8Xgb0c3teZNA80obh8j4 +YNjwanzzku0Z1IhdhXiPs0O8RZy6qA+nJCO+kUQH+8NLKp7zJQ0aY1VPnA8rqUeT +XQe/aD1yaR5bSoOJGkODucegVx1RKQDISXPGz75nwIkxWnOX9MnIm39kebTZc2yj +A/wcVa81zIIbCeT1pgIlG6j79SCOZC8OdW+gc75fCsySNIfskolBm/OsnnHxxmxe +8z0XIdirefu7dH4BIwFXDEX3IB7H0J/BGD/c6HM6eRsOy70fAQQ6tXbExBBD0w63 +vh8hsaQzeHPCzRqw2ghXV5znZTVf80/VbwZP0ZFfk5fy5OK5Ag0EVwyNyAEQAKsv +2AeF2vqBBfhkwDmyWnrbzE6scKx0s7nhY109Ep4UdcmpJImLd+zwXEFYjgWd6N4p +QZsX4ys6UWkqoQvFoyN7tvBnJqneLPO1kezM/diY6hMEm9EQYp0KQvzZwuwKFgP8 ++uATxyu+SFKer169ywoCfOIzGD/AMIKFQvcS+qjb0F6gHzV/4T3CStRMwJP+RXG3 +ekZFqUpfRSGu0qumbzJF+O58l/COR3CC+KeREZnYatYePgvMxuL3+51holnrpjDS +ERThRLFQH2822ZIWtvgQH3VPauFzrx2BDiNgEjsrgRtvxdpYDFv4gCrfWXVSSIQD +fYXipQygvqsKEHjLqcfE6dO+z5cRvlMHBdWiCMtEpNCzlT8dX2XuP4cByGTnLeKb +Y3ZQqYzEeqi289llRk91oJHFR51B/2BHTItlX5T0FwO7CPMv/OOu2E1liUQYnodn +9MtJOnh0Mf65e4uoxVbLmKq4q2duuc1NC2/m3AP4COmDLrRgs4n1hqIngaOJ86nN +KTzd7Wsnen+lfoHk1ZCKdUtknPHJ46iHeIyN2YINKcRcusKZi/mDqPJX9Zt3gZgW +4wrxNPv49B1Ytxtn8vFznDSz5zv5/k5+Ypc7ko8eedSysXkMFopE+NJynB49CK3F +4iCVSAQwOQ2u4GG7U/MLF3cG1eC774rdZ2gfdVyDABEBAAGJBEQEGAECAA8FAlcM +jcgCGwIFCQWjmoACKQkQdyH2O9OLR5bBXSAEGQECAAYFAlcMjcgACgkQE5e8U2QN +tVFBJg//QTCvdPt7SyhPPyDhAkstWpkNl1fwh7PTiJ00e68C7QDB1nbCXQL60yQP +uXhHZojoEp7/3A+d2T80l75lhwP+7PKIoglAPjw+uJ82fC8e70DzSsTgGmlCemUQ +16GJttZoY0lA40YUnHtBNiUWNLks2UbUBfqZCPG9vjbfM5ZI6YRqZhdgGZjIwbq+ +Sv9dM/OyV2TLxcW4+slRmyUv9aXHfVdDUiu2Qcc5ipbCvSFNznT/Y7wfR7CX90Fk +urcSaKdln62xO6Ch/SPhJvFiGmXD32cbBs3W5fLgvz91Y5Redjk6BpMpk8XXnNEz +Fc30V7KUFVimnmTOt7+tEjqZDaVp9gd1uO93uvIcXkm9hOhINd3SbMXacvObqPCw +7zjtk13kZ1MPr+9x5/Ugm1rWdLAD+GEu2C2XPr+02dyneUR0KMAzHb2Ng8Nf4uqz +0kDFwke5+vzajrAz1MXbhDytrw1u8Hreh1WJ0J+Ieg6wgUNStrMfxe5pDPJmQjRt +vMuaAwC8w7q7XM9979Mrot0mDsB4ApJw4lLfwPmabBoPVsAGvrt5sD9fkd1qiZIM +pV1Rhp7B9MYEiytaYKYql1v5Z9fih0Wk3Ndb+qySIGnlZJ6wq83VBSQslkNkPWTP +b75e6XkH3uzkvEtMtHC+Aug1pQWveWd6PM0uB0Gl/oWeQDn2zJFS4g/+OIBna4nN +dUo95EmowYv5R7YeJUR7yKZrdJAhx6pbyMpLtzs2Jzp0xbGCcOmTm0mZWOuB9zRr +1zUVjgaiZQFtkWP6Pvf5ZGTcRpUoiy3L7HPjz6uUmN1gVUImjQmnruOONc63oQ4d +OeI4D8A23NEjMMydavAlnsDEIYnhiMtKGRxEz1LfRfSK4CTE0gtf75I053xqpzZs +Wh/JMKRxuToIhzVbTS99+/VAZM2Oy02lVKX35tGaUkOolacMaTk2Xg1yp3xfnVXS +1gSDpYQrg4zzfDHkTD9N1NGrmMmRsK7YZg1o002BdbttdK9PlUg1u4/p2ZuLX/yz +SMolA4QEjdHHGgH7TUODH1h01+OwIzeG9C/KRpYwAt5kinNtfFXXXxj1BBQhF7Yv +7hdkAIQCFaOPYrXA/s4MZNCusziriwjDWXeXvI71pdc3uWKQAB11PhYSkZXQKKit +F44nXL+wolElIj+ek3VQrvAxrNn7LOHE3SrMbyypEQolCc264ZBD+ogZv+h7h087 +QWKEBQaRahrRuC0ZWKM3l9BJsxtXnSRlippr0ac1BGdyGZlv6krbEtwrzmEPWenf +v8CbmjaCCg42PeojclpEexlo6zWo9BzJ3jo7IgVSkckh+Tid7MMKhBh33dtZQaoP +DEj+f8Kx07TfrnKoM78AOxx6GVWp0C6lW/aJBFsEGAEIACYCGwIWIQTrTBv9TwQv +bd3M7JF3IfY704tHlgUCVwyNyAUJBaOagAIpwV0gBBkBAgAGBQJXDI3IAAoJEBOX +vFNkDbVRQSYP/0Ewr3T7e0soTz8g4QJLLVqZDZdX8Iez04idNHuvAu0AwdZ2wl0C ++tMkD7l4R2aI6BKe/9wPndk/NJe+ZYcD/uzyiKIJQD48PrifNnwvHu9A80rE4Bpp +QnplENehibbWaGNJQONGFJx7QTYlFjS5LNlG1AX6mQjxvb423zOWSOmEamYXYBmY +yMG6vkr/XTPzsldky8XFuPrJUZslL/Wlx31XQ1IrtkHHOYqWwr0hTc50/2O8H0ew +l/dBZLq3EminZZ+tsTugof0j4SbxYhplw99nGwbN1uXy4L8/dWOUXnY5OgaTKZPF +15zRMxXN9FeylBVYpp5kzre/rRI6mQ2lafYHdbjvd7ryHF5JvYToSDXd0mzF2nLz +m6jwsO847ZNd5GdTD6/vcef1IJta1nSwA/hhLtgtlz6/tNncp3lEdCjAMx29jYPD +X+Lqs9JAxcJHufr82o6wM9TF24Q8ra8NbvB63odVidCfiHoOsIFDUrazH8XuaQzy +ZkI0bbzLmgMAvMO6u1zPfe/TK6LdJg7AeAKScOJS38D5mmwaD1bABr67ebA/X5Hd +aomSDKVdUYaewfTGBIsrWmCmKpdb+WfX4odFpNzXW/qskiBp5WSesKvN1QUkLJZD +ZD1kz2++Xul5B97s5LxLTLRwvgLoNaUFr3lnejzNLgdBpf6FnkA59syRCRB3IfY7 +04tHllaPD/9jlUs3zxXz1ISUsM5oDV9lrFuljfdcLW39KFKTkSuKLYyRE1E77q1R +z4p+O95kgHiMqczDtaR0ukNbsj4+RJvMewYBs2tYQS1E70yKUX0vieeIaGkC+lxp +6xN/0CJfwMRiuWqnPYexKrE24T3JIOgRC1rnioNT6QhlrUNYoAnLE1Lf5ICeeE40 ++3VMrhQgGqVYGOpTJRLWuHSGCXW3kFpGUdON6Oru0dB72B5dD9d7YQ+NYLoXWbDz +WoepJuYXeyBF7gTaPx0Xkh54iMwiqJaSJCcp/V9YPkiieWkOjLxXdi+KZKiSrfpz +b5KEFyE8PchMQxyUkAoV+UJ8HniaFNEtkHOlvYy/asjsN1PrLtv6D805NsUbtQsI +mC3jY2UjWIVPQM+/ArLza2VFCgpoma5JjfLUZRRabN02hf36HcLmH1jwv0fVqSm7 +Wqo489z6lx2G4eTclEVcPxKrzMtcj9uj7EJ+NbRORG53Zej9mM4wGUCyjU3OfOAV +6u06o+eY3nh/7Etl17+YBdkvrZvfjcMrmr5dZguQjWi/im5F+sPzmnSDVDgK0Fth +wtUsKj9fOHzfXCQsdzXgduJCoPODONqkD1DiB34rtEdOiSmj1om5PVgFOrLEC3K2 +0bOTWdMkqiVlNLaUv1uGZc9WI2LZ3HtFQG89uTgAAmdGnSp1oCr/DrkCDQRYhr/M +ARAAra5iu3Ndg38ZDwCUg9dlWp8Sy7742FtChFisTekzEUAmsC4RsBX+xSzaWm/N +vQxTIQXDVYCfGlNGCCLvXx66CGhovm5AZA5ElhIUk5WGuiEnwZ+mZPQBgVgf0iHp +Tp3LgHCPyJOgXQ8yF5wze3F7DIFdape4yG3W7HV+t8GRBcKm2oi9RlneAVCoY9B+ +OmUxFQS5dDHxzZWJfl94m/VqUUuogQBjlrPCqsOcVRO6CZvQw111jnT1BPPxVbYv +m5js/iAsEBE2PSNhJsjV8lBt4mirNO1QH+0jx4YMgRJDJ4LeWRnB+ILNZtpfxxUN +ErTRTYV7s5R70IQ83fTNZ6qbLRHvn/AGTIMjZzPrhhyavVVbWjZ8LTA2rLbzZFDr +PmrjF2zcLJecvzumT1+SOSLyZlIW0odFDoLhjySlLv25eubYXmoc+BxgQNEOZ84D +S11ZlOmonNeN4oscTJPv6gOfSOinpYFho/UATwB1OvP/yeHTxbZbESme7yNLw9eM +JzL2iRb+rck0oDXVrEDglovp9eqxnzMdSzWUztWzukxOvmS7rznIRiU2D+C2wnli +y/Kg23X896Eih1KcmpRCyZY84p+q96qAN06r6BiuTlqxaM7oVq1rd7SLaOd6Cd+L +yQrEWdKryg5/T0F/a96OeoSoZ/Bvc/1+bTZX683kmIpFkHEAEQEAAYkERAQYAQIA +DwUCWIa/zAIbAgUJBaOagAIpCRB3IfY704tHlsFdIAQZAQIABgUCWIa/zAAKCRBk +lMbWmXwhXluJD/4mavm5UQ84EczsNesfNL8gY3zzlCnfvnUlJHK+CoYub4wcoDXV +UlnCmWgSlZHQZgr3/qfW2MM3y/kXcbxhL/FijUzY3WlnCdnIVNjuB+QJt0LHbkP7 +En/o085ZzHuzaXxfZ97qN+KPsRBTjnJ8hd3B64cVjgnXva1+pG51EK4iDF2bXiWP +HvUbPiL+Og6C9XjpWrwIA1CWyH/4i7dtfTnbViO2aqKQNHfrXJ+xS938Lr8r5+Vm +UWByHqweBGIASOmwsJeSUHozkZYbmMdaJJ8j458zyfS6LO+HIa3+zhzidOoiEH9c +5QvVf54gNsYjPTcHj7U0DgkxCVQeiBKBLR+q6M6QHa4qax/X0Z2ZCcSDTZwqGJNa +KfcFYd8X1B2zgrxkGweeHKjfmpqfXRKrggHumLdVqHU7KS9cz1yeTL+Nw7ne+kzR +MEA8sLnm4ODRUJwUz12RqS0GG1FYV0rjJVWVzRFMfMUs+7xAptEuMdoddkQSmytk +XyOKAqv8KQ9XUEbGWikmCxW2cOY9spOpwQa7X2oXe7FlV9RfmHYrG03k+YlIREgF +qlvWwsgpzURculd+CIFvT3vci7vFm1UiQBb5wC8bHOoRsr7OXW1267lipouZr5Or +QhVnRZQVa64cdUIKjLXEt4790uxh8ggNwktZRILIn2JHjgEQICdYWeQb1Lq1D/sG ++upSIQwdFPTbhXSVE3Opzv9XMt4vZhglaKsJk3AdQSfRNYZ3DFD9fzL6wIJAQawF +iYg9l4/UFf7gaMwO5y8a1e3H9XXvTi4B+HjRH19ucY/AQT2J8lch7MpOWRw4Y4/U +mrq375RVmItd4uYnjKci1SVePq9lotcdVIClQJQe/LB2J2w80qBzywXCMbSCqd9C +ydDxJGrfEhuxtsILb9UXYZnGRAVdObzJ6xhjvfdXvqSs0TT2B/Kw91UCiZb2hcLC +bgU1uNoGdyn6VDSiNroAnJ0TaaBxVjQq85SdAhSOPCzJZlErPu4v5fkBpXmiykMU +UzTaQJnry60u4GuCKtCBKsXsulVukUpP2dWd+yfAezyEkkdK2Z+k3skIBVn/xTi8 +OjrcDqrhpjHhkqo9lM8cm8oLbL1Gc9AcWMpqFhXeBfLKeN6C9k11Olqe0CKQWhYJ +En/1EMX0esHEN4r2n3ktZYPL1BbjH7jC7aOk9CYmcPLikrg1pbUkXhfhV1Z4WsM+ +9gWTMvESKLIRnaVh5/2Gzei/iTrsWZ75DAGb0i093NB+Fwg2LRHytpiTKg9sp1+b +RkfBctxgGhI4cd+k7804wl0ZifhZ5Ultae+8flIxVBXKWPLJL/n9Boqd9IspwG9Y +aAHYmyA2m+tdjlov+L19A2jOrevFKvK7Gm3iWLGRuIkEWwQYAQgAJgIbAhYhBOtM +G/1PBC9t3czskXch9jvTi0eWBQJYhr/MBQkFo5qAAinBXSAEGQECAAYFAliGv8wA +CgkQZJTG1pl8IV5biQ/+Jmr5uVEPOBHM7DXrHzS/IGN885Qp3751JSRyvgqGLm+M +HKA11VJZwploEpWR0GYK9/6n1tjDN8v5F3G8YS/xYo1M2N1pZwnZyFTY7gfkCbdC +x25D+xJ/6NPOWcx7s2l8X2fe6jfij7EQU45yfIXdweuHFY4J172tfqRudRCuIgxd +m14ljx71Gz4i/joOgvV46Vq8CANQlsh/+Iu3bX0521YjtmqikDR361yfsUvd/C6/ +K+flZlFgch6sHgRiAEjpsLCXklB6M5GWG5jHWiSfI+OfM8n0uizvhyGt/s4c4nTq +IhB/XOUL1X+eIDbGIz03B4+1NA4JMQlUHogSgS0fqujOkB2uKmsf19GdmQnEg02c +KhiTWin3BWHfF9Qds4K8ZBsHnhyo35qan10Sq4IB7pi3Vah1OykvXM9cnky/jcO5 +3vpM0TBAPLC55uDg0VCcFM9dkaktBhtRWFdK4yVVlc0RTHzFLPu8QKbRLjHaHXZE +EpsrZF8jigKr/CkPV1BGxlopJgsVtnDmPbKTqcEGu19qF3uxZVfUX5h2KxtN5PmJ +SERIBapb1sLIKc1EXLpXfgiBb0973Iu7xZtVIkAW+cAvGxzqEbK+zl1tduu5YqaL +ma+Tq0IVZ0WUFWuuHHVCCoy1xLeO/dLsYfIIDcJLWUSCyJ9iR44BECAnWFnkG9QJ +EHch9jvTi0eW9zAP/A0WYtLO0i0MGkIia0+xqwArCDI2KOkmqVFcQzBdvEHwDVvN +PQDaati3rfsgA5hIm0oKYg4ju66uj72Jx5j8sZk2xMDLZtWw4tI+ef08m5zTeoZ1 +KPBfqNMsAiY36E/Bg7gV+dDg6DmFDJiKGMMjM/1LTYvIh7cUwT0eW+5dVbfBH1G9 +8K8BmuIttpo4CylOPYezsotVWGUazPtIZa5mixe/bU/ZrA55/N5oKvann5CblOJw +alF7ovwmOW/LyVwvvLQ/qtcAolDPLr9iybP+ScivNMxSW5AVwP2QmLVNCyRKVH+x +42yAHQjA1o6XOI/iMo1PgMb/jZDC4GEYWmnZz0Vc6mPH9k9gbPhEFpNoutQVUDKm +pBrcAViDAqdn5xgwsSC/xQjdZCANCdIfaJpoTIGXTiWgJLbHXa/y8FYf4XGF/DH8 +veLz7PhNym+joosD5JDerpkL3RWvUYYfUlDb5rV8zKN1hCy6G7b1Sgvn3RVWrQ7C +bq00SiwyhLz40sRZSf0/LfciLUwQGe/mm+JyYVqBG85FU4DYsywiTZnQBLYimvXR +GTmZdA1ZsQYQdWqjHxwN0uVIWV5hgR8Ahej3KZzNwuF2NjI0P7EcXRWu/xxQSjjt +e+oeh8ro0PwMjpZZryQgoPR89FpNLY0zBbJwG4e3QdhkzUMATWetIFAlkfphuQIN +BF01/K4BEACskZL08crrKfX2aD2w8OUS3jVGSW7K10Jr/dgl6ZB7Xx/y3c9lhBim +7oRIsl6tpR/DBP50UnTIgBbvynbJ6tbWGptt64AznI7el9pH0k63DOKcfqRUgJKT +M4OUZSkcuqQ2qnkvn+g0oiJ3VhaVYOJdJfJF/pLj5Oi3UEL2afoEd048/lZEaATR +vEqLj+h2pSfETEl5wCWyRnuMSu6ay9NmVzRxiJhPDGW2ppQTxJuaKj+6Vqw5WISu +9nsRxTPE1DW8f7LYyPBwgultuSYKZoCdfoYE8ff471oZIuCKcGSSBHQbR6MBTD6K +JtqzBzpfJ8zZJmVO4lg0CJgp9xX2QZ8hPkpaBbnq2JCMS1zriCMN8iGhW6ZHYmZQ +JtWuubuZt51VL9QmEUUhCF1t+3ld11SaowY4NFKILUdYbC2zAOQIEEJkWRIHKleu +c2zYSNSoXl06oGgwCKQb5l+LlcYHx4+/F3+KzyAq0NqBC1rMnhbn3tcckdZyhLEp +nx9/y33ypo6ZZ0s6dLGrmSpJpedEz6zr8siBa4uT3IvVF4xjfpzSt3cMD/Lzhbnk +5onUfkmoCmQ/pkuKpMr35hHtdDxshLcLPFkTncMjEVAOBToHDbKDSplueyJm48EL +Pi9ZmuyNu7WsB8TWVEAkUShxdeHALVpY1D+MjXK+Z5ap6/tppj+fmwARAQABiQRE +BBgBCAAPBQJdNfyuAhsCBQkFo5qAAikJEHch9jvTi0eWwV0gBBkBCAAGBQJdNfyu +AAoJEHi9ZUc8s70TzUAP/1Qq69M1CMd302TMnp1Yh1O06wkCPFGnMFMVwYRXH5gg +oYUb3IoCOmIAHOEn6v9fho0rYImS+oRDFeE08dOxeI+Co0xVisVHJ1JJvdnu216B +aXEsztZ0KGyUlFidXROrwndlpE3qlz4t1wh/EEaUH2TaQjRJ+O1mXJtF6vLB1+Yv +MTMz3+/3aeX/elDz9aatHSpjBVS2NzbHurb9g7mqD45nB80yTBsPYT7439O9m70O +qsxjoDqe0bL/XlIXsM9w3ei/Us7rSfSY5zgIKf7/iu+aJcMAQC9Zir7XASUVsbBZ +ywfpo2v4/ACWCHJ63lFST2Qrlf4Rjj1PhF0ifvB2XMR6SewNkDgVlQV+YRPO1XwT +OmloFU8qepkt8nm0QM1lhdOQdKVe0QyNn6btyUCKI7p4pKc8/yfZm5j6EboXiGAb +3XCcSFhR6pFrad12YMcKBhFYvLCaCN6g1q5sSDxvxqfRETvEFVwqOzlfiUH9KVY3 +WJcOZ3Cpbeu3QCpPkTiVZgbnR+WU9JSGQFEi7iZTrT8tct4hIg1Pa35B1lGZIlpY +mzvdN5YoV9ohJoa1Bxj7qialTT/Su1Eb/toOOkOlqQ7B+1NBXzv9FmiBntC4afyk +HIeEIESNX9LdmvB+kQMW7d1d7Bs0aW2okPDt02vgwH2VEtQTtfq5B98jbwNW9mbX +TvMQAKKCKl+H8T72WdueqgPKHEkXDZtJmTn6nyneYlETvdmHGEIb1ejxuJ5URlAY +nciY+kvSQ/boKjVHNGmf6+JBexd+HqPhkeextV6Jcnmi47HDvIU/TSynhuqZeK/3 +SZAV7ESqQl42q7wm7Pqw0dkv4jjFCRxDA+Qq2aH6szJ7DZxTRWqfR3Zbe78NyFVX +KxhFQO72zHzC3pFu/Ak59hmTU23yoXVo5t+5O+Q21kX2dbuLd6Px1bnT+EmyneoP +P1Emea5jgsw2/ECqHnvNt6cbp+42XYldGh+PBHBmucC3Mn7sALajHe5k2XkNlfbj +SNlmutxQFH1qq9rh/JVyxJNHeGzV5G0timAwfdJFUzE1vNU5P0w4O8HrCsX5Ecfg +cw2BQ9vPCE3OfG+11xp6oiNMRVsR5pTu7RiI1BQAyICWUW/wXuhhHkkwNTiwfciJ +fVA8ckOiRubik8geEH5boOxgeAaBu6yusQVHnRRyG4wjQ+qsWo+wDI9WMdtpNG1t +oJrSUL4OYa4oX3YogSv5hGrbYIaP4HwO6O2oTMnS0lRIGJOqbEQcmKUa/nWT/3Ni +pTnYzyMjMlEQe89YKjd+32tjMfOSdIOvwCGaTizdWnKPF77qB9D0v8C/7AdHmEFq +f2ZX8vK31aaY+ZpPWG5IHlf6f/buIMBalJOxIBeveBqxcHwQiQRbBBgBCAAmAhsC +FiEE60wb/U8EL23dzOyRdyH2O9OLR5YFAl01/K4FCQWjmoACKcFdIAQZAQgABgUC +XTX8rgAKCRB4vWVHPLO9E81AD/9UKuvTNQjHd9NkzJ6dWIdTtOsJAjxRpzBTFcGE +Vx+YIKGFG9yKAjpiABzhJ+r/X4aNK2CJkvqEQxXhNPHTsXiPgqNMVYrFRydSSb3Z +7ttegWlxLM7WdChslJRYnV0Tq8J3ZaRN6pc+LdcIfxBGlB9k2kI0SfjtZlybRery +wdfmLzEzM9/v92nl/3pQ8/WmrR0qYwVUtjc2x7q2/YO5qg+OZwfNMkwbD2E++N/T +vZu9DqrMY6A6ntGy/15SF7DPcN3ov1LO60n0mOc4CCn+/4rvmiXDAEAvWYq+1wEl +FbGwWcsH6aNr+PwAlghyet5RUk9kK5X+EY49T4RdIn7wdlzEeknsDZA4FZUFfmET +ztV8EzppaBVPKnqZLfJ5tEDNZYXTkHSlXtEMjZ+m7clAiiO6eKSnPP8n2ZuY+hG6 +F4hgG91wnEhYUeqRa2nddmDHCgYRWLywmgjeoNaubEg8b8an0RE7xBVcKjs5X4lB +/SlWN1iXDmdwqW3rt0AqT5E4lWYG50fllPSUhkBRIu4mU60/LXLeISINT2t+QdZR +mSJaWJs73TeWKFfaISaGtQcY+6ompU0/0rtRG/7aDjpDpakOwftTQV87/RZogZ7Q +uGn8pByHhCBEjV/S3ZrwfpEDFu3dXewbNGltqJDw7dNr4MB9lRLUE7X6uQffI28D +VvZm1wkQdyH2O9OLR5YsPhAAuRTTsJAAcWWdQvCuMFA5djnu5nsFUYVTar01kuLo +m7xWse/Bw8izaipn4vskR0kLAwJCq/Rs5gXNQzmm6eFfiEcI9LwAx23KcKBjOxCd +hqP9EbXWYkz/fAfAzAArtezcIzNZeRFBMaoxhHl6d4xGRjEhPL6o6vHY5L5fLOZl +DI9PVqY9xpeOuDTUP4JwdD/9rKddU9AVW9rt9szSySodGT/UMV098/d1ATnok/Qc +0YahTMpdOMabea6mA1HYi+8vrWQ0dvCDWWe2mLArODZANthYepyN7+N1LN/Piq6K +7tPYqSxPAOX8dxuS8TMLViQPs2YrK65MvOiWBBTlCxqRbiq5JT9m0cmh3j4zHqjs +w2Of5+bp6pOPhGQD1iDOCxTsA7Uw9QVprg3aT/0Yz7j2VIaDEltKf1b/xbgQJ6YF +UPQ/5FyN8WgMSoGj6fgM0DedHQqLJp+uN8wBq2U4iAyKdVbV1URQFYpzf5myZKkC +b/MzP8dG3OhFDW/yvAT/ySafseQ9dw47V2FBBvExR8+mCmXvUM5YSua2WPxbbcKr +8iryCmp1xIzn+f2s5HThpUjeme+BuJdtrbot8twjPr5ka5TIgWy5Ak7j38PK0urs +4geTaPD2AcKL26jb6ZvnRjR8khU7zciYc1prwlbfBWfnOLajpsHfw/n4aJ9D4ONm +7vm5Ag0EYXgMnwEQAKTYbBq76z+kqY5OcYsycHfJtCyJUYtGGzDZKpJO9LIG2EDJ +bVQCp1Go0Dz8V0yW9W6mkbz68Itc+ak/rcL70za3JB45HZpVCn67UQjA3iU+7jCu +Hq922BJGKby7EF0sgcbXrPTHFWETasz863+C5x+SmgTDbz1VpHuw+YjF9VObVByo +IaIVCXetmeZroHPxQ5cmgTzA74srlaRx2YXPr5ncEtJvkgv2WGwCsVOg2rl03ZJJ +NsJN0dHOtG6gJ159gd3GgsIziQl+CBFGBaEqgQmuj5mKkRKrVPnIdX+IAnL7cO4L +wttgu+yO31VlgBHDzEgZv3RWPdOFa4yhWv8hWYpPt+gLlaF7Q9Mj9vZ+s1pYLHEL +hxFXEy6yIFfrqZyVDgy/PCPnQbVYrKBQthy89DzVODSm+BMqqDHAv2W430yINW2S +f0JnHs0WWzNWv6JzudUmMA2cl5sUcIRl8ltCji4jAp9huGilSQJrjWJel6xtXMAq +o3B4+DI1MAwXJieMBQTwVVSbHtklVvXmIRsyzfyAkon9ZvzC0lF+UB+SGsTOHUz/ +jdZQBTdF8+ZdOsZ7IMJgpMeJdOePvjy4fpTjSwH2r6p4SFl1OPQq5+OC1ZoVaxlO +RWu0Y5ojrF9fqnL+TeGNTd6z7t10SXGE8Fyo0TemQ9Gpv3DP57FJ0zuGJTM3ABEB +AAGJBEQEGAEIAA8FAmF4DJ8CGwIFCQWjmoACKQkQdyH2O9OLR5bBXSAEGQEIAAYF +AmF4DJ8ACgkQTrJ9sqO4i4uCCQ//Ug1HJFOguZjWaz0NNYxDSXBsEvwnfG7+d4og +4pUY53D3NxaUa6BSg62FJtPxuO+7JsfVWPHjAUz5ye4xV+MPnxe7pmmAIc3XBdgy +7NjB4EUpoyDihLBMq4AkEnYiF8Sb9wCvJW8pjbNj67LOCLPHe8CDeyOQA8NytIIk +/aeS4dwnefNRso0COZ0yydYOuqplXA/32e7IyTxsC255nRIq8ikK/bAh5g7vOSPr +W+5A4U4aGX3w4G6LnBSG2BDD/96xNZiIY0pKYPd16t3YkdUDTW0GYJZXgowsNuDc +JwwxDXHdXWZ7oQbeCLAEvUj3FOwFRsRrp4Q31TTN0q+gxtKiA43nAK7EDM78JcYy +t4m0FS6kcRzr2hO7B7jboiGLcBtGs8CDe2cYYUK3XUehAU2dE9Zve6cXxSUDatLK +2/AXJCLenMFi3lWxMgDs0Qca4mz786ivoA4ifOG3VynsB+YMZ8bLY3mjD7gYjoU9 +7ZSoiDb6cWIav2FFk69dGAtAvx2UOcUKHKaV3Gb8n9QV0kZJZGV0QOw+vMdARIq+ +xX0SOclBHmnnORArqPHTOpKUOCI0bYZPf8JK/Ah0KKHoKX0dOEe1g2bdlg3RtT1b +aN6guHcAg01NyunS0Adm5AsXG6RuPno7l4H6d+Trv9faI2KLjpl0lA3BtP1g3oKy +1DP4KeqDtg//cbpjo0chCCBeeVgiLeLA3vaESASrPq8hErzuUEZbavd5DRwNm4Tf +7lDgVhyLD4HZEp4OGN2Y8fKkDmj5GIDIjsk5nAlqWoc7efAkbmyvStHNwmxsa+lv +OyjYm5PJNRG/i0E2rjlv3LRB3O3k+k2s8ltAAMlaf4daxtUkHmBYFN2hBiCnJOvz +idDKxxYBQVNFuYe+2MIJ8t29TzAzu5sBDkPCLWkAFG21EAy48D3gfNoEXnJeSCHE +emdbQhxcaLCByH0tDJo71VJGGI8fqvlm6Tsq8aEemHtILkmBSf28maanXNx3SZdD +ZmHwzzUndGLeIY8czYKqmUDFc1siufO1sQmE3Yj7vubvdnh34rWK+DrFCG15JmHC +HHv9ndOX5TaNg4QUif9QWZXdTFFIlr+NBFyO8wqmtKni0BnbIkjdtpuNFuLGBQS2 +UrXTn8l6nFwB3D2+izE7+tHtWoLO7Ryil3ELQYAfyiD4D3/cs+GVEbLdCF/OPL0k +dYePgQiyiXTYFLz51a8Chh6uS970736Hr8HnAz9ieD0GNP46s2+R+aorZyykFfBh +506sLi5ZxSa54RWu/k/gUXfrAn56O89Lq91PFVN0teOi5QfBNBBlWU2NZjdwjKPT +ednX1z5vfT7YXMb+5Kdv949axEtjsjLPjKCvx63B4E+cQi+PCkBnE66JBFsEGAEI +ACYCGwIWIQTrTBv9TwQvbd3M7JF3IfY704tHlgUCYXgMnwUJBaOagAIpwV0gBBkB +CAAGBQJheAyfAAoJEE6yfbKjuIuLggkP/1INRyRToLmY1ms9DTWMQ0lwbBL8J3xu +/neKIOKVGOdw9zcWlGugUoOthSbT8bjvuybH1Vjx4wFM+cnuMVfjD58Xu6ZpgCHN +1wXYMuzYweBFKaMg4oSwTKuAJBJ2IhfEm/cAryVvKY2zY+uyzgizx3vAg3sjkAPD +crSCJP2nkuHcJ3nzUbKNAjmdMsnWDrqqZVwP99nuyMk8bAtueZ0SKvIpCv2wIeYO +7zkj61vuQOFOGhl98OBui5wUhtgQw//esTWYiGNKSmD3derd2JHVA01tBmCWV4KM +LDbg3CcMMQ1x3V1me6EG3giwBL1I9xTsBUbEa6eEN9U0zdKvoMbSogON5wCuxAzO +/CXGMreJtBUupHEc69oTuwe426Ihi3AbRrPAg3tnGGFCt11HoQFNnRPWb3unF8Ul +A2rSytvwFyQi3pzBYt5VsTIA7NEHGuJs+/Oor6AOInzht1cp7AfmDGfGy2N5ow+4 +GI6FPe2UqIg2+nFiGr9hRZOvXRgLQL8dlDnFChymldxm/J/UFdJGSWRldEDsPrzH +QESKvsV9EjnJQR5p5zkQK6jx0zqSlDgiNG2GT3/CSvwIdCih6Cl9HThHtYNm3ZYN +0bU9W2jeoLh3AINNTcrp0tAHZuQLFxukbj56O5eB+nfk67/X2iNii46ZdJQNwbT9 +YN6CstQz+CnqCRB3IfY704tHlsa8D/9M5VgmDrDR+SHeEmbDynvIpnrwm495b26E +2D3OLuh7228G2Ki3q8z9mo1kgnVACuAjKwLrxYpXaOJOgjoelWmXYgzsLCqCX7Ol +XeaLneWvo0Z7/PqJLUQX+TgFXN0S3wRtUQvaiPPdSUzoxq01O3QSa0Y0VncvWEHf +3qTdiNEVbVGiZcShC6BY+exTxEWYIPsqJooXgQESvny2GP6BU8CSt/ird63ZwbVH +laRIi+lY1Om6ryKVBvj9LtuwLKXGnIA3sIOffrYXG2OLZ7HaOg0mQUPdmwT1Rs6V +zxIaUP72TOtwhvKrGX0NY8PNqL5kp5Cjy5wUEWmxWFZdAwpdbmB4NuFKeusOi4/7 +U9l2wngX9p+eCvR6FDFfX+/6S6E2tHRN1GCNSuBi2XafssTL3lBIxp4dGDkwZqAb +aXculHXo7o4pesWx9oC8GyAhZvm6ClVsM62Asn5edQEoquXZqkMHd7TwIPR3Oqrb +2fHLMMlsjTkKWaNJsr3z2iqx1mvbthqkJnhgcIXJFycRTP82rtMsejTJEhSOPZE4 +JcNAO+63JpSVAEEHqF5kyjJejTP9wFH7y/EH7vf++JIKBSPaZkBZMgbXEDAngdvy +UPSvcsD4Yv4CHm437XzICkLE1vv+jZdcmfKt/Mtp9SeKf2nFZRXNij4W5ii+Ar3E +nZUEuAm7xYkEWwQYAQgADwUCYXgMnwIbAgUJBaOagAJACRB3IfY704tHlsFdIAQZ +AQgABgUCYXgMnwAKCRBOsn2yo7iLi4IJD/9SDUckU6C5mNZrPQ01jENJcGwS/Cd8 +bv53iiDilRjncPc3FpRroFKDrYUm0/G477smx9VY8eMBTPnJ7jFX4w+fF7umaYAh +zdcF2DLs2MHgRSmjIOKEsEyrgCQSdiIXxJv3AK8lbymNs2Prss4Is8d7wIN7I5AD +w3K0giT9p5Lh3Cd581GyjQI5nTLJ1g66qmVcD/fZ7sjJPGwLbnmdEiryKQr9sCHm +Du85I+tb7kDhThoZffDgboucFIbYEMP/3rE1mIhjSkpg93Xq3diR1QNNbQZglleC +jCw24NwnDDENcd1dZnuhBt4IsAS9SPcU7AVGxGunhDfVNM3Sr6DG0qIDjecArsQM +zvwlxjK3ibQVLqRxHOvaE7sHuNuiIYtwG0azwIN7ZxhhQrddR6EBTZ0T1m97pxfF +JQNq0srb8BckIt6cwWLeVbEyAOzRBxribPvzqK+gDiJ84bdXKewH5gxnxstjeaMP +uBiOhT3tlKiINvpxYhq/YUWTr10YC0C/HZQ5xQocppXcZvyf1BXSRklkZXRA7D68 +x0BEir7FfRI5yUEeaec5ECuo8dM6kpQ4IjRthk9/wkr8CHQooegpfR04R7WDZt2W +DdG1PVto3qC4dwCDTU3K6dLQB2bkCxcbpG4+ejuXgfp35Ou/19ojYouOmXSUDcG0 +/WDegrLUM/gp6hYhBOtMG/1PBC9t3czskXch9jvTi0eWg7YP/3G6Y6NHIQggXnlY +Ii3iwN72hEgEqz6vIRK87lBGW2r3eQ0cDZuE3+5Q4FYciw+B2RKeDhjdmPHypA5o ++RiAyI7JOZwJalqHO3nwJG5sr0rRzcJsbGvpbzso2JuTyTURv4tBNq45b9y0Qdzt +5PpNrPJbQADJWn+HWsbVJB5gWBTdoQYgpyTr84nQyscWAUFTRbmHvtjCCfLdvU8w +M7ubAQ5Dwi1pABRttRAMuPA94HzaBF5yXkghxHpnW0IcXGiwgch9LQyaO9VSRhiP +H6r5Zuk7KvGhHph7SC5JgUn9vJmmp1zcd0mXQ2Zh8M81J3Ri3iGPHM2CqplAxXNb +IrnztbEJhN2I+77m73Z4d+K1ivg6xQhteSZhwhx7/Z3Tl+U2jYOEFIn/UFmV3UxR +SJa/jQRcjvMKprSp4tAZ2yJI3babjRbixgUEtlK105/JepxcAdw9vosxO/rR7VqC +zu0copdxC0GAH8og+A9/3LPhlRGy3Qhfzjy9JHWHj4EIsol02BS8+dWvAoYerkve +9O9+h6/B5wM/Yng9BjT+OrNvkfmqK2cspBXwYedOrC4uWcUmueEVrv5P4FF36wJ+ +ejvPS6vdTxVTdLXjouUHwTQQZVlNjWY3cIyj03nZ19c+b30+2FzG/uSnb/ePWsRL +Y7Iyz4ygr8etweBPnEIvjwpAZxOu +=wwl/ -----END PGP PUBLIC KEY BLOCK----- pub 79752DB6C966F0B8 diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 87f940bf4..575126f00 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -12,6 +12,9 @@ + + + @@ -10244,10 +10247,10 @@ - + - + diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index 7b5954ba5..8ec425503 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 9 errors and 104 warnings + Lint Report: 9 errors and 103 warnings