diff --git a/app/build.gradle b/app/build.gradle index 879b904ea..15f2dd8c2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -267,7 +267,7 @@ dependencies { implementation 'com.novoda:merlin:1.2.1' implementation 'com.github.Kennyc1012:BottomSheet:2.4.1' - implementation 'com.github.nextcloud:PopupBubble:master-SNAPSHOT' + implementation 'com.github.nextcloud:PopupBubble:1.0.6' implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation('eu.medsea.mimeutil:mime-util:2.1.3', { diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.java b/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.java index 4a146e3fc..91b2d0fcd 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.java +++ b/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.java @@ -22,6 +22,7 @@ package com.nextcloud.talk.adapters.items; +import android.annotation.SuppressLint; import android.content.Context; import android.content.res.ColorStateList; import android.graphics.Color; @@ -105,6 +106,7 @@ public class ConversationItem extends AbstractFlexibleItem adapter, ConversationItemViewHolder holder, int position, List payloads) { Context appContext = diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.java b/app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.java index 714b17dd6..2d1fec291 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.java +++ b/app/src/main/java/com/nextcloud/talk/adapters/items/MentionAutocompleteItem.java @@ -45,17 +45,20 @@ import eu.davidea.flexibleadapter.utils.FlexibleUtils; public class MentionAutocompleteItem extends AbstractFlexibleItem implements IFilterable { + public static final String SOURCE_CALLS = "calls"; + public static final String SOURCE_GUESTS = "guests"; private String objectId; private String displayName; private String source; private UserEntity currentUser; private Context context; - public MentionAutocompleteItem(String objectId, - String displayName, - String source, - UserEntity currentUser, - Context activityContext) { + public MentionAutocompleteItem( + String objectId, + String displayName, + String source, + UserEntity currentUser, + Context activityContext) { this.objectId = objectId; this.displayName = displayName; this.source = source; @@ -102,19 +105,27 @@ public class MentionAutocompleteItem extends AbstractFlexibleItem adapter, UserItem.UserItemViewHolder holder, int position, List payloads) { + public void bindViewHolder( + FlexibleAdapter adapter, + UserItem.UserItemViewHolder holder, + int position, + List payloads) { holder.contactDisplayName.setTextColor(ResourcesCompat.getColor(context.getResources(), - R.color.conversation_item_header, - null)); + R.color.conversation_item_header, + null)); if (adapter.hasFilter()) { - FlexibleUtils.highlightText(holder.contactDisplayName, displayName, - String.valueOf(adapter.getFilter(String.class)), NextcloudTalkApplication.Companion.getSharedApplication() - .getResources().getColor(R.color.colorPrimary)); + FlexibleUtils.highlightText(holder.contactDisplayName, + displayName, + String.valueOf(adapter.getFilter(String.class)), + NextcloudTalkApplication.Companion.getSharedApplication() + .getResources().getColor(R.color.colorPrimary)); if (holder.contactMentionId != null) { - FlexibleUtils.highlightText(holder.contactMentionId, "@" + objectId, - String.valueOf(adapter.getFilter(String.class)), NextcloudTalkApplication.Companion.getSharedApplication() - .getResources().getColor(R.color.colorPrimary)); + FlexibleUtils.highlightText(holder.contactMentionId, + "@" + objectId, + String.valueOf(adapter.getFilter(String.class)), + NextcloudTalkApplication.Companion.getSharedApplication() + .getResources().getColor(R.color.colorPrimary)); } } else { holder.contactDisplayName.setText(displayName); @@ -123,16 +134,19 @@ public class MentionAutocompleteItem extends AbstractFlexibleItem if (info != null) { - updateViewsByProgress( - info - ) + when (info.state) { + WorkInfo.State.RUNNING -> { + Log.d(TAG, "WorkInfo.State.RUNNING in ViewHolder") + showVoiceMessageLoading() + } + WorkInfo.State.SUCCEEDED -> { + Log.d(TAG, "WorkInfo.State.SUCCEEDED in ViewHolder") + showPlayButton() + } + WorkInfo.State.FAILED -> { + Log.d(TAG, "WorkInfo.State.FAILED in ViewHolder") + showPlayButton() + } + else -> { + } + } } } } @@ -153,6 +178,16 @@ class IncomingVoiceMessageViewHolder(incomingView: View) : MessageHolders } } + private fun showPlayButton() { + binding.playPauseBtn.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + } + + private fun showVoiceMessageLoading() { + binding.playPauseBtn.visibility = View.GONE + binding.progressBar.visibility = View.VISIBLE + } + private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) { val author: String = message.actorDisplayName if (!TextUtils.isEmpty(author)) { @@ -249,155 +284,12 @@ class IncomingVoiceMessageViewHolder(incomingView: View) : MessageHolders } } - private fun openOrDownloadFile(message: ChatMessage) { - val filename = message.getSelectedIndividualHashMap()["name"] - val file = File(context!!.cacheDir, filename!!) - if (file.exists()) { - binding.progressBar.visibility = View.GONE - startPlayback(message) - } else { - binding.playBtn.visibility = View.GONE - binding.progressBar.visibility = View.VISIBLE - downloadFileToCache(message) - } - } - - private fun startPlayback(message: ChatMessage) { - initMediaPlayer(message) - - if (!mediaPlayer!!.isPlaying) { - mediaPlayer!!.start() - } - - handler = Handler() - activity.runOnUiThread(object : Runnable { - override fun run() { - if (mediaPlayer != null) { - val currentPosition: Int = mediaPlayer!!.currentPosition / SEEKBAR_BASE - binding.seekbar.progress = currentPosition - } - handler.postDelayed(this, SECOND) - } - }) - - binding.progressBar.visibility = View.GONE - binding.playBtn.visibility = View.GONE - binding.pauseBtn.visibility = View.VISIBLE - } - - private fun pausePlayback() { - if (mediaPlayer!!.isPlaying) { - mediaPlayer!!.pause() - } - - binding.playBtn.visibility = View.VISIBLE - binding.pauseBtn.visibility = View.GONE - } - - private fun initMediaPlayer(message: ChatMessage) { - val fileName = message.getSelectedIndividualHashMap()["name"] - val absolutePath = context!!.cacheDir.absolutePath + "/" + fileName - - if (mediaPlayer == null) { - mediaPlayer = MediaPlayer().apply { - setDataSource(absolutePath) - prepare() - } - } - - binding.seekbar.max = mediaPlayer!!.duration / SEEKBAR_BASE - - mediaPlayer!!.setOnCompletionListener { - binding.playBtn.visibility = View.VISIBLE - binding.pauseBtn.visibility = View.GONE - binding.seekbar.progress = SEEKBAR_START - handler.removeCallbacksAndMessages(null) - mediaPlayer?.stop() - mediaPlayer?.release() - mediaPlayer = null - } - } - - @SuppressLint("LongLogTag") - private fun downloadFileToCache(message: ChatMessage) { - val baseUrl = message.activeUser.baseUrl - val userId = message.activeUser.userId - val attachmentFolder = CapabilitiesUtil.getAttachmentFolder(message.activeUser) - val fileName = message.getSelectedIndividualHashMap()["name"] - var size = message.getSelectedIndividualHashMap()["size"] - if (size == null) { - size = "-1" - } - val fileSize = Integer.valueOf(size) - val fileId = message.getSelectedIndividualHashMap()["id"] - val path = message.getSelectedIndividualHashMap()["path"] - - // check if download worker is already running - val workers = WorkManager.getInstance( - context!! - ).getWorkInfosByTag(fileId!!) - try { - for (workInfo in workers.get()) { - if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) { - Log.d(TAG, "Download worker for " + fileId + " is already running or scheduled") - return - } - } - } catch (e: ExecutionException) { - Log.e(TAG, "Error when checking if worker already exists", e) - } catch (e: InterruptedException) { - Log.e(TAG, "Error when checking if worker already exists", e) - } - - val data: Data = Data.Builder() - .putString(DownloadFileToCacheWorker.KEY_BASE_URL, baseUrl) - .putString(DownloadFileToCacheWorker.KEY_USER_ID, userId) - .putString(DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, attachmentFolder) - .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileName) - .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path) - .putInt(DownloadFileToCacheWorker.KEY_FILE_SIZE, fileSize) - .build() - - val downloadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(DownloadFileToCacheWorker::class.java) - .setInputData(data) - .addTag(fileId) - .build() - - WorkManager.getInstance().enqueue(downloadWorker) - - WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(downloadWorker.id) - .observeForever { workInfo: WorkInfo -> - updateViewsByProgress( - workInfo - ) - } - } - - private fun updateViewsByProgress(workInfo: WorkInfo) { - when (workInfo.state) { - WorkInfo.State.RUNNING -> { - val progress = workInfo.progress.getInt(DownloadFileToCacheWorker.PROGRESS, -1) - if (progress > -1) { - binding.playBtn.visibility = View.GONE - binding.progressBar.visibility = View.VISIBLE - } - } - WorkInfo.State.SUCCEEDED -> { - startPlayback(message) - } - WorkInfo.State.FAILED -> { - binding.progressBar.visibility = View.GONE - binding.playBtn.visibility = View.VISIBLE - } - else -> { - } - } + fun assignAdapter(voiceMessageInterface: VoiceMessageInterface) { + this.voiceMessageInterface = voiceMessageInterface } companion object { private const val TAG = "VoiceInMessageView" - private const val SECOND: Long = 1000 - private const val SEEKBAR_BASE: Int = 1000 private const val SEEKBAR_START: Int = 0 } } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt index 7b8437ce4..335940f1e 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt @@ -23,19 +23,15 @@ package com.nextcloud.talk.adapters.messages import android.annotation.SuppressLint -import android.app.Activity import android.content.Context import android.graphics.PorterDuff -import android.media.MediaPlayer -import android.net.Uri import android.os.Handler import android.util.Log import android.view.View import android.widget.SeekBar import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat -import androidx.work.Data -import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import autodagger.AutoInjector @@ -44,25 +40,21 @@ import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.databinding.ItemCustomOutcomingVoiceMessageBinding -import com.nextcloud.talk.jobs.DownloadFileToCacheWorker -import com.nextcloud.talk.models.database.CapabilitiesUtil import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ReadStatus import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.preferences.AppPreferences import com.stfalcon.chatkit.messages.MessageHolders -import java.io.File import java.util.concurrent.ExecutionException import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class OutcomingVoiceMessageViewHolder(incomingView: View) : MessageHolders -.OutcomingTextMessageViewHolder(incomingView) { +class OutcomingVoiceMessageViewHolder(outcomingView: View) : MessageHolders +.OutcomingTextMessageViewHolder(outcomingView) { private val binding: ItemCustomOutcomingVoiceMessageBinding = ItemCustomOutcomingVoiceMessageBinding.bind(itemView) - private val realView: View = itemView @JvmField @Inject @@ -74,12 +66,10 @@ class OutcomingVoiceMessageViewHolder(incomingView: View) : MessageHolders lateinit var message: ChatMessage - lateinit var activity: Activity - - var mediaPlayer: MediaPlayer? = null - lateinit var handler: Handler + lateinit var voiceMessageInterface: VoiceMessageInterface + @SuppressLint("SetTextI18n") override fun onBind(message: ChatMessage) { super.onBind(message) @@ -94,57 +84,56 @@ class OutcomingVoiceMessageViewHolder(incomingView: View) : MessageHolders // parent message handling setParentMessageDataOnMessageItem(message) - binding.playBtn.setOnClickListener { - openOrDownloadFile(message) + updateDownloadState(message) + binding.seekbar.max = message.voiceMessageDuration + + if (message.isPlayingVoiceMessage) { + showPlayButton() + binding.playPauseBtn.icon = ContextCompat.getDrawable( + context!!, + R.drawable.ic_baseline_pause_voice_message_24 + ) + binding.seekbar.progress = message.voiceMessagePlayedSeconds + } else { + binding.playPauseBtn.visibility = View.VISIBLE + binding.playPauseBtn.icon = ContextCompat.getDrawable( + context!!, + R.drawable.ic_baseline_play_arrow_voice_message_24 + ) } - binding.pauseBtn.setOnClickListener { - pausePlayback() + if (message.isDownloadingVoiceMessage) { + showVoiceMessageLoading() + } else { + binding.progressBar.visibility = View.GONE } - activity = itemView.context as Activity + if (message.resetVoiceMessage) { + binding.playPauseBtn.visibility = View.VISIBLE + binding.playPauseBtn.icon = ContextCompat.getDrawable( + context!!, + R.drawable.ic_baseline_play_arrow_voice_message_24 + ) + binding.seekbar.progress = SEEKBAR_START + message.resetVoiceMessage = false + } binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onStopTrackingTouch(seekBar: SeekBar) { // unused atm } + override fun onStartTrackingTouch(seekBar: SeekBar) { // unused atm } + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - if (mediaPlayer != null && fromUser) { - mediaPlayer!!.seekTo(progress * SEEKBAR_BASE) + if (fromUser) { + voiceMessageInterface.updateMediaPlayerProgressBySlider(message, progress) } } }) - // check if download worker is already running - val fileId = message.getSelectedIndividualHashMap()["id"] - val workers = WorkManager.getInstance( - context!! - ).getWorkInfosByTag(fileId!!) - - try { - for (workInfo in workers.get()) { - if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) { - binding.progressBar.visibility = View.VISIBLE - binding.playBtn.visibility = View.GONE - WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(workInfo.id) - .observeForever { info: WorkInfo? -> - if (info != null) { - updateViewsByProgress( - info - ) - } - } - } - } - } catch (e: ExecutionException) { - Log.e(TAG, "Error when checking if worker already exists", e) - } catch (e: InterruptedException) { - Log.e(TAG, "Error when checking if worker already exists", e) - } - val readStatusDrawableInt = when (message.readStatus) { ReadStatus.READ -> R.drawable.ic_check_all ReadStatus.SENT -> R.drawable.ic_check @@ -167,6 +156,56 @@ class OutcomingVoiceMessageViewHolder(incomingView: View) : MessageHolders binding.checkMark.setContentDescription(readStatusContentDescriptionString) } + private fun updateDownloadState(message: ChatMessage) { + // check if download worker is already running + val fileId = message.getSelectedIndividualHashMap()["id"] + val workers = WorkManager.getInstance(context!!).getWorkInfosByTag(fileId!!) + + try { + for (workInfo in workers.get()) { + if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) { + showVoiceMessageLoading() + WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(workInfo.id) + .observeForever { info: WorkInfo? -> + if (info != null) { + + when (info.state) { + WorkInfo.State.RUNNING -> { + Log.d(TAG, "WorkInfo.State.RUNNING in ViewHolder") + showVoiceMessageLoading() + } + WorkInfo.State.SUCCEEDED -> { + Log.d(TAG, "WorkInfo.State.SUCCEEDED in ViewHolder") + showPlayButton() + } + WorkInfo.State.FAILED -> { + Log.d(TAG, "WorkInfo.State.FAILED in ViewHolder") + showPlayButton() + } + else -> { + } + } + } + } + } + } + } catch (e: ExecutionException) { + Log.e(TAG, "Error when checking if worker already exists", e) + } catch (e: InterruptedException) { + Log.e(TAG, "Error when checking if worker already exists", e) + } + } + + private fun showPlayButton() { + binding.playPauseBtn.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + } + + private fun showVoiceMessageLoading() { + binding.playPauseBtn.visibility = View.GONE + binding.progressBar.visibility = View.VISIBLE + } + private fun setParentMessageDataOnMessageItem(message: ChatMessage) { if (!message.isDeleted && message.parentMessage != null) { val parentChatMessage = message.parentMessage @@ -224,156 +263,12 @@ class OutcomingVoiceMessageViewHolder(incomingView: View) : MessageHolders } } - private fun openOrDownloadFile(message: ChatMessage) { - val filename = message.getSelectedIndividualHashMap()["name"] - val file = File(context!!.cacheDir, filename!!) - - if (file.exists()) { - binding.progressBar.visibility = View.GONE - startPlayback(message) - } else { - binding.playBtn.visibility = View.GONE - binding.progressBar.visibility = View.VISIBLE - downloadFileToCache(message) - } - } - - private fun startPlayback(message: ChatMessage) { - initMediaPlayer(message) - - if (!mediaPlayer!!.isPlaying) { - mediaPlayer!!.start() - } - - handler = Handler() - activity.runOnUiThread(object : Runnable { - override fun run() { - if (mediaPlayer != null) { - val currentPosition: Int = mediaPlayer!!.currentPosition / SEEKBAR_BASE - binding.seekbar.progress = currentPosition - } - handler.postDelayed(this, SECOND) - } - }) - - binding.progressBar.visibility = View.GONE - binding.playBtn.visibility = View.GONE - binding.pauseBtn.visibility = View.VISIBLE - } - - private fun pausePlayback() { - if (mediaPlayer!!.isPlaying) { - mediaPlayer!!.pause() - } - - binding.playBtn.visibility = View.VISIBLE - binding.pauseBtn.visibility = View.GONE - } - - private fun initMediaPlayer(message: ChatMessage) { - val fileName = message.getSelectedIndividualHashMap()["name"] - val absolutePath = context!!.cacheDir.absolutePath + "/" + fileName - - if (mediaPlayer == null) { - mediaPlayer = MediaPlayer().apply { - setDataSource(context!!, Uri.parse(absolutePath)) - prepare() - } - } - - binding.seekbar.max = mediaPlayer!!.duration / SEEKBAR_BASE - - mediaPlayer!!.setOnCompletionListener { - binding.playBtn.visibility = View.VISIBLE - binding.pauseBtn.visibility = View.GONE - binding.seekbar.progress = SEEKBAR_START - handler.removeCallbacksAndMessages(null) - mediaPlayer?.stop() - mediaPlayer?.release() - mediaPlayer = null - } - } - - @SuppressLint("LongLogTag") - private fun downloadFileToCache(message: ChatMessage) { - val baseUrl = message.activeUser.baseUrl - val userId = message.activeUser.userId - val attachmentFolder = CapabilitiesUtil.getAttachmentFolder(message.activeUser) - val fileName = message.getSelectedIndividualHashMap()["name"] - var size = message.getSelectedIndividualHashMap()["size"] - if (size == null) { - size = "-1" - } - val fileSize = Integer.valueOf(size) - val fileId = message.getSelectedIndividualHashMap()["id"] - val path = message.getSelectedIndividualHashMap()["path"] - - // check if download worker is already running - val workers = WorkManager.getInstance( - context!! - ).getWorkInfosByTag(fileId!!) - try { - for (workInfo in workers.get()) { - if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) { - Log.d(TAG, "Download worker for " + fileId + " is already running or scheduled") - return - } - } - } catch (e: ExecutionException) { - Log.e(TAG, "Error when checking if worker already exists", e) - } catch (e: InterruptedException) { - Log.e(TAG, "Error when checking if worker already exists", e) - } - - val data: Data = Data.Builder() - .putString(DownloadFileToCacheWorker.KEY_BASE_URL, baseUrl) - .putString(DownloadFileToCacheWorker.KEY_USER_ID, userId) - .putString(DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, attachmentFolder) - .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileName) - .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path) - .putInt(DownloadFileToCacheWorker.KEY_FILE_SIZE, fileSize) - .build() - - val downloadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(DownloadFileToCacheWorker::class.java) - .setInputData(data) - .addTag(fileId) - .build() - - WorkManager.getInstance().enqueue(downloadWorker) - - WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(downloadWorker.id) - .observeForever { workInfo: WorkInfo -> - updateViewsByProgress( - workInfo - ) - } - } - - private fun updateViewsByProgress(workInfo: WorkInfo) { - when (workInfo.state) { - WorkInfo.State.RUNNING -> { - val progress = workInfo.progress.getInt(DownloadFileToCacheWorker.PROGRESS, -1) - if (progress > -1) { - binding.playBtn.visibility = View.GONE - binding.progressBar.visibility = View.VISIBLE - } - } - WorkInfo.State.SUCCEEDED -> { - startPlayback(message) - } - WorkInfo.State.FAILED -> { - binding.progressBar.visibility = View.GONE - binding.playBtn.visibility = View.VISIBLE - } - else -> { - } - } + fun assignAdapter(voiceMessageInterface: VoiceMessageInterface) { + this.voiceMessageInterface = voiceMessageInterface } companion object { private const val TAG = "VoiceOutMessageView" - private const val SECOND: Long = 1000 - private const val SEEKBAR_BASE: Int = 1000 private const val SEEKBAR_START: Int = 0 } } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java b/app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java index bcf6e92ee..916253a5c 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java @@ -20,7 +20,9 @@ package com.nextcloud.talk.adapters.messages; +import com.nextcloud.talk.controllers.ChatController; import com.stfalcon.chatkit.commons.ImageLoader; +import com.stfalcon.chatkit.commons.ViewHolder; import com.stfalcon.chatkit.commons.models.IMessage; import com.stfalcon.chatkit.messages.MessageHolders; import com.stfalcon.chatkit.messages.MessagesListAdapter; @@ -28,12 +30,29 @@ import com.stfalcon.chatkit.messages.MessagesListAdapter; import java.util.List; public class TalkMessagesListAdapter extends MessagesListAdapter { + private final ChatController chatController; - public TalkMessagesListAdapter(String senderId, MessageHolders holders, ImageLoader imageLoader) { + public TalkMessagesListAdapter( + String senderId, + MessageHolders holders, + ImageLoader imageLoader, + ChatController chatController) { super(senderId, holders, imageLoader); + this.chatController = chatController; } public List getItems() { return items; } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + super.onBindViewHolder(holder, position); + + if (holder instanceof IncomingVoiceMessageViewHolder) { + ((IncomingVoiceMessageViewHolder) holder).assignAdapter(chatController); + } else if (holder instanceof OutcomingVoiceMessageViewHolder) { + ((OutcomingVoiceMessageViewHolder) holder).assignAdapter(chatController); + } + } } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/VoiceMessageInterface.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/VoiceMessageInterface.kt new file mode 100644 index 000000000..84ac39040 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/VoiceMessageInterface.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2021 Marcel Hibbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.adapters.messages + +import com.nextcloud.talk.models.json.chat.ChatMessage + +interface VoiceMessageInterface { + fun updateMediaPlayerProgressBySlider(message: ChatMessage, progress: Int) +} diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index ea6cea1af..e12c711e9 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -34,6 +34,7 @@ import android.content.pm.PackageManager import android.content.res.Resources import android.graphics.Bitmap import android.graphics.drawable.ColorDrawable +import android.media.MediaPlayer import android.media.MediaRecorder import android.net.Uri import android.os.Build @@ -76,6 +77,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.work.Data import androidx.work.OneTimeWorkRequest +import androidx.work.WorkInfo import androidx.work.WorkManager import autodagger.AutoInjector import coil.load @@ -102,6 +104,7 @@ import com.nextcloud.talk.adapters.messages.OutcomingLocationMessageViewHolder import com.nextcloud.talk.adapters.messages.OutcomingPreviewMessageViewHolder import com.nextcloud.talk.adapters.messages.OutcomingVoiceMessageViewHolder import com.nextcloud.talk.adapters.messages.TalkMessagesListAdapter +import com.nextcloud.talk.adapters.messages.VoiceMessageInterface import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.callbacks.MentionAutocompleteCallback @@ -112,6 +115,7 @@ import com.nextcloud.talk.controllers.util.viewBinding import com.nextcloud.talk.databinding.ControllerChatBinding import com.nextcloud.talk.events.UserMentionClickEvent import com.nextcloud.talk.events.WebSocketCommunicationEvent +import com.nextcloud.talk.jobs.DownloadFileToCacheWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.models.database.CapabilitiesUtil import com.nextcloud.talk.models.database.UserEntity @@ -175,6 +179,7 @@ import java.util.ArrayList import java.util.Date import java.util.HashMap import java.util.Objects +import java.util.concurrent.ExecutionException import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -186,7 +191,9 @@ class ChatController(args: Bundle) : MessagesListAdapter.OnLoadMoreListener, MessagesListAdapter.Formatter, MessagesListAdapter.OnMessageViewLongClickListener, - ContentChecker { + ContentChecker, + VoiceMessageInterface { + private val binding: ControllerChatBinding by viewBinding(ControllerChatBinding::bind) @Inject @@ -247,6 +254,10 @@ class ChatController(args: Bundle) : private var recorder: MediaRecorder? = null + var mediaPlayer: MediaPlayer? = null + lateinit var mediaPlayerHandler: Handler + var currentlyPlayedVoiceMessage: ChatMessage? = null + init { setHasOptionsMenu(true) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) @@ -488,7 +499,8 @@ class ChatController(args: Bundle) : .setAutoPlayAnimations(true) .build() imageView.controller = draweeController - } + }, + this ) } else { binding.messagesListView.visibility = View.VISIBLE @@ -499,6 +511,22 @@ class ChatController(args: Bundle) : adapter?.setDateHeadersFormatter { format(it) } adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) } + adapter?.registerViewClickListener( + R.id.playPauseBtn + ) { view, message -> + val filename = message.getSelectedIndividualHashMap()["name"] + val file = File(context!!.cacheDir, filename!!) + if (file.exists()) { + if (message.isPlayingVoiceMessage) { + pausePlayback(message) + } else { + startPlayback(message) + } + } else { + downloadFileToCache(message) + } + } + if (context != null) { val messageSwipeController = MessageSwipeCallback( activity!!, @@ -749,6 +777,151 @@ class ChatController(args: Bundle) : super.onViewBound(view) } + private fun startPlayback(message: ChatMessage) { + + if (!this.isAttached) { + // 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) + + if (!mediaPlayer!!.isPlaying) { + mediaPlayer!!.start() + } + + mediaPlayerHandler = Handler() + activity?.runOnUiThread(object : Runnable { + override fun run() { + if (mediaPlayer != null) { + val currentPosition: Int = mediaPlayer!!.currentPosition / VOICE_MESSAGE_SEEKBAR_BASE + message.voiceMessagePlayedSeconds = currentPosition + adapter?.update(message) + } + mediaPlayerHandler.postDelayed(this, SECOND) + } + }) + + message.isDownloadingVoiceMessage = false + message.isPlayingVoiceMessage = true + adapter?.update(message) + } + + private fun pausePlayback(message: ChatMessage) { + if (mediaPlayer!!.isPlaying) { + mediaPlayer!!.pause() + } + + message.isPlayingVoiceMessage = false + adapter?.update(message) + } + + private fun initMediaPlayer(message: ChatMessage) { + if (message != currentlyPlayedVoiceMessage) { + currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) } + } + + if (mediaPlayer == null) { + val fileName = message.getSelectedIndividualHashMap()["name"] + val absolutePath = context!!.cacheDir.absolutePath + "/" + fileName + mediaPlayer = MediaPlayer().apply { + setDataSource(absolutePath) + prepare() + } + currentlyPlayedVoiceMessage = message + message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE + + mediaPlayer!!.setOnCompletionListener { + stopMediaPlayer(message) + } + } else { + Log.e(TAG, "mediaPlayer was not null. This should not happen!") + } + } + + private fun stopMediaPlayer(message: ChatMessage) { + message.isPlayingVoiceMessage = false + message.resetVoiceMessage = true + adapter?.update(message) + + currentlyPlayedVoiceMessage = null + + mediaPlayerHandler.removeCallbacksAndMessages(null) + + mediaPlayer?.stop() + mediaPlayer?.release() + mediaPlayer = null + } + + override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) { + if (mediaPlayer != null) { + if (messageWithSlidedProgress == currentlyPlayedVoiceMessage) { + mediaPlayer!!.seekTo(progress * VOICE_MESSAGE_SEEKBAR_BASE) + } + } + } + + @SuppressLint("LongLogTag") + private fun downloadFileToCache(message: ChatMessage) { + message.isDownloadingVoiceMessage = true + adapter?.update(message) + + val baseUrl = message.activeUser.baseUrl + val userId = message.activeUser.userId + val attachmentFolder = CapabilitiesUtil.getAttachmentFolder(message.activeUser) + val fileName = message.getSelectedIndividualHashMap()["name"] + var size = message.getSelectedIndividualHashMap()["size"] + if (size == null) { + size = "-1" + } + val fileSize = Integer.valueOf(size) + val fileId = message.getSelectedIndividualHashMap()["id"] + val path = message.getSelectedIndividualHashMap()["path"] + + // check if download worker is already running + val workers = WorkManager.getInstance( + context!! + ).getWorkInfosByTag(fileId!!) + try { + for (workInfo in workers.get()) { + if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) { + Log.d(TAG, "Download worker for " + fileId + " is already running or scheduled") + return + } + } + } catch (e: ExecutionException) { + Log.e(TAG, "Error when checking if worker already exists", e) + } catch (e: InterruptedException) { + Log.e(TAG, "Error when checking if worker already exists", e) + } + + val data: Data = Data.Builder() + .putString(DownloadFileToCacheWorker.KEY_BASE_URL, baseUrl) + .putString(DownloadFileToCacheWorker.KEY_USER_ID, userId) + .putString(DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, attachmentFolder) + .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileName) + .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path) + .putInt(DownloadFileToCacheWorker.KEY_FILE_SIZE, fileSize) + .build() + + val downloadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(DownloadFileToCacheWorker::class.java) + .setInputData(data) + .addTag(fileId) + .build() + + WorkManager.getInstance().enqueue(downloadWorker) + + WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(downloadWorker.id) + .observeForever { workInfo: WorkInfo -> + if (workInfo.state == WorkInfo.State.SUCCEEDED) { + startPlayback(message) + } + } + } + @SuppressLint("SimpleDateFormat") private fun setVoiceRecordFileName() { val pattern = "yyyy-MM-dd HH-mm-ss" @@ -1290,6 +1463,8 @@ class ChatController(args: Bundle) : actionBar?.setIcon(null) } + currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) } + adapter = null inConversation = false } @@ -2349,5 +2524,7 @@ class ChatController(args: Bundle) : private const val SHORT_VIBRATE: Long = 20 private const val FULLY_OPAQUE_INT: Int = 255 private const val SEMI_TRANSPARENT_INT: Int = 99 + private const val VOICE_MESSAGE_SEEKBAR_BASE: Int = 1000 + private const val SECOND: Long = 1000 } } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java index e2ad6c17c..41c5c48c3 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java +++ b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java @@ -90,6 +90,13 @@ public class ChatMessage implements MessageContentType, MessageContentType.Image @JsonField(name = "messageType") public String messageType; + public boolean isDownloadingVoiceMessage; + public boolean resetVoiceMessage; + public boolean isPlayingVoiceMessage; + public int voiceMessageDuration; + public int voiceMessagePlayedSeconds; + public int voiceMessageDownloadProgress; + @JsonIgnore List messageTypesToIgnore = Arrays.asList( MessageType.REGULAR_TEXT_MESSAGE, @@ -133,8 +140,6 @@ public class ChatMessage implements MessageContentType, MessageContentType.Image @Nullable @Override public String getImageUrl() { - - if (messageParameters != null && messageParameters.size() > 0) { for (HashMap.Entry> entry : messageParameters.entrySet()) { Map individualHashMap = entry.getValue(); diff --git a/app/src/main/res/layout/item_custom_incoming_voice_message.xml b/app/src/main/res/layout/item_custom_incoming_voice_message.xml index 7c19ad934..17dffdabe 100644 --- a/app/src/main/res/layout/item_custom_incoming_voice_message.xml +++ b/app/src/main/res/layout/item_custom_incoming_voice_message.xml @@ -66,7 +66,7 @@ android:textSize="12sp" /> @@ -79,32 +79,20 @@ android:visibility="gone"/> - - diff --git a/app/src/main/res/layout/item_custom_outcoming_voice_message.xml b/app/src/main/res/layout/item_custom_outcoming_voice_message.xml index 0f7c4ca59..bc412aa13 100644 --- a/app/src/main/res/layout/item_custom_outcoming_voice_message.xml +++ b/app/src/main/res/layout/item_custom_outcoming_voice_message.xml @@ -49,7 +49,7 @@ android:visibility="gone" /> @@ -60,14 +60,15 @@ android:layout_height="wrap_content" android:layout_gravity="center" android:progressTint="@color/fontAppbar" - android:visibility="gone"/> + android:visibility="gone" + android:indeterminateTint="@color/nc_outcoming_text_default"/> - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fcef576fb..afa9e5c2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -389,8 +389,7 @@ Hold to record, release to send. Record voice message << Slide to cancel - Play voice message - Pause voice message + Play/pause voice message Permission for audio recording is required diff --git a/scripts/analysis/findbugs-results.txt b/scripts/analysis/findbugs-results.txt index ce163e7b6..273849746 100644 --- a/scripts/analysis/findbugs-results.txt +++ b/scripts/analysis/findbugs-results.txt @@ -1 +1 @@ -440 \ No newline at end of file +439 \ No newline at end of file diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index 96f95f5ca..210a55682 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 3 errors and 275 warnings + Lint Report: 3 errors and 274 warnings