Abstracting away media player functionality to MediaPlayerManager

- Most code removed from ChatActivity
- Most work in MediaPlayerManager
- Added BackgroundVoiceMessageCard

Signed-off-by: rapterjet2004 <juliuslinus1@gmail.com>
This commit is contained in:
rapterjet2004 2024-12-24 11:58:27 -06:00
parent 459913d5d2
commit d26697b932
No known key found for this signature in database
GPG Key ID: A6A69CFF84968EA1
18 changed files with 1126 additions and 820 deletions

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<String> = 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<String, TypingParticipant>()
var callStarted = false
private var voiceMessageToRestoreId = ""
private var voiceMessageToRestoreAudioPosition = 0
private var voiceMessageToRestoreWasPlaying = false
private val playbackSpeedPreferencesObserver: (Map<String, PlaybackSpeed>) -> 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<ChatMessage, Int>? {
if (adapter != null) {
val messagePosition = adapter!!.items!!.indexOfFirst {

View File

@ -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()

View File

@ -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<ChatMessage?>
get() = _backgroundPlayUIFlow
private val _backgroundPlayUIFlow = MutableStateFlow<ChatMessage?>(null)
val managerState: Flow<MediaPlayerManagerState>
get() = _managerState
private val _managerState = MutableStateFlow(MediaPlayerManagerState.DEFAULT)
private val playQueue = mutableListOf<Pair<String, ChatMessage>>()
val mediaPlayerSeekBarPositionMsg: Flow<ChatMessage>
get() = _mediaPlayerSeekBarPositionMsg
private val _mediaPlayerSeekBarPositionMsg: MutableSharedFlow<ChatMessage> = MutableSharedFlow()
val mediaPlayerSeekBarPosition: Flow<Int>
get() = _mediaPlayerSeekBarPosition
private val _mediaPlayerSeekBarPosition: MutableSharedFlow<Int> = 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<Int> = MutableLiveData()
val mediaPlayerSeekBarPosition: LiveData<Int>
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!!)
}
}
}
}

View File

@ -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<Disposable>()
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<ChatMessage>
get() = mediaPlayerManager.mediaPlayerSeekBarPositionMsg
val managerStateFlow: Flow<MediaPlayerManager.MediaPlayerManagerState>
get() = mediaPlayerManager.managerState
val voiceMessagePlayBackUIFlow: Flow<PlaybackSpeed>
get() = _voiceMessagePlayBackUIFlow
private val _voiceMessagePlayBackUIFlow: MutableSharedFlow<PlaybackSpeed> = MutableSharedFlow()
val getAudioFocusChange: LiveData<AudioFocusRequestManager.ManagerState>
get() = audioFocusRequestManager.getManagerState
@ -122,10 +146,6 @@ class ChatViewModel @Inject constructor(
val outOfOfficeViewState: LiveData<OutOfOfficeUIState>
get() = _outOfOfficeViewState
private val _voiceMessagePlaybackSpeedPreferences: MutableLiveData<Map<String, PlaybackSpeed>> = MutableLiveData()
val voiceMessagePlaybackSpeedPreferences: LiveData<Map<String, PlaybackSpeed>>
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<String, PlaybackSpeed>) {
_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<ConversationModel> {
override fun onSubscribe(d: Disposable) {

View File

@ -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<Pair<Float, Float>>
get() = audioRecorderManager.getAudioValues
val mediaPlayerSeekbarObserver: LiveData<Int>
val mediaPlayerSeekbarObserver: Flow<Int>
get() = mediaPlayerManager.mediaPlayerSeekBarPosition
private val _getEditChatMessage: MutableLiveData<IMessage?> = MutableLiveData()
@ -231,7 +232,7 @@ class MessageInputViewModel @Inject constructor(
fun pauseMediaPlayer() {
audioFocusRequestManager.audioFocusRequest(false) {
mediaPlayerManager.pause()
mediaPlayerManager.pause(false)
_isVoicePreviewPlaying.postValue(false)
}
}

View File

@ -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<ConversationModel>) {
@ -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) {

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,195 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
* 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
)
}
}
}
}
}
}
}

View File

@ -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<String, PlaybackSpeed> speeds);
void deleteAllMessageQueuesFor(String userId);
Map<String, PlaybackSpeed> readVoiceMessagePlaybackSpeedPreferences();
void savePreferredPlayback(String userId, PlaybackSpeed speed);
PlaybackSpeed getPreferredPlayback(String userId);
Long getNotificationWarningLastPostponedDate();

View File

@ -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<String, PlaybackSpeed>) {
Json.encodeToString(speeds).let {
runBlocking<Unit> { async { writeString(VOICE_MESSAGE_PLAYBACK_SPEEDS, it) } }
}
}
override fun deleteAllMessageQueuesFor(userId: String) {
runBlocking {
async {
val keyList = mutableListOf<Preferences.Key<*>>()
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<String, PlaybackSpeed> {
return runBlocking {
async { readString(VOICE_MESSAGE_PLAYBACK_SPEEDS, "{}").first() }
}.getCompleted().let {
try {
Json.decodeFromString<HashMap<String, String>>(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<Unit> {
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"

View File

@ -143,6 +143,11 @@
app:popupTheme="@style/appActionBarPopupMenu"
app:titleTextColor="@color/fontAppbar"
tools:title="@string/nc_app_product_name" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeViewForBackgroundPlay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout

View File

@ -272,8 +272,6 @@ How to translate with transifex:
<string name="nc_contacts_done">Done</string>
<string name="user_avatar">User avatar</string>
<string name="back_button">Back button</string>
<string name="new_conversation_creation_icon">New Conversation Creation Icon</string>
<string name="join_open_conversations_icon">Join Open Conversations Icon</string>
<!-- Permissions -->
<string name="nc_permissions_rationale_dialog_title">Please allow permissions</string>
<string name="nc_permissions_denied">Some permissions were denied.</string>

View File

@ -1339,6 +1339,8 @@ vCeonVI7Q1CkIHt8u7eMgzfEkaiPLZlI0l0RpfT4pnNieqg=
-----END PGP PUBLIC KEY BLOCK-----
pub BAC30622339994C4
uid Chris Povirk <cpovirk@google.com>
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 <eric@swiftzer.net>
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) <linux-packages-keymaster@google.com>
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
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
Y7Iyz4ygr8etweBPnEIvjwpAZxOu
=wwl/
-----END PGP PUBLIC KEY BLOCK-----
pub 79752DB6C966F0B8

View File

@ -12,6 +12,9 @@
<trust group="org.javassist" name="javassist" version="3.26.0-GA" reason="java assist"/>
<trust file=".*-sources[.]jar" regex="true"/>
</trusted-artifacts>
<ignored-keys>
<ignored-key id="C020E96222A31FB3" reason="Key couldn't be downloaded from any key server"/>
</ignored-keys>
<trusted-keys>
<trusted-key id="02A36B6DB7056EB5E6FFEF893DA731F041734930" group="org.parceler"/>
<trusted-key id="03D5EBC6C81161316CF21CEE1592D9DA6586CF26" group="^com[.]afollestad($|([.].*))" regex="true"/>
@ -10244,10 +10247,10 @@
</component>
<component group="net.swiftzer.semver" name="semver" version="1.1.1">
<artifact name="semver-1.1.1.jar">
<sha256 value="f028546d70326b93314a4f448cfd217b50b801ec20313cd46028a94604f46d66" origin="Generated by Gradle"/>
<sha256 value="f028546d70326b93314a4f448cfd217b50b801ec20313cd46028a94604f46d66" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
<artifact name="semver-1.1.1.pom">
<sha256 value="320c1150c2c5dc40937423f9e888e10d9e8b2d4e1346c349864816ba2afe39ff" origin="Generated by Gradle"/>
<sha256 value="320c1150c2c5dc40937423f9e888e10d9e8b2d4e1346c349864816ba2afe39ff" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
</artifact>
</component>
<component group="net.zetetic" name="android-database-sqlcipher" version="4.5.4">

View File

@ -1,2 +1,2 @@
DO NOT TOUCH; GENERATED BY DRONE
<span class="mdl-layout-title">Lint Report: 9 errors and 104 warnings</span>
<span class="mdl-layout-title">Lint Report: 9 errors and 103 warnings</span>