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 com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
@ -61,9 +63,8 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
@Inject @Inject
lateinit var dateUtils: DateUtils lateinit var dateUtils: DateUtils
@JvmField
@Inject @Inject
var appPreferences: AppPreferences? = null lateinit var appPreferences: AppPreferences
lateinit var message: ChatMessage lateinit var message: ChatMessage
@ -83,7 +84,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
sharedApplication!!.componentApplication.inject(this) sharedApplication!!.componentApplication.inject(this)
val filename = message.selectedIndividualHashMap!!["name"] val filename = message.selectedIndividualHashMap!!["name"]
val retrieved = appPreferences!!.getWaveFormFromFile(filename) val retrieved = appPreferences.getWaveFormFromFile(filename)
if (retrieved.isNotEmpty() && if (retrieved.isNotEmpty() &&
message.voiceMessageFloatArray == null || message.voiceMessageFloatArray == null ||
message.voiceMessageFloatArray?.isEmpty() == true message.voiceMessageFloatArray?.isEmpty() == true
@ -103,7 +104,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
setParentMessageDataOnMessageItem(message) setParentMessageDataOnMessageItem(message)
updateDownloadState(message) updateDownloadState(message)
binding.seekbar.max = message.voiceMessageDuration * ONE_SEC binding.seekbar.max = MAX
viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar) viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT) viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
@ -139,9 +140,15 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
} }
}) })
voiceMessageInterface.registerMessageToObservePlaybackSpeedPreferences(message.user.id) { speed -> CoroutineScope(Dispatchers.Default).launch {
(voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed ->
withContext(Dispatchers.Main) {
binding.playbackSpeedControlBtn.setSpeed(speed) binding.playbackSpeedControlBtn.setSpeed(speed)
} }
}.collect()
}
binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId))
Reaction().showReactions( Reaction().showReactions(
message, message,
@ -158,9 +165,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
private fun showVoiceMessageDuration(message: ChatMessage) { private fun showVoiceMessageDuration(message: ChatMessage) {
if (message.voiceMessageDuration > 0) { if (message.voiceMessageDuration > 0) {
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(
message.voiceMessageDuration.toLong()
)
binding.voiceMessageDuration.visibility = View.VISIBLE binding.voiceMessageDuration.visibility = View.VISIBLE
} else { } else {
binding.voiceMessageDuration.visibility = View.INVISIBLE binding.voiceMessageDuration.visibility = View.INVISIBLE
@ -200,7 +204,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
val t = message.voiceMessagePlayedSeconds.toLong() val t = message.voiceMessagePlayedSeconds.toLong()
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t) binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
binding.voiceMessageDuration.visibility = View.VISIBLE binding.voiceMessageDuration.visibility = View.VISIBLE
binding.seekbar.max = message.voiceMessageDuration * ONE_SEC
binding.seekbar.progress = message.voiceMessageSeekbarProgress binding.seekbar.progress = message.voiceMessageSeekbarProgress
} else { } else {
showVoiceMessageDuration(message) showVoiceMessageDuration(message)
@ -372,6 +375,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
companion object { companion object {
private const val TAG = "VoiceInMessageView" private const val TAG = "VoiceInMessageView"
private const val SEEKBAR_START: Int = 0 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 com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
@ -65,9 +67,8 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
@Inject @Inject
lateinit var dateUtils: DateUtils lateinit var dateUtils: DateUtils
@JvmField
@Inject @Inject
var appPreferences: AppPreferences? = null lateinit var appPreferences: AppPreferences
lateinit var message: ChatMessage lateinit var message: ChatMessage
@ -90,7 +91,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT) viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
val filename = message.selectedIndividualHashMap!!["name"] val filename = message.selectedIndividualHashMap!!["name"]
val retrieved = appPreferences!!.getWaveFormFromFile(filename) val retrieved = appPreferences.getWaveFormFromFile(filename)
if (retrieved.isNotEmpty() && if (retrieved.isNotEmpty() &&
message.voiceMessageFloatArray == null || message.voiceMessageFloatArray == null ||
message.voiceMessageFloatArray?.isEmpty() == true message.voiceMessageFloatArray?.isEmpty() == true
@ -99,6 +100,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!) binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
} }
binding.seekbar.max = MAX
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp) binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
colorizeMessageBubble(message) colorizeMessageBubble(message)
@ -136,9 +138,15 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
setReadStatus(message.readStatus) setReadStatus(message.readStatus)
voiceMessageInterface.registerMessageToObservePlaybackSpeedPreferences(message.user.id) { speed -> CoroutineScope(Dispatchers.Default).launch {
(voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed ->
withContext(Dispatchers.Main) {
binding.playbackSpeedControlBtn.setSpeed(speed) binding.playbackSpeedControlBtn.setSpeed(speed)
} }
}.collect()
}
binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId))
Reaction().showReactions( Reaction().showReactions(
message, message,
@ -199,9 +207,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
private fun showVoiceMessageDuration(message: ChatMessage) { private fun showVoiceMessageDuration(message: ChatMessage) {
if (message.voiceMessageDuration > 0) { if (message.voiceMessageDuration > 0) {
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(
message.voiceMessageDuration.toLong()
)
binding.voiceMessageDuration.visibility = View.VISIBLE binding.voiceMessageDuration.visibility = View.VISIBLE
} else { } else {
binding.voiceMessageDuration.visibility = View.INVISIBLE binding.voiceMessageDuration.visibility = View.INVISIBLE
@ -234,7 +239,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
val t = message.voiceMessagePlayedSeconds.toLong() val t = message.voiceMessagePlayedSeconds.toLong()
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t) binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
binding.voiceMessageDuration.visibility = View.VISIBLE binding.voiceMessageDuration.visibility = View.VISIBLE
binding.seekbar.max = message.voiceMessageDuration * ONE_SEC
binding.seekbar.progress = message.voiceMessageSeekbarProgress binding.seekbar.progress = message.voiceMessageSeekbarProgress
} else { } else {
showVoiceMessageDuration(message) showVoiceMessageDuration(message)
@ -377,6 +381,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
companion object { companion object {
private const val TAG = "VoiceOutMessageView" private const val TAG = "VoiceOutMessageView"
private const val SEEKBAR_START: Int = 0 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.BitmapDrawable
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.media.MediaMetadataRetriever
import android.media.MediaPlayer
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@ -332,24 +330,12 @@ class ChatActivity :
private val filesToUpload: MutableList<String> = ArrayList() private val filesToUpload: MutableList<String> = ArrayList()
lateinit var sharedText: String 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 lateinit var participantPermissions: ParticipantPermissions
private var videoURI: Uri? = null private var videoURI: Uri? = null
private val onBackPressedCallback = object : OnBackPressedCallback(true) { private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
if (currentlyPlayedVoiceMessage != null) {
stopMediaPlayer(currentlyPlayedVoiceMessage!!)
}
val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java) val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java)
intent.putExtras(Bundle()) intent.putExtras(Bundle())
startActivity(intent) startActivity(intent)
@ -361,22 +347,6 @@ class ChatActivity :
val typingParticipants = HashMap<String, TypingParticipant>() val typingParticipants = HashMap<String, TypingParticipant>()
var callStarted = false 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 { private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener {
override fun onSwitchTo(token: String?) { override fun onSwitchTo(token: String?) {
@ -458,35 +428,7 @@ class ChatActivity :
onBackPressedDispatcher.addCallback(this, onBackPressedCallback) onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
appPreferences.readVoiceMessagePlaybackSpeedPreferences().let { playbackSpeedPreferences ->
chatViewModel.applyPlaybackSpeedPreferences(playbackSpeedPreferences)
}
initObservers() 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 { private fun getMessageInputFragment(): MessageInputFragment {
@ -551,17 +493,6 @@ class ChatActivity :
} }
override fun onSaveInstanceState(outState: Bundle) { 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() chatViewModel.handleOrientationChange()
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
@ -933,6 +864,12 @@ class ChatActivity :
}.collect() }.collect()
} }
this.lifecycleScope.launch {
chatViewModel.mediaPlayerSeekbarObserver.onEach { msg ->
adapter?.update(msg)
}.collect()
}
chatViewModel.reactionDeletedViewState.observe(this) { state -> chatViewModel.reactionDeletedViewState.observe(this) { state ->
when (state) { when (state) {
is ChatViewModel.ReactionDeletedSuccessState -> { is ChatViewModel.ReactionDeletedSuccessState -> {
@ -1171,8 +1108,6 @@ class ChatActivity :
setupSwipeToReply() setupSwipeToReply()
chatViewModel.voiceMessagePlaybackSpeedPreferences.observe(this, playbackSpeedPreferencesObserver)
binding.unreadMessagesPopup.setOnClickListener { binding.unreadMessagesPopup.setOnClickListener {
binding.messagesListView.smoothScrollToPosition(0) binding.messagesListView.smoothScrollToPosition(0)
binding.unreadMessagesPopup.visibility = View.GONE binding.unreadMessagesPopup.visibility = View.GONE
@ -1267,13 +1202,15 @@ class ChatActivity :
val file = File(context.cacheDir, filename!!) val file = File(context.cacheDir, filename!!)
if (file.exists()) { if (file.exists()) {
if (message.isPlayingVoiceMessage) { if (message.isPlayingVoiceMessage) {
pausePlayback(message) chatViewModel.pauseMediaPlayer(true)
message.isPlayingVoiceMessage = false
adapter?.update(message)
} else { } else {
val retrieved = appPreferences.getWaveFormFromFile(filename) val retrieved = appPreferences.getWaveFormFromFile(filename)
if (retrieved.isEmpty()) { if (retrieved.isEmpty()) {
setUpWaveform(message) setUpWaveform(message)
} else { } else {
startPlayback(message) startPlayback(file, message)
} }
} }
} else { } else {
@ -1286,11 +1223,8 @@ class ChatActivity :
adapter?.registerViewClickListener(R.id.playbackSpeedControlBtn) { button, message -> adapter?.registerViewClickListener(R.id.playbackSpeedControlBtn) { button, message ->
val nextSpeed = (button as PlaybackSpeedControl).getSpeed().next() val nextSpeed = (button as PlaybackSpeedControl).getSpeed().next()
HashMap(appPreferences.readVoiceMessagePlaybackSpeedPreferences()).let { playbackSpeedPreferences -> chatViewModel.setPlayBack(nextSpeed)
playbackSpeedPreferences[message.user.id] = nextSpeed appPreferences.savePreferredPlayback(conversationUser!!.userId, nextSpeed)
chatViewModel.applyPlaybackSpeedPreferences(playbackSpeedPreferences)
appPreferences.saveVoiceMessagePlaybackSpeedPreferences(playbackSpeedPreferences)
}
} }
} }
@ -1305,14 +1239,37 @@ class ChatActivity :
appPreferences.saveWaveFormForFile(filename, r.toTypedArray()) appPreferences.saveWaveFormForFile(filename, r.toTypedArray())
message.voiceMessageFloatArray = r message.voiceMessageFloatArray = r
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
startPlayback(message, thenPlay, backgroundPlayAllowed) startPlayback(file, message)
} }
} }
} else { } 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 { private fun initMessageHolders(): MessageHolders {
val messageHolders = MessageHolders() val messageHolders = MessageHolders()
val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!, viewThemeUtils) val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!, viewThemeUtils)
@ -1692,253 +1649,20 @@ class ChatActivity :
} }
} }
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod", "Detekt.NestedBlockDepth") override fun updateMediaPlayerProgressBySlider(message: ChatMessage, progress: Int) {
private fun startPlayback(message: ChatMessage, doPlay: Boolean = true, backgroundPlayAllowed: Boolean = false) { chatViewModel.seekToMediaPlayer(progress)
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 registerMessageToObservePlaybackSpeedPreferences( override fun registerMessageToObservePlaybackSpeedPreferences(
userId: String, userId: String,
listener: (speed: PlaybackSpeed) -> Unit listener: (speed: PlaybackSpeed) -> Unit
) { ) {
chatViewModel.voiceMessagePlaybackSpeedPreferences.let { liveData -> CoroutineScope(Dispatchers.Default).launch {
liveData.observe(this) { playbackSpeedPreferences -> chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed ->
listener(playbackSpeedPreferences[userId] ?: PlaybackSpeed.NORMAL) withContext(Dispatchers.Main) {
} listener(speed)
liveData.value?.let { playbackSpeedPreferences ->
listener(playbackSpeedPreferences[userId] ?: PlaybackSpeed.NORMAL)
} }
}.collect()
} }
} }
@ -2610,8 +2334,6 @@ class ChatActivity :
if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) {
mentionAutocomplete?.dismissPopup() mentionAutocomplete?.dismissPopup()
} }
chatViewModel.voiceMessagePlaybackSpeedPreferences.removeObserver(playbackSpeedPreferencesObserver)
} }
private fun isActivityNotChangingConfigurations(): Boolean = !isChangingConfigurations private fun isActivityNotChangingConfigurations(): Boolean = !isChangingConfigurations
@ -2677,8 +2399,7 @@ class ChatActivity :
actionBar?.setIcon(null) actionBar?.setIcon(null)
} }
currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) } // FIXME, mediaplayer can sometimes be null here adapter = null
disposables.dispose() disposables.dispose()
} }
@ -2972,8 +2693,6 @@ class ChatActivity :
adapter?.addToEnd(chatMessageList, false) adapter?.addToEnd(chatMessageList, false)
} }
scrollToRequestedMessageIfNeeded() scrollToRequestedMessageIfNeeded()
// FENOM: add here audio resume policy
resumeAudioPlaybackIfNeeded()
} }
private fun scrollToFirstUnreadMessage() { 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>? { private fun getItemFromAdapter(messageId: String): Pair<ChatMessage, Int>? {
if (adapter != null) { if (adapter != null) {
val messagePosition = adapter!!.items!!.indexOfFirst { val messagePosition = adapter!!.items!!.indexOfFirst {

View File

@ -16,6 +16,7 @@ import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener import android.widget.SeekBar.OnSeekBarChangeListener
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import autodagger.AutoInjector import autodagger.AutoInjector
import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.talk.R 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.chat.data.io.AudioFocusRequestManager
import com.nextcloud.talk.databinding.FragmentMessageInputVoiceRecordingBinding import com.nextcloud.talk.databinding.FragmentMessageInputVoiceRecordingBinding
import com.nextcloud.talk.ui.theme.ViewThemeUtils 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 import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class) @AutoInjector(NextcloudTalkApplication::class)
@ -60,6 +64,7 @@ class MessageInputVoiceRecordingFragment : Fragment() {
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
chatActivity.messageInputViewModel.stopMediaPlayer() // if it wasn't stopped already
this.lifecycle.removeObserver(chatActivity.messageInputViewModel) this.lifecycle.removeObserver(chatActivity.messageInputViewModel)
} }
@ -68,13 +73,16 @@ class MessageInputVoiceRecordingFragment : Fragment() {
chatActivity.messageInputViewModel.micInputAudioObserver.observe(viewLifecycleOwner) { chatActivity.messageInputViewModel.micInputAudioObserver.observe(viewLifecycleOwner) {
binding.micInputCloud.setRotationSpeed(it.first, it.second) binding.micInputCloud.setRotationSpeed(it.first, it.second)
} }
chatActivity.messageInputViewModel.mediaPlayerSeekbarObserver.observe(viewLifecycleOwner) { progress ->
lifecycleScope.launch {
chatActivity.messageInputViewModel.mediaPlayerSeekbarObserver.onEach { progress ->
if (progress >= SEEK_LIMIT) { if (progress >= SEEK_LIMIT) {
togglePausePlay() togglePausePlay()
binding.seekbar.progress = 0 binding.seekbar.progress = 0
} else if (!pause) { } else if (!pause && chatActivity.messageInputViewModel.isVoicePreviewPlaying.value == true) {
binding.seekbar.progress = progress binding.seekbar.progress = progress
} }
}.collect()
} }
chatActivity.messageInputViewModel.getAudioFocusChange.observe(viewLifecycleOwner) { state -> chatActivity.messageInputViewModel.getAudioFocusChange.observe(viewLifecycleOwner) { state ->
@ -107,7 +115,7 @@ class MessageInputVoiceRecordingFragment : Fragment() {
binding.sendVoiceRecording.setOnClickListener { binding.sendVoiceRecording.setOnClickListener {
chatActivity.chatViewModel.stopAndSendAudioRecording( chatActivity.chatViewModel.stopAndSendAudioRecording(
chatActivity.roomToken, chatActivity.roomToken,
chatActivity.currentConversation!!.displayName!!, chatActivity.currentConversation!!.displayName,
MessageInputFragment.VOICE_MESSAGE_META_DATA MessageInputFragment.VOICE_MESSAGE_META_DATA
) )
clear() clear()

View File

@ -9,44 +9,120 @@ package com.nextcloud.talk.chat.data.io
import android.media.MediaPlayer import android.media.MediaPlayer
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.nextcloud.talk.chat.ChatActivity 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.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay 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.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext 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 * Abstraction over the [MediaPlayer](https://developer.android.com/reference/android/media/MediaPlayer) class used
* to manage the MediaPlayer instance. * to manage the MediaPlayer instance.
*/ */
@Suppress("TooManyFunctions", "TooGenericExceptionCaught")
class MediaPlayerManager : LifecycleAwareManager { class MediaPlayerManager : LifecycleAwareManager {
companion object { companion object {
val TAG: String = MediaPlayerManager::class.java.simpleName val TAG: String = MediaPlayerManager::class.java.simpleName
private const val SEEKBAR_UPDATE_DELAY = 15L private const val SEEKBAR_UPDATE_DELAY = 150L
const val DIVIDER = 100f 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 mediaPlayer: MediaPlayer? = null
private var mediaPlayerPosition: Int = 0
private var loop = false private var loop = false
private var scope = MainScope() private var scope = MainScope()
private var currentCycledMessage: ChatMessage? = null
private var currentDataSource: String = ""
var mediaPlayerDuration: Int = 0 var mediaPlayerDuration: Int = 0
private val _mediaPlayerSeekBarPosition: MutableLiveData<Int> = MutableLiveData() var mediaPlayerPosition: Int = 0
val mediaPlayerSeekBarPosition: LiveData<Int>
get() = _mediaPlayerSeekBarPosition
/** /**
* Starts playing audio from the given path, initializes or resumes if the player is already created. * Starts playing audio from the given path, initializes or resumes if the player is already created.
*/ */
fun start(path: String) { fun start(path: String) {
if (mediaPlayer != null && mediaPlayer!!.isPlaying) {
stop()
}
if (mediaPlayer == null || !scope.isActive) { if (mediaPlayer == null || !scope.isActive) {
init(path) init(path)
} else { } 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() mediaPlayer!!.start()
loop = true loop = true
scope.launch { seekbarUpdateObserver() } scope.launch { seekbarUpdateObserver() }
@ -60,19 +136,28 @@ class MediaPlayerManager : LifecycleAwareManager {
if (mediaPlayer != null) { if (mediaPlayer != null) {
Log.d(TAG, "media player destroyed") Log.d(TAG, "media player destroyed")
loop = false loop = false
scope.cancel()
mediaPlayer!!.stop() mediaPlayer!!.stop()
mediaPlayer!!.release() mediaPlayer!!.release()
mediaPlayer = null mediaPlayer = null
currentCycledMessage = null
_backgroundPlayUIFlow.tryEmit(null)
_managerState.value = MediaPlayerManagerState.STOPPED
} }
} }
/** /**
* Pauses the player. * Pauses the player.
*/ */
fun pause() { fun pause(notifyUI: Boolean) {
if (mediaPlayer != null) { if (mediaPlayer != null) {
Log.d(TAG, "media player paused") Log.d(TAG, "media player paused")
_managerState.value = MediaPlayerManagerState.PAUSED
mediaPlayer!!.pause() mediaPlayer!!.pause()
loop = false
if (notifyUI) {
_backgroundPlayUIFlow.tryEmit(null)
}
} }
} }
@ -89,14 +174,29 @@ class MediaPlayerManager : LifecycleAwareManager {
private suspend fun seekbarUpdateObserver() { private suspend fun seekbarUpdateObserver() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
currentCycledMessage?.voiceMessageDuration = mediaPlayerDuration / ONE_SEC
currentCycledMessage?.resetVoiceMessage = false
while (true) { while (true) {
if (!loop) { 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 val pos = mediaPlayer!!.currentPosition
mediaPlayerPosition = pos
val progress = (pos.toFloat() / mediaPlayerDuration) * DIVIDER 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) 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) { private fun init(path: String) {
try { try {
mediaPlayer = MediaPlayer().apply { mediaPlayer = MediaPlayer().apply {
_managerState.value = MediaPlayerManagerState.SETUP
setDataSource(path) setDataSource(path)
currentDataSource = path
prepareAsync() prepareAsync()
setOnPreparedListener { setOnPreparedListener {
mediaPlayerDuration = it.duration onPrepare()
start()
loop = true
scope = MainScope()
scope.launch { seekbarUpdateObserver() }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(ChatActivity.TAG, "failed to initialize mediaPlayer", e) 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() { override fun handleOnPause() {
// unused atm // unused atm
} }
override fun handleOnResume() { override fun handleOnResume() {
// unused atm if (mediaPlayer != null && mediaPlayer!!.isPlaying) {
loop = true
}
} }
override fun handleOnStop() { override fun handleOnStop() {
stop() loop = false
scope.cancel() 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 androidx.lifecycle.viewModelScope
import com.nextcloud.talk.chat.data.ChatMessageRepository import com.nextcloud.talk.chat.data.ChatMessageRepository
import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager 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.io.MediaRecorderManager
import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource 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.ConversationUtils
import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
import com.nextcloud.talk.utils.preferences.AppPreferences
import io.reactivex.Observer import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -57,6 +62,7 @@ import javax.inject.Inject
@Suppress("TooManyFunctions", "LongParameterList") @Suppress("TooManyFunctions", "LongParameterList")
class ChatViewModel @Inject constructor( class ChatViewModel @Inject constructor(
// should be removed here. Use it via RetrofitChatNetwork // should be removed here. Use it via RetrofitChatNetwork
private val appPreferences: AppPreferences,
private val chatNetworkDataSource: ChatNetworkDataSource, private val chatNetworkDataSource: ChatNetworkDataSource,
private val chatRepository: ChatMessageRepository, private val chatRepository: ChatMessageRepository,
private val conversationRepository: OfflineConversationsRepository, private val conversationRepository: OfflineConversationsRepository,
@ -73,8 +79,11 @@ class ChatViewModel @Inject constructor(
STOPPED STOPPED
} }
private val mediaPlayerManager: MediaPlayerManager = MediaPlayerManager.sharedInstance(appPreferences)
lateinit var currentLifeCycleFlag: LifeCycleFlag lateinit var currentLifeCycleFlag: LifeCycleFlag
val disposableSet = mutableSetOf<Disposable>() val disposableSet = mutableSetOf<Disposable>()
var mediaPlayerDuration = mediaPlayerManager.mediaPlayerDuration
val mediaPlayerPosition = mediaPlayerManager.mediaPlayerPosition
fun getChatRepository(): ChatMessageRepository { fun getChatRepository(): ChatMessageRepository {
return chatRepository return chatRepository
@ -85,6 +94,7 @@ class ChatViewModel @Inject constructor(
currentLifeCycleFlag = LifeCycleFlag.RESUMED currentLifeCycleFlag = LifeCycleFlag.RESUMED
mediaRecorderManager.handleOnResume() mediaRecorderManager.handleOnResume()
chatRepository.handleOnResume() chatRepository.handleOnResume()
mediaPlayerManager.handleOnResume()
} }
override fun onPause(owner: LifecycleOwner) { override fun onPause(owner: LifecycleOwner) {
@ -94,6 +104,7 @@ class ChatViewModel @Inject constructor(
disposableSet.clear() disposableSet.clear()
mediaRecorderManager.handleOnPause() mediaRecorderManager.handleOnPause()
chatRepository.handleOnPause() chatRepository.handleOnPause()
mediaPlayerManager.handleOnPause()
} }
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
@ -101,8 +112,21 @@ class ChatViewModel @Inject constructor(
currentLifeCycleFlag = LifeCycleFlag.STOPPED currentLifeCycleFlag = LifeCycleFlag.STOPPED
mediaRecorderManager.handleOnStop() mediaRecorderManager.handleOnStop()
chatRepository.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> val getAudioFocusChange: LiveData<AudioFocusRequestManager.ManagerState>
get() = audioFocusRequestManager.getManagerState get() = audioFocusRequestManager.getManagerState
@ -122,10 +146,6 @@ class ChatViewModel @Inject constructor(
val outOfOfficeViewState: LiveData<OutOfOfficeUIState> val outOfOfficeViewState: LiveData<OutOfOfficeUIState>
get() = _outOfOfficeViewState get() = _outOfOfficeViewState
private val _voiceMessagePlaybackSpeedPreferences: MutableLiveData<Map<String, PlaybackSpeed>> = MutableLiveData()
val voiceMessagePlaybackSpeedPreferences: LiveData<Map<String, PlaybackSpeed>>
get() = _voiceMessagePlaybackSpeedPreferences
val getMessageFlow = chatRepository.messageFlow val getMessageFlow = chatRepository.messageFlow
.onEach { .onEach {
_chatMessageViewState.value = if (_chatMessageViewState.value == ChatMessageInitialState) { _chatMessageViewState.value = if (_chatMessageViewState.value == ChatMessageInitialState) {
@ -665,12 +685,34 @@ class ChatViewModel @Inject constructor(
emit(message.first()) emit(message.first())
} }
fun applyPlaybackSpeedPreferences(speeds: Map<String, PlaybackSpeed>) { fun setPlayBack(speed: PlaybackSpeed) {
_voiceMessagePlaybackSpeedPreferences.postValue(speeds) mediaPlayerManager.setPlayBackSpeed(speed)
CoroutineScope(Dispatchers.Default).launch {
_voiceMessagePlayBackUIFlow.emit(speed)
}
} }
fun getPlaybackSpeedPreference(message: ChatMessage) = fun startMediaPlayer(path: String) {
_voiceMessagePlaybackSpeedPreferences.value?.get(message.user.id) ?: PlaybackSpeed.NORMAL 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> { inner class JoinRoomObserver : Observer<ConversationModel> {
override fun onSubscribe(d: Disposable) { 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.nextcloud.talk.utils.message.SendMessageUtils
import com.stfalcon.chatkit.commons.models.IMessage import com.stfalcon.chatkit.commons.models.IMessage
import io.reactivex.disposables.Disposable import io.reactivex.disposables.Disposable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -82,7 +83,7 @@ class MessageInputViewModel @Inject constructor(
val micInputAudioObserver: LiveData<Pair<Float, Float>> val micInputAudioObserver: LiveData<Pair<Float, Float>>
get() = audioRecorderManager.getAudioValues get() = audioRecorderManager.getAudioValues
val mediaPlayerSeekbarObserver: LiveData<Int> val mediaPlayerSeekbarObserver: Flow<Int>
get() = mediaPlayerManager.mediaPlayerSeekBarPosition get() = mediaPlayerManager.mediaPlayerSeekBarPosition
private val _getEditChatMessage: MutableLiveData<IMessage?> = MutableLiveData() private val _getEditChatMessage: MutableLiveData<IMessage?> = MutableLiveData()
@ -231,7 +232,7 @@ class MessageInputViewModel @Inject constructor(
fun pauseMediaPlayer() { fun pauseMediaPlayer() {
audioFocusRequestManager.audioFocusRequest(false) { audioFocusRequestManager.audioFocusRequest(false) {
mediaPlayerManager.pause() mediaPlayerManager.pause(false)
_isVoicePreviewPlaying.postValue(false) _isVoicePreviewPlaying.postValue(false)
} }
} }

View File

@ -41,6 +41,7 @@ import androidx.activity.OnBackPressedCallback
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.view.MenuItemCompat import androidx.core.view.MenuItemCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment 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.application.NextcloudTalkApplication
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
import com.nextcloud.talk.contacts.ContactsActivityCompose import com.nextcloud.talk.contacts.ContactsActivityCompose
import com.nextcloud.talk.contacts.ContactsUiState import com.nextcloud.talk.contacts.ContactsUiState
import com.nextcloud.talk.contacts.ContactsViewModel 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.models.json.participants.Participant
import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository
import com.nextcloud.talk.settings.SettingsActivity 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.ChooseAccountDialogFragment
import com.nextcloud.talk.ui.dialog.ChooseAccountShareToDialogFragment import com.nextcloud.talk.ui.dialog.ChooseAccountShareToDialogFragment
import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog 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.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import retrofit2.HttpException import retrofit2.HttpException
import java.io.File
import java.util.Objects import java.util.Objects
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@ -185,6 +189,9 @@ class ConversationsListActivity :
@Inject @Inject
lateinit var networkMonitor: NetworkMonitor lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var chatViewModel: ChatViewModel
@Inject @Inject
lateinit var contactsViewModel: ContactsViewModel lateinit var contactsViewModel: ContactsViewModel
@ -283,7 +290,7 @@ class ConversationsListActivity :
if (adapter == null) { if (adapter == null) {
adapter = FlexibleAdapter(conversationItems, this, true) adapter = FlexibleAdapter(conversationItems, this, true)
} else { } else {
binding.loadingContent?.visibility = View.GONE binding.loadingContent.visibility = View.GONE
} }
adapter!!.addListener(this) adapter!!.addListener(this)
prepareViews() prepareViews()
@ -455,6 +462,55 @@ class ConversationsListActivity :
} }
}.collect() }.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>) { private fun setConversationList(list: List<ConversationModel>) {
@ -770,8 +826,8 @@ class ConversationsListActivity :
initSearchDisposable() initSearchDisposable()
adapter?.setHeadersShown(true) adapter?.setHeadersShown(true)
if (!hasFilterEnabled()) filterableConversationItems = searchableConversationItems if (!hasFilterEnabled()) filterableConversationItems = searchableConversationItems
adapter?.updateDataSet(filterableConversationItems, false) adapter!!.updateDataSet(filterableConversationItems, false)
adapter?.showAllHeaders() adapter!!.showAllHeaders()
binding.swipeRefreshLayoutView?.isEnabled = false binding.swipeRefreshLayoutView?.isEnabled = false
searchBehaviorSubject.onNext(true) searchBehaviorSubject.onNext(true)
return true return true
@ -786,9 +842,9 @@ class ConversationsListActivity :
// cancel any pending searches // cancel any pending searches
searchHelper!!.cancelSearch() searchHelper!!.cancelSearch()
} }
binding.swipeRefreshLayoutView?.isRefreshing = false binding.swipeRefreshLayoutView.isRefreshing = false
searchBehaviorSubject.onNext(false) searchBehaviorSubject.onNext(false)
binding.swipeRefreshLayoutView?.isEnabled = true binding.swipeRefreshLayoutView.isEnabled = true
searchView!!.onActionViewCollapsed() searchView!!.onActionViewCollapsed()
binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator(
@ -801,7 +857,7 @@ class ConversationsListActivity :
viewThemeUtils.platform.resetStatusBar(this@ConversationsListActivity) viewThemeUtils.platform.resetStatusBar(this@ConversationsListActivity)
} }
val layoutManager = binding.recyclerView?.layoutManager as SmoothScrollLinearLayoutManager? val layoutManager = binding.recyclerView.layoutManager as SmoothScrollLinearLayoutManager?
layoutManager?.scrollToPositionWithOffset(0, 0) layoutManager?.scrollToPositionWithOffset(0, 0)
return true return true
} }
@ -894,18 +950,18 @@ class ConversationsListActivity :
private fun initOverallLayout(isConversationListNotEmpty: Boolean) { private fun initOverallLayout(isConversationListNotEmpty: Boolean) {
if (isConversationListNotEmpty) { if (isConversationListNotEmpty) {
if (binding.emptyLayout?.visibility != View.GONE) { if (binding.emptyLayout.visibility != View.GONE) {
binding.emptyLayout?.visibility = View.GONE binding.emptyLayout.visibility = View.GONE
} }
if (binding.swipeRefreshLayoutView?.visibility != View.VISIBLE) { if (binding.swipeRefreshLayoutView.visibility != View.VISIBLE) {
binding.swipeRefreshLayoutView?.visibility = View.VISIBLE binding.swipeRefreshLayoutView.visibility = View.VISIBLE
} }
} else { } else {
if (binding.emptyLayout?.visibility != View.VISIBLE) { if (binding.emptyLayout.visibility != View.VISIBLE) {
binding.emptyLayout?.visibility = View.VISIBLE binding.emptyLayout.visibility = View.VISIBLE
} }
if (binding.swipeRefreshLayoutView?.visibility != View.GONE) { if (binding.swipeRefreshLayoutView.visibility != View.GONE) {
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) { if (!isDestroyed) {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(v.windowToken, 0) imm.hideSoftInputFromWindow(v.windowToken, 0)
} }
false false
} }
binding.swipeRefreshLayoutView?.setOnRefreshListener { binding.swipeRefreshLayoutView.setOnRefreshListener {
fetchRooms() fetchRooms()
fetchPendingInvitations() fetchPendingInvitations()
} }
binding.swipeRefreshLayoutView?.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } binding.swipeRefreshLayoutView.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) }
binding.emptyLayout?.setOnClickListener { showNewConversationsScreen() } binding.emptyLayout.setOnClickListener { showNewConversationsScreen() }
binding.floatingActionButton?.setOnClickListener { binding.floatingActionButton.setOnClickListener {
run(context) run(context)
showNewConversationsScreen() showNewConversationsScreen()
} }
binding.floatingActionButton?.let { viewThemeUtils.material.themeFAB(it) } binding.floatingActionButton.let { viewThemeUtils.material.themeFAB(it) }
binding.switchAccountButton.setOnClickListener { binding.switchAccountButton.setOnClickListener {
if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) { if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) {
@ -1284,7 +1340,7 @@ class ConversationsListActivity :
@SuppressLint("CheckResult") // handled by helper @SuppressLint("CheckResult") // handled by helper
private fun startMessageSearch(search: String?) { private fun startMessageSearch(search: String?) {
binding.swipeRefreshLayoutView?.isRefreshing = true binding.swipeRefreshLayoutView.isRefreshing = true
searchHelper?.startMessageSearch(search!!) searchHelper?.startMessageSearch(search!!)
?.subscribeOn(Schedulers.io()) ?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread()) ?.observeOn(AndroidSchedulers.mainThread())
@ -1539,8 +1595,8 @@ class ConversationsListActivity :
filesToShare?.forEach { filesToShare?.forEach {
UploadAndShareFilesWorker.upload( UploadAndShareFilesWorker.upload(
it, it,
selectedConversation!!.token!!, selectedConversation!!.token,
selectedConversation!!.displayName!!, selectedConversation!!.displayName,
null null
) )
} }
@ -2016,7 +2072,7 @@ class ConversationsListActivity :
binding.recyclerView?.scrollToPosition(0) binding.recyclerView?.scrollToPosition(0)
} }
} }
binding.swipeRefreshLayoutView?.isRefreshing = false binding.swipeRefreshLayoutView.isRefreshing = false
} }
private fun onMessageSearchError(throwable: Throwable) { 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.AudioRecorderManager
import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager
import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.chat.data.io.MediaRecorderManager
import com.nextcloud.talk.utils.preferences.AppPreferences
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -29,8 +30,10 @@ class ManagerModule {
} }
@Provides @Provides
fun provideMediaPlayerManager(): MediaPlayerManager { fun provideMediaPlayerManager(preferences: AppPreferences): MediaPlayerManager {
return MediaPlayerManager() return MediaPlayerManager().apply {
appPreferences = preferences
}
} }
@Provides @Provides

View File

@ -10,8 +10,8 @@ package com.nextcloud.talk.dagger.modules
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.chat.viewmodels.ChatViewModel
import com.nextcloud.talk.contacts.ContactsViewModel
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel
import com.nextcloud.talk.contacts.ContactsViewModel
import com.nextcloud.talk.conversation.viewmodel.ConversationViewModel import com.nextcloud.talk.conversation.viewmodel.ConversationViewModel
import com.nextcloud.talk.conversationcreation.ConversationCreationViewModel import com.nextcloud.talk.conversationcreation.ConversationCreationViewModel
import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel
@ -125,8 +125,6 @@ abstract class ViewModelModule {
@ViewModelKey(MessageInputViewModel::class) @ViewModelKey(MessageInputViewModel::class)
abstract fun messageInputViewModel(viewModel: MessageInputViewModel): ViewModel abstract fun messageInputViewModel(viewModel: MessageInputViewModel): ViewModel
// TODO I had a merge conflict here that went weird. choose their version
@Binds @Binds
@IntoMap @IntoMap
@ViewModelKey(ConversationInfoViewModel::class) @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 android.annotation.SuppressLint;
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel;
import com.nextcloud.talk.ui.PlaybackSpeed; import com.nextcloud.talk.ui.PlaybackSpeed;
import java.util.List;
import java.util.Map;
@SuppressLint("NonConstantResourceId") @SuppressLint("NonConstantResourceId")
public interface AppPreferences { public interface AppPreferences {
@ -175,9 +171,11 @@ public interface AppPreferences {
int getLastKnownId(String internalConversationId, int defaultValue); 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(); Long getNotificationWarningLastPostponedDate();

View File

@ -8,7 +8,6 @@
package com.nextcloud.talk.utils.preferences package com.nextcloud.talk.utils.preferences
import android.content.Context import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
@ -24,9 +23,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@Suppress("TooManyFunctions", "DeferredResultUnused", "EmptyFunctionBlock") @Suppress("TooManyFunctions", "DeferredResultUnused", "EmptyFunctionBlock")
@ -500,25 +496,41 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
return if (lastReadId.isNotEmpty()) lastReadId.toInt() else defaultValue return if (lastReadId.isNotEmpty()) lastReadId.toInt() else defaultValue
} }
override fun saveVoiceMessagePlaybackSpeedPreferences(speeds: Map<String, PlaybackSpeed>) { override fun deleteAllMessageQueuesFor(userId: String) {
Json.encodeToString(speeds).let { runBlocking {
runBlocking<Unit> { async { writeString(VOICE_MESSAGE_PLAYBACK_SPEEDS, it) } } 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> { for (key in keyList) {
return runBlocking { context.dataStore.edit {
async { readString(VOICE_MESSAGE_PLAYBACK_SPEEDS, "{}").first() } it.remove(key)
}.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()
} }
} }
} }
}
}
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 = override fun getNotificationWarningLastPostponedDate(): Long =
runBlocking { runBlocking {
@ -609,6 +621,8 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
const val DB_ROOM_MIGRATED = "db_room_migrated" const val DB_ROOM_MIGRATED = "db_room_migrated"
const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run" const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run"
const val TYPING_STATUS = "typing_status" 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 VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds"
const val SHOW_REGULAR_NOTIFICATION_WARNING = "show_regular_notification_warning" const val SHOW_REGULAR_NOTIFICATION_WARNING = "show_regular_notification_warning"
const val LAST_NOTIFICATION_WARNING = "last_notification_warning" const val LAST_NOTIFICATION_WARNING = "last_notification_warning"

View File

@ -143,6 +143,11 @@
app:popupTheme="@style/appActionBarPopupMenu" app:popupTheme="@style/appActionBarPopupMenu"
app:titleTextColor="@color/fontAppbar" app:titleTextColor="@color/fontAppbar"
tools:title="@string/nc_app_product_name" /> 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> </com.google.android.material.appbar.AppBarLayout>
<LinearLayout <LinearLayout

View File

@ -272,8 +272,6 @@ How to translate with transifex:
<string name="nc_contacts_done">Done</string> <string name="nc_contacts_done">Done</string>
<string name="user_avatar">User avatar</string> <string name="user_avatar">User avatar</string>
<string name="back_button">Back button</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 --> <!-- Permissions -->
<string name="nc_permissions_rationale_dialog_title">Please allow permissions</string> <string name="nc_permissions_rationale_dialog_title">Please allow permissions</string>
<string name="nc_permissions_denied">Some permissions were denied.</string> <string name="nc_permissions_denied">Some permissions were denied.</string>

View File

@ -1339,6 +1339,8 @@ vCeonVI7Q1CkIHt8u7eMgzfEkaiPLZlI0l0RpfT4pnNieqg=
-----END PGP PUBLIC KEY BLOCK----- -----END PGP PUBLIC KEY BLOCK-----
pub BAC30622339994C4 pub BAC30622339994C4
uid Chris Povirk <cpovirk@google.com>
sub FC9BDC25FB378008 sub FC9BDC25FB378008
-----BEGIN PGP PUBLIC KEY BLOCK----- -----BEGIN PGP PUBLIC KEY BLOCK-----
@ -1347,20 +1349,21 @@ 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
i8/b+PwSW+nW3EpQZdLqZtOU QzLSAVn44AHSomlgzecMR6RZk/fwbzSSiOc3rW1mNFOG70iLz9v4/BJb6dbcSlBl
=2H2i 0upm05Q=
=Gf3Y
-----END PGP PUBLIC KEY BLOCK----- -----END PGP PUBLIC KEY BLOCK-----
pub BCF4173966770193 pub BCF4173966770193
@ -1449,42 +1452,6 @@ lQyC8nl8P5PgkEZ5CHcGymZlpzihR3ECrPJTk39Sb7D3SxCW4WrChV3kVfmLgvc=
=WqT9 =WqT9
-----END PGP PUBLIC KEY BLOCK----- -----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 pub C21CE653B639E41A
sub 4F80368F9034B8D0 sub 4F80368F9034B8D0
-----BEGIN PGP PUBLIC KEY BLOCK----- -----BEGIN PGP PUBLIC KEY BLOCK-----
@ -5670,13 +5637,15 @@ xOcUt3JhIGtKwRMO4mte4wmT6Ko+Nj4uy6tFjbTfN2eBins/1F9qLU4YJUqC4QD4
-----END PGP PUBLIC KEY BLOCK----- -----END PGP PUBLIC KEY BLOCK-----
pub 7721F63BD38B4796 pub 7721F63BD38B4796
sub 4EB27DB2A3B88B8B uid Google Inc. (Linux Packages Signing Authority) <linux-packages-keymaster@google.com>
sub 1397BC53640DB551
sub 78BD65473CB3BD13
sub 6494C6D6997C215E
sub FD533C07C264648F sub FD533C07C264648F
sub 32EE5355A6BC6E42 sub 32EE5355A6BC6E42
sub E88979FB9B30ACF2 sub E88979FB9B30ACF2
sub 1397BC53640DB551
sub 6494C6D6997C215E
sub 78BD65473CB3BD13
sub 4EB27DB2A3B88B8B
-----BEGIN PGP PUBLIC KEY BLOCK----- -----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBFcMjNMBEAC6Wr5QuLIFgz1V1EFPlg8ty2TsjQEl4VWftUAqWlMevJFWvYEx mQINBFcMjNMBEAC6Wr5QuLIFgz1V1EFPlg8ty2TsjQEl4VWftUAqWlMevJFWvYEx
@ -5690,295 +5659,411 @@ xeqTWDlzONUpOs5yBjF1cfJSdVxsfshvln2JXUwgIdKl4DLbZybuNFXnPffNLb2v
PtRJHO48O2UbeXS8n27PcuMoLRd7+r7TsqG2vBH4t/cB/1vsvWMbqnQlaJ5VsjeW PtRJHO48O2UbeXS8n27PcuMoLRd7+r7TsqG2vBH4t/cB/1vsvWMbqnQlaJ5VsjeW
Tp8Gv9FJiKuU8PKiWsF4EGR/kAFyCB8QbJeQ6HrOT0CXLOaYHRu2TvJ4taY9doXn Tp8Gv9FJiKuU8PKiWsF4EGR/kAFyCB8QbJeQ6HrOT0CXLOaYHRu2TvJ4taY9doXn
98TgU03XTLcYoSp49cdkkis4K+9hd2dUqARVCG7UVd9PY60VVCKi47BVKQARAQAB 98TgU03XTLcYoSp49cdkkis4K+9hd2dUqARVCG7UVd9PY60VVCKi47BVKQARAQAB
uQINBGF4DJ8BEACk2Gwau+s/pKmOTnGLMnB3ybQsiVGLRhsw2SqSTvSyBthAyW1U tFRHb29nbGUgSW5jLiAoTGludXggUGFja2FnZXMgU2lnbmluZyBBdXRob3JpdHkp
AqdRqNA8/FdMlvVuppG8+vCLXPmpP63C+9M2tyQeOR2aVQp+u1EIwN4lPu4wrh6v IDxsaW51eC1wYWNrYWdlcy1rZXltYXN0ZXJAZ29vZ2xlLmNvbT65Ag0EZ31b7QEQ
dtgSRim8uxBdLIHG16z0xxVhE2rM/Ot/gucfkpoEw289VaR7sPmIxfVTm1QcqCGi AMEWM0g1KKIxE7q8JK7QblKom7++NYn92E3suHv3WxqzrhRT9tYNDSaoOazQP+ha
FQl3rZnma6Bz8UOXJoE8wO+LK5WkcdmFz6+Z3BLSb5IL9lhsArFToNq5dN2SSTbC NA2BqkdFcB7G4jKdtK2VLFc7RBpcR2rnQEJWgpeP03DHrdZYFdpH9zABoFsotgZR
TdHRzrRuoCdefYHdxoLCM4kJfggRRgWhKoEJro+ZipESq1T5yHV/iAJy+3DuC8Lb KwwTOoxdm6XtV+47LEY9yAefPrt1gPJQ4h1SKwWIFSRPChQ1cThBz2QD2LaPAGtj
YLvsjt9VZYARw8xIGb90Vj3ThWuMoVr/IVmKT7foC5Whe0PTI/b2frNaWCxxC4cR Wzr6+0xf0nm4xTDvya0EbdTpMOvtyDCUp7qe41u34RelGxoo0+rmoL/0cTJGCr2L
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
c8LNGrDaCFdXnOdlNV/zT9VvBk/RkV+Tl/Lk4g== mzvdN5YoV9ohJoa1Bxj7qialTT/Su1Eb/toOOkOlqQ7B+1NBXzv9FmiBntC4afyk
=AP/d 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
Y7Iyz4ygr8etweBPnEIvjwpAZxOu
=wwl/
-----END PGP PUBLIC KEY BLOCK----- -----END PGP PUBLIC KEY BLOCK-----
pub 79752DB6C966F0B8 pub 79752DB6C966F0B8

View File

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

View File

@ -1,2 +1,2 @@
DO NOT TOUCH; GENERATED BY DRONE 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>