From 9fbf9ef4923c3b8d1f2b7c448e725671cbc7b77b Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 21 Jun 2021 11:40:40 +0200 Subject: [PATCH 01/13] add voice messages Signed-off-by: Marcel Hibbe --- app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 1 + .../IncomingVoiceMessageViewHolder.kt | 396 ++++++++++++++++++ .../OutcomingVoiceMessageViewHolder.kt | 374 +++++++++++++++++ .../java/com/nextcloud/talk/api/NcApi.java | 3 +- .../talk/controllers/ChatController.kt | 365 ++++++++++++++-- .../talk/interfaces/ExtendedIMessage.kt | 28 -- .../talk/jobs/ShareOperationWorker.java | 31 +- .../talk/jobs/UploadAndShareFilesWorker.kt | 34 +- .../talk/models/json/chat/ChatMessage.java | 39 +- .../nextcloud/talk/utils/bundle/BundleKeys.kt | 1 + .../drawable/ic_baseline_attach_file_24.xml | 10 + .../drawable/ic_baseline_attachment_24.xml | 10 + .../res/drawable/ic_baseline_keyboard_24.xml | 10 + .../main/res/drawable/ic_baseline_mic_24.xml | 10 + .../res/drawable/ic_baseline_mic_red_24.xml | 10 + .../ic_baseline_pause_voice_message_24.xml | 9 + ...c_baseline_play_arrow_voice_message_24.xml | 9 + .../voice_message_outgoing_seek_bar_ruler.xml | 9 + ...voice_message_outgoing_seek_bar_slider.xml | 10 + app/src/main/res/layout/controller_chat.xml | 13 +- .../item_custom_incoming_voice_message.xml | 115 +++++ .../item_custom_outcoming_voice_message.xml | 111 +++++ .../main/res/layout/view_message_input.xml | 119 +++++- app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 11 + 26 files changed, 1621 insertions(+), 113 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt create mode 100644 app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/interfaces/ExtendedIMessage.kt create mode 100644 app/src/main/res/drawable/ic_baseline_attach_file_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_attachment_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_keyboard_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_mic_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_mic_red_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_pause_voice_message_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_play_arrow_voice_message_24.xml create mode 100644 app/src/main/res/drawable/voice_message_outgoing_seek_bar_ruler.xml create mode 100644 app/src/main/res/drawable/voice_message_outgoing_seek_bar_slider.xml create mode 100644 app/src/main/res/layout/item_custom_incoming_voice_message.xml create mode 100644 app/src/main/res/layout/item_custom_outcoming_voice_message.xml diff --git a/app/build.gradle b/app/build.gradle index 5e0da95b8..ae22e0d1a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -297,6 +297,8 @@ dependencies { exclude group: 'org.apache.httpcomponents', module: 'httpclient' }) + implementation 'androidx.core:core-ktx:1.5.0' + testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:3.11.1' testImplementation "org.powermock:powermock-core:${powermockVersion}" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index acca95c7a..192166c78 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -94,6 +94,7 @@ diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt new file mode 100644 index 000000000..029380b41 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt @@ -0,0 +1,396 @@ +/* + * Nextcloud Talk application + * + * @author Mario Danic + * @author Marcel Hibbe + * @author Andy Scherzinger + * Copyright (C) 2021 Andy Scherzinger + * Copyright (C) 2021 Marcel Hibbe + * Copyright (C) 2017-2018 Mario Danic + * + * 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 android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.media.MediaPlayer +import android.os.Handler +import android.text.TextUtils +import android.util.Log +import android.view.View +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.view.ViewCompat +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import autodagger.AutoInjector +import coil.load +import com.amulyakhare.textdrawable.TextDrawable +import com.nextcloud.talk.R +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.databinding.ItemCustomIncomingVoiceMessageBinding +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.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 IncomingVoiceMessageViewHolder(incomingView: View) : MessageHolders +.IncomingTextMessageViewHolder(incomingView) { + + private val binding: ItemCustomIncomingVoiceMessageBinding = + ItemCustomIncomingVoiceMessageBinding.bind(itemView) + + @JvmField + @Inject + var context: Context? = null + + @JvmField + @Inject + var appPreferences: AppPreferences? = null + + lateinit var message: ChatMessage + + lateinit var activity: Activity + + var mediaPlayer: MediaPlayer? = null + + lateinit var handler: Handler + + @SuppressLint("SetTextI18n") + override fun onBind(message: ChatMessage) { + super.onBind(message) + this.message = message + sharedApplication!!.componentApplication.inject(this) + + setAvatarAndAuthorOnMessageItem(message) + + colorizeMessageBubble(message) + + itemView.isSelected = false + binding.messageTime.setTextColor(context?.resources!!.getColor(R.color.warm_grey_four)) + + // parent message handling + setParentMessageDataOnMessageItem(message) + + binding.playBtn.setOnClickListener { + openOrDownloadFile(message) + } + + binding.pauseBtn.setOnClickListener { + pausePlayback() + } + + activity = itemView.context as Activity + + binding.seekbar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onStopTrackingTouch(seekBar: SeekBar) {} + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + if (mediaPlayer != null && fromUser) { + mediaPlayer!!.seekTo(progress * 1000) + } + } + }) + + // 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) + } + } + + private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) { + val author: String = message.actorDisplayName + if (!TextUtils.isEmpty(author)) { + binding.messageAuthor.text = author + } else { + binding.messageAuthor.setText(R.string.nc_nick_guest) + } + + if (!message.isGrouped && !message.isOneToOneConversation) { + binding.messageUserAvatar.visibility = View.VISIBLE + if (message.actorType == "guests") { + // do nothing, avatar is set + } else if (message.actorType == "bots" && message.actorId == "changelog") { + val layers = arrayOfNulls(2) + layers[0] = AppCompatResources.getDrawable(context!!, R.drawable.ic_launcher_background) + layers[1] = AppCompatResources.getDrawable(context!!, R.drawable.ic_launcher_foreground) + val layerDrawable = LayerDrawable(layers) + binding.messageUserAvatar.setImageDrawable(DisplayUtils.getRoundedDrawable(layerDrawable)) + } else if (message.actorType == "bots") { + val drawable = TextDrawable.builder() + .beginConfig() + .bold() + .endConfig() + .buildRound( + ">", + context!!.resources.getColor(R.color.black) + ) + binding.messageUserAvatar.visibility = View.VISIBLE + binding.messageUserAvatar.setImageDrawable(drawable) + } + } else { + if (message.isOneToOneConversation) { + binding.messageUserAvatar.visibility = View.GONE + } else { + binding.messageUserAvatar.visibility = View.INVISIBLE + } + binding.messageAuthor.visibility = View.GONE + } + } + + private fun colorizeMessageBubble(message: ChatMessage) { + val resources = itemView.resources + + var bubbleResource = R.drawable.shape_incoming_message + + if (message.isGrouped) { + bubbleResource = R.drawable.shape_grouped_incoming_message + } + + val bgBubbleColor = if (message.isDeleted) { + resources.getColor(R.color.bg_message_list_incoming_bubble_deleted) + } else { + resources.getColor(R.color.bg_message_list_incoming_bubble) + } + val bubbleDrawable = DisplayUtils.getMessageSelector( + bgBubbleColor, + resources.getColor(R.color.transparent), + bgBubbleColor, bubbleResource + ) + ViewCompat.setBackground(bubble, bubbleDrawable) + } + + private fun setParentMessageDataOnMessageItem(message: ChatMessage) { + if (!message.isDeleted && message.parentMessage != null) { + val parentChatMessage = message.parentMessage + parentChatMessage.activeUser = message.activeUser + parentChatMessage.imageUrl?.let { + binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE + binding.messageQuote.quotedMessageImage.load(it) { + addHeader( + "Authorization", + ApiUtils.getCredentials(message.activeUser.username, message.activeUser.token) + ) + } + } ?: run { + binding.messageQuote.quotedMessageImage.visibility = View.GONE + } + binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName + ?: context!!.getText(R.string.nc_nick_guest) + binding.messageQuote.quotedMessage.text = parentChatMessage.text + + binding.messageQuote.quotedMessageAuthor + .setTextColor(context!!.resources.getColor(R.color.textColorMaxContrast)) + + if (parentChatMessage.actorId?.equals(message.activeUser.userId) == true) { + binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.colorPrimary) + } else { + binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.textColorMaxContrast) + } + + binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE + } else { + binding.messageQuote.quotedChatMessageView.visibility = View.GONE + } + } + + 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 / 1000 + binding.seekbar.progress = currentPosition + } + handler.postDelayed(this, 1000) + } + }) + + 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 / 1000 + + mediaPlayer!!.setOnCompletionListener { + binding.playBtn.visibility = View.VISIBLE + binding.pauseBtn.visibility = View.GONE + binding.seekbar.progress = 0 + 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 exsists", e) + } catch (e: InterruptedException) { + Log.e(TAG, "Error when checking if worker already exsists", e) + } + val data: Data + val downloadWorker: OneTimeWorkRequest + 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() + downloadWorker = 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 -> { + } + } + } + + companion object { + private const val TAG = "VoiceInMessageView" + } +} 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 new file mode 100644 index 000000000..d67e39b12 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt @@ -0,0 +1,374 @@ +/* + * Nextcloud Talk application + * + * @author Mario Danic + * @author Marcel Hibbe + * Copyright (C) 2017-2018 Mario Danic + * 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 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.view.ViewCompat +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import autodagger.AutoInjector +import coil.load +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) { + + private val binding: ItemCustomOutcomingVoiceMessageBinding = + ItemCustomOutcomingVoiceMessageBinding.bind(itemView) + private val realView: View = itemView + + @JvmField + @Inject + var context: Context? = null + + @JvmField + @Inject + var appPreferences: AppPreferences? = null + + lateinit var message: ChatMessage + + lateinit var activity: Activity + + var mediaPlayer: MediaPlayer? = null + + lateinit var handler: Handler + + @SuppressLint("SetTextI18n") + override fun onBind(message: ChatMessage) { + super.onBind(message) + this.message = message + sharedApplication!!.componentApplication.inject(this) + + colorizeMessageBubble(message) + + itemView.isSelected = false + binding.messageTime.setTextColor(context?.resources!!.getColor(R.color.warm_grey_four)) + + // parent message handling + setParentMessageDataOnMessageItem(message) + + binding.playBtn.setOnClickListener { + openOrDownloadFile(message) + } + + binding.pauseBtn.setOnClickListener { + pausePlayback() + } + + activity = itemView.context as Activity + + binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onStopTrackingTouch(seekBar: SeekBar) {} + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + if (mediaPlayer != null && fromUser) { + mediaPlayer!!.seekTo(progress * 1000) + } + } + }) + + // 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 + else -> null + } + + val readStatusContentDescriptionString = when (message.readStatus) { + ReadStatus.READ -> context?.resources?.getString(R.string.nc_message_read) + ReadStatus.SENT -> context?.resources?.getString(R.string.nc_message_sent) + else -> null + } + + readStatusDrawableInt?.let { drawableInt -> + AppCompatResources.getDrawable(context!!, drawableInt)?.let { + it.setColorFilter(context?.resources!!.getColor(R.color.white60), PorterDuff.Mode.SRC_ATOP) + binding.checkMark.setImageDrawable(it) + } + } + + binding.checkMark.setContentDescription(readStatusContentDescriptionString) + } + + private fun setParentMessageDataOnMessageItem(message: ChatMessage) { + if (!message.isDeleted && message.parentMessage != null) { + val parentChatMessage = message.parentMessage + parentChatMessage.activeUser = message.activeUser + parentChatMessage.imageUrl?.let { + binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE + binding.messageQuote.quotedMessageImage.load(it) { + addHeader( + "Authorization", + ApiUtils.getCredentials(message.activeUser.username, message.activeUser.token) + ) + } + } ?: run { + binding.messageQuote.quotedMessageImage.visibility = View.GONE + } + binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName + ?: context!!.getText(R.string.nc_nick_guest) + binding.messageQuote.quotedMessage.text = parentChatMessage.text + binding.messageQuote.quotedMessage.setTextColor( + context!!.resources.getColor(R.color.nc_outcoming_text_default) + ) + binding.messageQuote.quotedMessageAuthor.setTextColor(context!!.resources.getColor(R.color.nc_grey)) + + binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.white) + + binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE + } else { + binding.messageQuote.quotedChatMessageView.visibility = View.GONE + } + } + + private fun colorizeMessageBubble(message: ChatMessage) { + val resources = sharedApplication!!.resources + val bgBubbleColor = if (message.isDeleted) { + resources.getColor(R.color.bg_message_list_outcoming_bubble_deleted) + } else { + resources.getColor(R.color.bg_message_list_outcoming_bubble) + } + if (message.isGrouped) { + val bubbleDrawable = DisplayUtils.getMessageSelector( + bgBubbleColor, + resources.getColor(R.color.transparent), + bgBubbleColor, + R.drawable.shape_grouped_outcoming_message + ) + ViewCompat.setBackground(bubble, bubbleDrawable) + } else { + val bubbleDrawable = DisplayUtils.getMessageSelector( + bgBubbleColor, + resources.getColor(R.color.transparent), + bgBubbleColor, + R.drawable.shape_outcoming_message + ) + ViewCompat.setBackground(bubble, bubbleDrawable) + } + } + + 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 / 1000 + binding.seekbar.progress = currentPosition + } + handler.postDelayed(this, 1000) + } + }) + + 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 / 1000 + + mediaPlayer!!.setOnCompletionListener { + binding.playBtn.visibility = View.VISIBLE + binding.pauseBtn.visibility = View.GONE + binding.seekbar.progress = 0 + 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 exsists", e) + } catch (e: InterruptedException) { + Log.e(TAG, "Error when checking if worker already exsists", e) + } + val data: Data + val downloadWorker: OneTimeWorkRequest + 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() + downloadWorker = 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 -> { + } + } + } + + companion object { + private const val TAG = "VoiceOutMessageView" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index 85cb7b887..5115de760 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -359,7 +359,8 @@ public interface NcApi { Observable createRemoteShare(@Nullable @Header("Authorization") String authorization, @Url String url, @Field("path") String remotePath, @Field("shareWith") String roomToken, - @Field("shareType") String shareType); + @Field("shareType") String shareType, + @Field("talkMetaData") String talkMetaData); @FormUrlEncoded @PUT 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 13975de25..35ab3d2e3 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -24,6 +24,8 @@ package com.nextcloud.talk.controllers +import android.Manifest +import android.annotation.SuppressLint import android.app.Activity.RESULT_OK import android.content.ClipData import android.content.Context @@ -31,11 +33,15 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.Resources import android.graphics.Bitmap -import android.graphics.PorterDuff import android.graphics.drawable.ColorDrawable +import android.media.MediaRecorder import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Handler +import android.os.SystemClock +import android.os.VibrationEffect +import android.os.Vibrator import android.text.Editable import android.text.InputFilter import android.text.TextUtils @@ -46,15 +52,20 @@ import android.view.Gravity import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.MotionEvent import android.view.View +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.LinearInterpolator import android.widget.AbsListView import android.widget.ImageButton import android.widget.ImageView import android.widget.PopupMenu import android.widget.RelativeLayout -import android.widget.Space import android.widget.Toast import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory import androidx.emoji.text.EmojiCompat import androidx.emoji.widget.EmojiTextView @@ -65,7 +76,6 @@ import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import autodagger.AutoInjector -import coil.load import com.bluelinelabs.conductor.RouterTransaction import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler import com.bluelinelabs.conductor.changehandler.VerticalChangeHandler @@ -80,12 +90,14 @@ import com.nextcloud.talk.R import com.nextcloud.talk.activities.MagicCallActivity import com.nextcloud.talk.adapters.messages.IncomingLocationMessageViewHolder import com.nextcloud.talk.adapters.messages.IncomingPreviewMessageViewHolder +import com.nextcloud.talk.adapters.messages.IncomingVoiceMessageViewHolder import com.nextcloud.talk.adapters.messages.MagicIncomingTextMessageViewHolder import com.nextcloud.talk.adapters.messages.MagicOutcomingTextMessageViewHolder import com.nextcloud.talk.adapters.messages.MagicSystemMessageViewHolder import com.nextcloud.talk.adapters.messages.MagicUnreadNoticeMessageViewHolder 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.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication @@ -145,13 +157,17 @@ import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.android.synthetic.main.view_message_input.view.* import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.parceler.Parcels import retrofit2.HttpException import retrofit2.Response +import java.io.File +import java.io.IOException import java.net.HttpURLConnection +import java.text.SimpleDateFormat import java.util.ArrayList import java.util.Date import java.util.HashMap @@ -223,6 +239,10 @@ class ChatController(args: Bundle) : val filesToUpload: MutableList = ArrayList() var sharedText: String + var isVoiceRecordingInProgress: Boolean = false + var currentVoiceRecordFile: String = "" + + private var recorder: MediaRecorder? = null init { setHasOptionsMenu(true) @@ -436,6 +456,15 @@ class ChatController(args: Bundle) : this ) + messageHolders.registerContentType( + CONTENT_TYPE_VOICE_MESSAGE, + IncomingVoiceMessageViewHolder::class.java, + R.layout.item_custom_incoming_voice_message, + OutcomingVoiceMessageViewHolder::class.java, + R.layout.item_custom_outcoming_voice_message, + this + ) + var senderId = "" if (!conversationUser?.userId.equals("?")) { senderId = "users/" + conversationUser?.userId @@ -576,6 +605,119 @@ class ChatController(args: Bundle) : } }) + showMicrophoneButton(true) + + binding.messageInputView.messageInput.doAfterTextChanged { + if (binding.messageInputView.messageInput.text.isEmpty()) { + showMicrophoneButton(true) + } else { + showMicrophoneButton(false) + } + } + + var sliderInitX = 0F + var downX = 0f + var deltaX = 0f + + var voiceRecordStartTime = 0L + var voiceRecordEndTime = 0L + + binding.messageInputView.recordAudioButton.setOnTouchListener(object : View.OnTouchListener { + override fun onTouch(v: View?, event: MotionEvent?): Boolean { + view.performClick() + when (event?.action) { + MotionEvent.ACTION_DOWN -> { + if (!isRecordAudioPermissionGranted()) { + requestRecordAudioPermissions() + return true + } + if (!UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { + UploadAndShareFilesWorker.requestStoragePermission(this@ChatController) + return true + } + + voiceRecordStartTime = System.currentTimeMillis() + + setVoiceRecordFileName() + startAudioRecording(currentVoiceRecordFile) + downX = event.x + showRecordAudioUi(true) + } + MotionEvent.ACTION_CANCEL -> { + Log.d(TAG, "ACTION_CANCEL. same as for UP") + if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) { + return true + } + + stopAndDiscardAudioRecording() + showRecordAudioUi(false) + binding.messageInputView.slideToCancelDescription.x = sliderInitX + } + MotionEvent.ACTION_UP -> { + Log.d(TAG, "ACTION_UP. stop recording??") + if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) { + return true + } + showRecordAudioUi(false) + + voiceRecordEndTime = System.currentTimeMillis() + var voiceRecordDuration = voiceRecordEndTime - voiceRecordStartTime + if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) { + Log.d(TAG, "voiceRecordDuration: " + voiceRecordDuration) + Toast.makeText( + context, + context!!.getString(R.string.nc_voice_message_hold_to_record_info), + Toast.LENGTH_SHORT + ).show() + stopAndDiscardAudioRecording() + return true + } else { + voiceRecordStartTime = 0L + voiceRecordEndTime = 0L + stopAndSendAudioRecording() + } + + binding.messageInputView.slideToCancelDescription.x = sliderInitX + } + MotionEvent.ACTION_MOVE -> { + Log.d(TAG, "ACTION_MOVE.") + + if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) { + return true + } + + showRecordAudioUi(true) + + if (sliderInitX == 0.0F) { + sliderInitX = binding.messageInputView.slideToCancelDescription.x + } + + var movedX: Float = event.x + deltaX = movedX - downX + + // only allow slide to left + if (binding.messageInputView.slideToCancelDescription.x > sliderInitX) { + binding.messageInputView.slideToCancelDescription.x = sliderInitX + } + + if (binding.messageInputView.slideToCancelDescription.x < VOICE_RECORD_CANCEL_SLIDER_X) { + Log.d(TAG, "stopping recording because slider was moved to left") + stopAndDiscardAudioRecording() + showRecordAudioUi(false) + binding.messageInputView.slideToCancelDescription.x = sliderInitX + return true + } else { + binding.messageInputView.slideToCancelDescription.x = binding.messageInputView + .slideToCancelDescription.x + deltaX + downX = movedX + } + } + } + + return v?.onTouchEvent(event) ?: true + } + }) + binding.messageInputView.inputEditText?.setText(sharedText) binding.messageInputView.setAttachmentsListener { activity?.let { AttachmentDialog(it, this).show() } @@ -604,6 +746,143 @@ class ChatController(args: Bundle) : super.onViewBound(view) } + @SuppressLint("SimpleDateFormat") + private fun setVoiceRecordFileName() { + val pattern = "yyyy-MM-dd HH-mm-ss" + val simpleDateFormat = SimpleDateFormat(pattern) + val date: String = simpleDateFormat.format(Date()) + + val fileNameWithoutSuffix = String.format( + context!!.resources.getString(R.string.nc_voice_message_filename), + date, currentConversation!!.displayName + ) + val fileName = fileNameWithoutSuffix + VOICE_MESSAGE_FILE_SUFFIX + + currentVoiceRecordFile = "${context!!.cacheDir.absolutePath}/$fileName" + } + + private fun showRecordAudioUi(show: Boolean) { + if (show) { + binding.messageInputView.microphoneEnabledInfo.visibility = View.VISIBLE + binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.VISIBLE + binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE + binding.messageInputView.slideToCancelDescription.visibility = View.VISIBLE + binding.messageInputView.attachmentButton.visibility = View.GONE + binding.messageInputView.smileyButton.visibility = View.GONE + binding.messageInputView.messageInput.visibility = View.GONE + binding.messageInputView.messageInput.hint = "" + } else { + binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE + binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE + binding.messageInputView.audioRecordDuration.visibility = View.GONE + binding.messageInputView.slideToCancelDescription.visibility = View.GONE + binding.messageInputView.attachmentButton.visibility = View.VISIBLE + binding.messageInputView.smileyButton.visibility = View.VISIBLE + binding.messageInputView.messageInput.visibility = View.VISIBLE + binding.messageInputView.messageInput.hint = + context?.resources?.getString(R.string.nc_hint_enter_a_message) + } + } + + private fun isRecordAudioPermissionGranted(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return PermissionChecker.checkSelfPermission( + context!!, + Manifest.permission.RECORD_AUDIO + ) == PermissionChecker.PERMISSION_GRANTED + } else { + true + } + } + + private fun startAudioRecording(file: String) { + binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime() + binding.messageInputView.audioRecordDuration.start() + + val animation: Animation = AlphaAnimation(1.0f, 0.0f) + animation.duration = 750 + animation.interpolator = LinearInterpolator() + animation.repeatCount = Animation.INFINITE + animation.repeatMode = Animation.REVERSE + binding.messageInputView.microphoneEnabledInfo.startAnimation(animation) + + recorder = MediaRecorder().apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFile(file) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + + try { + prepare() + } catch (e: IOException) { + Log.e(TAG, "prepare for audio recording failed") + } + + try { + start() + isVoiceRecordingInProgress = true + } catch (e: IllegalStateException) { + Log.e(TAG, "start for audio recording failed") + } + + vibrate() + } + } + + private fun stopAndSendAudioRecording() { + stopAudioRecording() + val uri = Uri.fromFile(File(currentVoiceRecordFile)) + uploadFiles(mutableListOf(uri.toString()), true) + } + + private fun stopAndDiscardAudioRecording() { + stopAudioRecording() + + val cachedFile = File(currentVoiceRecordFile) + cachedFile.delete() + } + + private fun stopAudioRecording() { + binding.messageInputView.audioRecordDuration.stop() + binding.messageInputView.microphoneEnabledInfo.clearAnimation() + + if (isVoiceRecordingInProgress) { + recorder?.apply { + try { + stop() + release() + isVoiceRecordingInProgress = false + Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false") + } catch (e: RuntimeException) { + Log.w(TAG, "error while stopping recorder!") + } + + vibrate() + } + recorder = null + } else { + Log.e(TAG, "tried to stop audio recorder but it was not recording") + } + } + + fun vibrate() { + val vibrator = context?.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + if (Build.VERSION.SDK_INT >= 26) { + vibrator.vibrate(VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + vibrator.vibrate(20) + } + } + + private fun requestRecordAudioPermissions() { + requestPermissions( + arrayOf( + Manifest.permission.RECORD_AUDIO + ), + REQUEST_RECORD_AUDIO_PERMISSION + ) + } + private fun checkReadOnlyState() { if (currentConversation != null && isAlive()) { if (currentConversation?.shouldShowLobby(conversationUser) ?: false || @@ -722,7 +1001,7 @@ class ChatController(args: Bundle) : .setMessage(filenamesWithLinebreaks.toString()) .setPositiveButton(R.string.nc_yes) { v -> if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { - uploadFiles(filesToUpload) + uploadFiles(filesToUpload, false) } else { UploadAndShareFilesWorker.requestStoragePermission(this) } @@ -743,18 +1022,32 @@ class ChatController(args: Bundle) : } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - if (requestCode == UploadAndShareFilesWorker.REQUEST_PERMISSION && - grantResults.isNotEmpty() && - grantResults[0] == PackageManager.PERMISSION_GRANTED - ) { - Log.d(ConversationsListController.TAG, "upload starting after permissions were granted") - uploadFiles(filesToUpload) - } else { - Toast.makeText(context, context?.getString(R.string.read_storage_no_permission), Toast.LENGTH_LONG).show() + if (requestCode == UploadAndShareFilesWorker.REQUEST_PERMISSION) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Log.d(ConversationsListController.TAG, "upload starting after permissions were granted") + if (filesToUpload.isNotEmpty()) { + uploadFiles(filesToUpload, false) + } + } else { + Toast.makeText(context, context?.getString(R.string.read_storage_no_permission), Toast.LENGTH_LONG) + .show() + } + } else if (requestCode == REQUEST_RECORD_AUDIO_PERMISSION) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // do nothing. user will tap on the microphone again if he wants to record audio.. + } else { + Toast.makeText(context, context!!.getString(R.string.nc_voice_message_missing_audio_permission), Toast + .LENGTH_LONG).show() + } } } - private fun uploadFiles(files: MutableList) { + private fun uploadFiles(files: MutableList, isVoiceMessage: Boolean) { + var metaData = "" + if (isVoiceMessage) { + metaData = VOICE_MESSAGE_META_DATA + } + try { require(files.isNotEmpty()) val data: Data = Data.Builder() @@ -764,16 +1057,19 @@ class ChatController(args: Bundle) : CapabilitiesUtil.getAttachmentFolder(conversationUser) ) .putString(UploadAndShareFilesWorker.ROOM_TOKEN, roomToken) + .putString(UploadAndShareFilesWorker.META_DATA, metaData) .build() val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java) .setInputData(data) .build() WorkManager.getInstance().enqueue(uploadWorker) - Toast.makeText( - context, context?.getString(R.string.nc_upload_in_progess), - Toast.LENGTH_LONG - ).show() + if (!isVoiceMessage) { + Toast.makeText( + context, context?.getString(R.string.nc_upload_in_progess), + Toast.LENGTH_LONG + ).show() + } } catch (e: IllegalArgumentException) { Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG).show() Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) @@ -882,16 +1178,12 @@ class ChatController(args: Bundle) : emojiPopup = binding.messageInputView.inputEditText?.let { EmojiPopup.Builder.fromRootView(view).setOnEmojiPopupShownListener { if (resources != null) { - smileyButton?.setColorFilter( - resources!!.getColor(R.color.colorPrimary), - PorterDuff.Mode.SRC_IN - ) + smileyButton?.setImageDrawable( + ContextCompat.getDrawable(context!!, R.drawable.ic_baseline_keyboard_24)) } }.setOnEmojiPopupDismissListener { - smileyButton?.setColorFilter( - resources!!.getColor(R.color.emoji_icons), - PorterDuff.Mode.SRC_IN - ) + smileyButton?.setImageDrawable( + ContextCompat.getDrawable(context!!, R.drawable.ic_insert_emoticon_black_24dp)) }.setOnEmojiClickListener { emoji, imageView -> binding.messageInputView.inputEditText?.editableText?.append(" ") @@ -924,7 +1216,6 @@ class ChatController(args: Bundle) : private fun cancelReply() { binding.messageInputView.findViewById(R.id.quotedChatMessageView)?.visibility = View.GONE binding.messageInputView.findViewById(R.id.attachmentButton)?.visibility = View.VISIBLE - binding.messageInputView.findViewById(R.id.attachmentButtonSpace)?.visibility = View.VISIBLE } private fun cancelNotificationsForCurrentConversation() { @@ -1216,6 +1507,7 @@ class ChatController(args: Bundle) : } }) } + showMicrophoneButton(true) } private fun setupWebsocket() { @@ -1817,8 +2109,6 @@ class ChatController(args: Bundle) : chatMessage?.let { binding.messageInputView.findViewById(R.id.attachmentButton)?.visibility = View.GONE - binding.messageInputView.findViewById(R.id.attachmentButtonSpace)?.visibility = - View.GONE binding.messageInputView.findViewById(R.id.cancelReplyButton)?.visibility = View.VISIBLE @@ -1868,6 +2158,16 @@ class ChatController(args: Bundle) : } } + private fun showMicrophoneButton(show: Boolean) { + if (show && CapabilitiesUtil.hasSpreedFeatureCapability(conversationUser, "voice-message-sharing")) { + binding.messageInputView.messageSendButton.visibility = View.GONE + binding.messageInputView.recordAudioButton.visibility = View.VISIBLE + } else { + binding.messageInputView.messageSendButton.visibility = View.VISIBLE + binding.messageInputView.recordAudioButton.visibility = View.GONE + } + } + private fun setMessageAsDeleted(message: IMessage?) { val messageTemp = message as ChatMessage messageTemp.isDeleted = true @@ -1910,7 +2210,8 @@ class ChatController(args: Bundle) : override fun hasContentFor(message: ChatMessage, type: Byte): Boolean { return when (type) { - CONTENT_TYPE_LOCATION -> return message.isLocationMessage() + CONTENT_TYPE_LOCATION -> return message.hasGeoLocation() + CONTENT_TYPE_VOICE_MESSAGE -> return message.isVoiceMessage() CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage) CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == "-1" else -> false @@ -2017,6 +2318,7 @@ class ChatController(args: Bundle) : private const val CONTENT_TYPE_SYSTEM_MESSAGE: Byte = 1 private const val CONTENT_TYPE_UNREAD_NOTICE_MESSAGE: Byte = 2 private const val CONTENT_TYPE_LOCATION: Byte = 3 + private const val CONTENT_TYPE_VOICE_MESSAGE: Byte = 4 private const val NEW_MESSAGES_POPUP_BUBBLE_DELAY: Long = 200 private const val POP_CURRENT_CONTROLLER_DELAY: Long = 100 private const val LOBBY_TIMER_DELAY: Long = 5000 @@ -2024,6 +2326,11 @@ class ChatController(args: Bundle) : private const val MESSAGE_MAX_LENGTH: Int = 1000 private const val AGE_THREHOLD_FOR_DELETE_MESSAGE: Int = 21600000 // (6 hours in millis = 6 * 3600 * 1000) private const val REQUEST_CODE_CHOOSE_FILE: Int = 555 + private const val REQUEST_RECORD_AUDIO_PERMISSION = 222 private const val OBJECT_MESSAGE: String = "{object}" + private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000 + private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -50 + private const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}" + private const val VOICE_MESSAGE_FILE_SUFFIX = ".mp3" } } diff --git a/app/src/main/java/com/nextcloud/talk/interfaces/ExtendedIMessage.kt b/app/src/main/java/com/nextcloud/talk/interfaces/ExtendedIMessage.kt deleted file mode 100644 index a494eeded..000000000 --- a/app/src/main/java/com/nextcloud/talk/interfaces/ExtendedIMessage.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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.interfaces - -import com.stfalcon.chatkit.commons.models.IMessage - -interface ExtendedIMessage : IMessage { - - fun isLocationMessage(): Boolean -} diff --git a/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.java index 908fcebc0..ee99a3cde 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.java @@ -21,38 +21,44 @@ package com.nextcloud.talk.jobs; import android.content.Context; -import androidx.annotation.NonNull; -import androidx.work.Data; -import androidx.work.Worker; -import androidx.work.WorkerParameters; -import autodagger.AutoInjector; +import android.util.Log; + import com.nextcloud.talk.api.NcApi; import com.nextcloud.talk.application.NextcloudTalkApplication; import com.nextcloud.talk.models.database.UserEntity; import com.nextcloud.talk.utils.ApiUtils; import com.nextcloud.talk.utils.bundle.BundleKeys; import com.nextcloud.talk.utils.database.user.UserUtils; -import io.reactivex.Observer; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; -import javax.inject.Inject; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.work.Data; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import autodagger.AutoInjector; +import io.reactivex.Observer; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + @AutoInjector(NextcloudTalkApplication.class) public class ShareOperationWorker extends Worker { @Inject UserUtils userUtils; @Inject NcApi ncApi; + private final String TAG = "ShareOperationWorker"; private long userId; private UserEntity operationsUser; private String roomToken; private List filesArray = new ArrayList<>(); private String credentials; private String baseUrl; + private String metaData; public ShareOperationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { super(context, workerParams); @@ -60,6 +66,7 @@ public class ShareOperationWorker extends Worker { Data data = workerParams.getInputData(); userId = data.getLong(BundleKeys.INSTANCE.getKEY_INTERNAL_USER_ID(), 0); roomToken = data.getString(BundleKeys.INSTANCE.getKEY_ROOM_TOKEN()); + metaData = data.getString(BundleKeys.INSTANCE.getKEY_META_DATA()); Collections.addAll(filesArray, data.getStringArray(BundleKeys.INSTANCE.getKEY_FILE_PATHS())); operationsUser = userUtils.getUserWithId(userId); credentials = ApiUtils.getCredentials(operationsUser.getUsername(), operationsUser.getToken()); @@ -70,12 +77,14 @@ public class ShareOperationWorker extends Worker { @NonNull @Override public Result doWork() { + for (int i = 0; i < filesArray.size(); i++) { ncApi.createRemoteShare(credentials, ApiUtils.getSharingUrl(baseUrl), filesArray.get(i), roomToken, - "10") + "10", + metaData) .subscribeOn(Schedulers.io()) .blockingSubscribe(new Observer() { @Override @@ -90,7 +99,7 @@ public class ShareOperationWorker extends Worker { @Override public void onError(Throwable e) { - + Log.w(TAG, "error while creating RemoteShare", e); } @Override diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index e9099c2a3..f1aab97a7 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -41,6 +41,7 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.UriUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_PATHS import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_META_DATA import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.database.user.UserUtils import com.nextcloud.talk.utils.preferences.AppPreferences @@ -88,6 +89,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa val sourcefiles = inputData.getStringArray(DEVICE_SOURCEFILES) val ncTargetpath = inputData.getString(NC_TARGETPATH) val roomToken = inputData.getString(ROOM_TOKEN) + val metaData = inputData.getString(META_DATA) checkNotNull(currentUser) checkNotNull(sourcefiles) @@ -99,7 +101,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa val sourcefileUri = Uri.parse(sourcefiles[index]) val filename = UriUtils.getFileName(sourcefileUri, context) val requestBody = createRequestBody(sourcefileUri) - uploadFile(currentUser, ncTargetpath, filename, roomToken, requestBody, sourcefileUri) + uploadFile(currentUser, ncTargetpath, filename, roomToken, requestBody, sourcefileUri, metaData) } } catch (e: IllegalStateException) { Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) @@ -130,7 +132,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa filename: String, roomToken: String?, requestBody: RequestBody?, - sourcefileUri: Uri + sourcefileUri: Uri, + metaData: String? ) { ncApi.uploadFile( ApiUtils.getCredentials(currentUser.username, currentUser.token), @@ -151,7 +154,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } override fun onComplete() { - shareFile(roomToken, currentUser, ncTargetpath, filename) + shareFile(roomToken, currentUser, ncTargetpath, filename, metaData) copyFileToCache(sourcefileUri, filename) } }) @@ -159,17 +162,28 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private fun copyFileToCache(sourceFileUri: Uri, filename: String) { val cachedFile = File(context.cacheDir, filename) - val outputStream = FileOutputStream(cachedFile) - val inputStream: InputStream = context.contentResolver.openInputStream(sourceFileUri)!! - inputStream.use { input -> - outputStream.use { output -> - input.copyTo(output) + if (cachedFile.exists()) { + Log.d(TAG, "file is already in cache") + } else { + val outputStream = FileOutputStream(cachedFile) + val inputStream: InputStream = context.contentResolver.openInputStream(sourceFileUri)!! + + inputStream.use { input -> + outputStream.use { output -> + input.copyTo(output) + } } } } - private fun shareFile(roomToken: String?, currentUser: UserEntity, ncTargetpath: String?, filename: String?) { + private fun shareFile( + roomToken: String?, + currentUser: UserEntity, + ncTargetpath: String?, + filename: String?, + metaData: String?) { + val paths: MutableList = ArrayList() paths.add("$ncTargetpath/$filename") @@ -177,6 +191,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa .putLong(KEY_INTERNAL_USER_ID, currentUser.id) .putString(KEY_ROOM_TOKEN, roomToken) .putStringArray(KEY_FILE_PATHS, paths.toTypedArray()) + .putString(KEY_META_DATA, metaData) .build() val shareWorker = OneTimeWorkRequest.Builder(ShareOperationWorker::class.java) .setInputData(data) @@ -190,6 +205,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa const val DEVICE_SOURCEFILES = "DEVICE_SOURCEFILES" const val NC_TARGETPATH = "NC_TARGETPATH" const val ROOM_TOKEN = "ROOM_TOKEN" + const val META_DATA = "META_DATA" fun isStoragePermissionGranted(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 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 a328fdf53..e9a130e07 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 @@ -26,7 +26,6 @@ import com.bluelinelabs.logansquare.annotation.JsonIgnore; import com.bluelinelabs.logansquare.annotation.JsonObject; import com.nextcloud.talk.R; import com.nextcloud.talk.application.NextcloudTalkApplication; -import com.nextcloud.talk.interfaces.ExtendedIMessage; import com.nextcloud.talk.models.database.UserEntity; import com.nextcloud.talk.models.json.converters.EnumSystemMessageTypeConverter; import com.nextcloud.talk.utils.ApiUtils; @@ -49,7 +48,7 @@ import kotlin.text.Charsets; @Parcel @JsonObject -public class ChatMessage implements ExtendedIMessage, MessageContentType, MessageContentType.Image { +public class ChatMessage implements MessageContentType, MessageContentType.Image { @JsonIgnore public boolean isGrouped; @JsonIgnore @@ -88,6 +87,8 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag @JsonField(name = "parent") public ChatMessage parentMessage; public Enum readStatus = ReadStatus.NONE; + @JsonField(name = "messageType") + public String messageType; @JsonIgnore List messageTypesToIgnore = Arrays.asList( @@ -96,7 +97,8 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag MessageType.SINGLE_LINK_VIDEO_MESSAGE, MessageType.SINGLE_LINK_AUDIO_MESSAGE, MessageType.SINGLE_LINK_MESSAGE, - MessageType.SINGLE_NC_GEOLOCATION_MESSAGE); + MessageType.SINGLE_NC_GEOLOCATION_MESSAGE, + MessageType.VOICE_MESSAGE); public boolean hasFileAttachment() { if (messageParameters != null && messageParameters.size() > 0) { @@ -112,7 +114,7 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag return false; } - private boolean hasGeoLocation() { + public boolean hasGeoLocation() { if (messageParameters != null && messageParameters.size() > 0) { for (HashMap.Entry> entry : messageParameters.entrySet()) { Map individualHashMap = entry.getValue(); @@ -131,6 +133,8 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag @Nullable @Override public String getImageUrl() { + + if (messageParameters != null && messageParameters.size() > 0) { for (HashMap.Entry> entry : messageParameters.entrySet()) { Map individualHashMap = entry.getValue(); @@ -138,8 +142,10 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag Objects.requireNonNull(individualHashMap.get("type")).getBytes(Charsets.UTF_8), ("file").getBytes(Charsets.UTF_8))) { selectedIndividualHashMap = individualHashMap; - return (ApiUtils.getUrlForFilePreviewWithFileId(getActiveUser().getBaseUrl(), - individualHashMap.get("id"), NextcloudTalkApplication.Companion.getSharedApplication().getResources().getDimensionPixelSize(R.dimen.maximum_file_preview_size))); + if(!isVoiceMessage()){ + return (ApiUtils.getUrlForFilePreviewWithFileId(getActiveUser().getBaseUrl(), + individualHashMap.get("id"), NextcloudTalkApplication.Companion.getSharedApplication().getResources().getDimensionPixelSize(R.dimen.maximum_file_preview_size))); + } } } } @@ -156,6 +162,10 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag return MessageType.SYSTEM_MESSAGE; } + if (isVoiceMessage()){ + return MessageType.VOICE_MESSAGE; + } + if (hasFileAttachment()) { return MessageType.SINGLE_NC_ATTACHMENT_MESSAGE; } @@ -213,6 +223,13 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag return (String.format(NextcloudTalkApplication.Companion.getSharedApplication().getResources().getString(R.string.nc_sent_location), !TextUtils.isEmpty(getActorDisplayName()) ? getActorDisplayName() : NextcloudTalkApplication.Companion.getSharedApplication().getString(R.string.nc_guest))); } + } else if (MessageType.VOICE_MESSAGE == getMessageType()) { + if (getActorId().equals(getActiveUser().getUserId())) { + return (NextcloudTalkApplication.Companion.getSharedApplication().getString(R.string.nc_sent_voice_you)); + } else { + return (String.format(NextcloudTalkApplication.Companion.getSharedApplication().getResources().getString(R.string.nc_sent_voice), + !TextUtils.isEmpty(getActorDisplayName()) ? getActorDisplayName() : NextcloudTalkApplication.Companion.getSharedApplication().getString(R.string.nc_guest))); + } /*} else if (getMessageType().equals(MessageType.SINGLE_LINK_MESSAGE)) { if (getActorId().equals(getActiveUser().getUserId())) { return (NextcloudTalkApplication.Companion.getSharedApplication().getString(R.string.nc_sent_a_link_you)); @@ -437,6 +454,10 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag this.messageTypesToIgnore = messageTypesToIgnore; } + public void setMessageType(String messageType) { + this.messageType = messageType; + } + public boolean equals(final Object o) { if (o == this) { return true; @@ -576,9 +597,8 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag return "ChatMessage(isGrouped=" + this.isGrouped() + ", isOneToOneConversation=" + this.isOneToOneConversation() + ", activeUser=" + this.getActiveUser() + ", selectedIndividualHashMap=" + this.getSelectedIndividualHashMap() + ", isLinkPreviewAllowed=" + this.isLinkPreviewAllowed() + ", isDeleted=" + this.isDeleted() + ", jsonMessageId=" + this.getJsonMessageId() + ", token=" + this.getToken() + ", actorType=" + this.getActorType() + ", actorId=" + this.getActorId() + ", actorDisplayName=" + this.getActorDisplayName() + ", timestamp=" + this.getTimestamp() + ", message=" + this.getMessage() + ", messageParameters=" + this.getMessageParameters() + ", systemMessageType=" + this.getSystemMessageType() + ", replyable=" + this.isReplyable() + ", parentMessage=" + this.getParentMessage() + ", readStatus=" + this.getReadStatus() + ", messageTypesToIgnore=" + this.getMessageTypesToIgnore() + ")"; } - @Override - public boolean isLocationMessage() { - return hasGeoLocation(); + public boolean isVoiceMessage(){ + return "voice-message".equals(messageType); } public enum MessageType { @@ -593,6 +613,7 @@ public class ChatMessage implements ExtendedIMessage, MessageContentType, Messag SINGLE_LINK_AUDIO_MESSAGE, SINGLE_NC_ATTACHMENT_MESSAGE, SINGLE_NC_GEOLOCATION_MESSAGE, + VOICE_MESSAGE } public enum SystemMessageType { diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt index bc66d2691..a262c75e8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt @@ -67,4 +67,5 @@ object BundleKeys { val KEY_NOTIFICATION_ID = "KEY_NOTIFICATION_ID" val KEY_SHARED_TEXT = "KEY_SHARED_TEXT" val KEY_GEOCODING_QUERY = "KEY_GEOCODING_QUERY" + val KEY_META_DATA = "KEY_META_DATA" } diff --git a/app/src/main/res/drawable/ic_baseline_attach_file_24.xml b/app/src/main/res/drawable/ic_baseline_attach_file_24.xml new file mode 100644 index 000000000..a39602244 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_attach_file_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_attachment_24.xml b/app/src/main/res/drawable/ic_baseline_attachment_24.xml new file mode 100644 index 000000000..52c055b99 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_attachment_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_24.xml new file mode 100644 index 000000000..533fc1562 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_mic_24.xml b/app/src/main/res/drawable/ic_baseline_mic_24.xml new file mode 100644 index 000000000..2e7944607 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_mic_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_mic_red_24.xml b/app/src/main/res/drawable/ic_baseline_mic_red_24.xml new file mode 100644 index 000000000..182c7ccf8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_mic_red_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_pause_voice_message_24.xml b/app/src/main/res/drawable/ic_baseline_pause_voice_message_24.xml new file mode 100644 index 000000000..f3c9c9a86 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_pause_voice_message_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_play_arrow_voice_message_24.xml b/app/src/main/res/drawable/ic_baseline_play_arrow_voice_message_24.xml new file mode 100644 index 000000000..bfa4697e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_play_arrow_voice_message_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/voice_message_outgoing_seek_bar_ruler.xml b/app/src/main/res/drawable/voice_message_outgoing_seek_bar_ruler.xml new file mode 100644 index 000000000..71f287c2a --- /dev/null +++ b/app/src/main/res/drawable/voice_message_outgoing_seek_bar_ruler.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/voice_message_outgoing_seek_bar_slider.xml b/app/src/main/res/drawable/voice_message_outgoing_seek_bar_slider.xml new file mode 100644 index 000000000..6670c4974 --- /dev/null +++ b/app/src/main/res/drawable/voice_message_outgoing_seek_bar_slider.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/controller_chat.xml b/app/src/main/res/layout/controller_chat.xml index 4f7d8e159..748d1a89b 100644 --- a/app/src/main/res/layout/controller_chat.xml +++ b/app/src/main/res/layout/controller_chat.xml @@ -76,18 +76,19 @@ android:layout_alignParentBottom="true" android:inputType="textLongMessage|textAutoComplete" android:maxLength="1000" - app:attachmentButtonDefaultBgColor="@color/colorPrimary" - app:attachmentButtonDefaultIconColor="@color/white" - app:attachmentButtonHeight="36dp" - app:attachmentButtonWidth="36dp" + app:showAttachmentButton="true" + app:attachmentButtonHeight="28dp" + app:attachmentButtonWidth="28dp" + app:attachmentButtonIcon="@drawable/ic_baseline_attach_file_24" + app:attachmentButtonBackground="@color/transparent" app:inputButtonDefaultBgColor="@color/colorPrimary" - app:inputButtonHeight="36dp" + app:inputButtonHeight="35dp" app:inputButtonMargin="8dp" app:inputButtonWidth="36dp" app:inputHint="@string/nc_hint_enter_a_message" app:inputTextColor="@color/nc_incoming_text_default" app:inputTextSize="16sp" - app:showAttachmentButton="true" /> + app:delayTypingStatus="200"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 000000000..86b791717 --- /dev/null +++ b/app/src/main/res/layout/item_custom_outcoming_voice_message.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_message_input.xml b/app/src/main/res/layout/view_message_input.xml index 5aef7d352..3019b054a 100644 --- a/app/src/main/res/layout/view_message_input.xml +++ b/app/src/main/res/layout/view_message_input.xml @@ -20,64 +20,143 @@ - + + + + + + - + + + + android:layout_alignParentStart="true" + android:background="@color/bg_default" + android:visibility="gone" + tools:visibility="visible" + android:contentDescription="@null" /> - + + android:scaleType="centerInside" + android:layout_alignParentStart="true" + android:src="@drawable/ic_baseline_mic_red_24" + android:contentDescription="@null" + android:visibility="gone" + tools:visibility="visible"/> + + + + + android:layout_toEndOf="@id/attachmentButton" + android:visibility="gone"/> #D7D7D7 #B4B4B4 + + + #606060 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d75ee0f50..88fd8d550 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,6 +276,7 @@ %1$s sent a video. %1$s sent an image. %1$s sent a location. + %1$s sent a voice message. You sent a link. You sent a GIF. You sent an attachment. @@ -283,6 +284,7 @@ You sent a video. You sent an image. You sent a location. + You sent a voice message. %1$s: %2$s Cancel reply @@ -380,6 +382,15 @@ Share this location Your current location + + Talk recording from %1$s (%2$s) + Hold to record, release to send. + Record voice + << Slide to cancel + Play voice message + Pause voice message + Permission for audio recording is required + phone_book_integration Match contacts based on phone number to integrate Talk shortcut into system contacts app From 7709967712384248c6f8eea5b96f81253521e14c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 21 Jun 2021 12:16:46 +0200 Subject: [PATCH 02/13] fix imports Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/controllers/ChatController.kt | 2 ++ 1 file changed, 2 insertions(+) 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 35ab3d2e3..d6f4916db 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -67,6 +67,7 @@ import androidx.appcompat.view.ContextThemeWrapper import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import androidx.core.widget.doAfterTextChanged import androidx.emoji.text.EmojiCompat import androidx.emoji.widget.EmojiTextView import androidx.recyclerview.widget.ItemTouchHelper @@ -76,6 +77,7 @@ import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import autodagger.AutoInjector +import coil.load import com.bluelinelabs.conductor.RouterTransaction import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler import com.bluelinelabs.conductor.changehandler.VerticalChangeHandler From edc4f657805f96d2f42c2ad44af5b4a26dd2772e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 21 Jun 2021 18:21:40 +0200 Subject: [PATCH 03/13] remove "portrait-only" for MainActivity Signed-off-by: Marcel Hibbe --- app/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 192166c78..acca95c7a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -94,7 +94,6 @@ From db68b4e93dd9319488ca772872b5ad6d04953f1a Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 21 Jun 2021 21:56:20 +0200 Subject: [PATCH 04/13] improve voice message UI Signed-off-by: Andy Scherzinger --- .../OutcomingVoiceMessageViewHolder.kt | 2 +- .../voice_message_outgoing_seek_bar_ruler.xml | 9 ----- ...voice_message_outgoing_seek_bar_slider.xml | 22 ++++++++++- .../item_custom_incoming_voice_message.xml | 38 +++++++++++-------- ...item_custom_outcoming_location_message.xml | 2 +- .../item_custom_outcoming_voice_message.xml | 37 ++++++++++-------- app/src/main/res/values/styles.xml | 14 +++++++ 7 files changed, 82 insertions(+), 42 deletions(-) delete mode 100644 app/src/main/res/drawable/voice_message_outgoing_seek_bar_ruler.xml 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 d67e39b12..082d45bdc 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 @@ -89,7 +89,7 @@ class OutcomingVoiceMessageViewHolder(incomingView: View) : MessageHolders colorizeMessageBubble(message) itemView.isSelected = false - binding.messageTime.setTextColor(context?.resources!!.getColor(R.color.warm_grey_four)) + binding.messageTime.setTextColor(context!!.resources.getColor(R.color.white60)) // parent message handling setParentMessageDataOnMessageItem(message) diff --git a/app/src/main/res/drawable/voice_message_outgoing_seek_bar_ruler.xml b/app/src/main/res/drawable/voice_message_outgoing_seek_bar_ruler.xml deleted file mode 100644 index 71f287c2a..000000000 --- a/app/src/main/res/drawable/voice_message_outgoing_seek_bar_ruler.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/voice_message_outgoing_seek_bar_slider.xml b/app/src/main/res/drawable/voice_message_outgoing_seek_bar_slider.xml index 6670c4974..150b12200 100644 --- a/app/src/main/res/drawable/voice_message_outgoing_seek_bar_slider.xml +++ b/app/src/main/res/drawable/voice_message_outgoing_seek_bar_slider.xml @@ -1,9 +1,29 @@ + + - + 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 ad150f773..00c6fbe1f 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 @@ -24,6 +24,7 @@ - - + app:cornerRadius="@dimen/button_corner_radius" + app:icon="@drawable/ic_baseline_play_arrow_voice_message_24" + app:iconSize="40dp" + app:iconTint="@color/nc_incoming_text_default" /> - - + app:cornerRadius="@dimen/button_corner_radius" + app:icon="@drawable/ic_baseline_pause_voice_message_24" + app:iconSize="40dp" + app:iconTint="@color/nc_incoming_text_default" /> - + android:layout_height="wrap_content" /> @@ -109,7 +116,8 @@ android:layout_height="wrap_content" android:layout_below="@id/messageText" android:layout_marginStart="8dp" - app:layout_alignSelf="center" /> + app:layout_alignSelf="center" + tools:text="12:38"/> diff --git a/app/src/main/res/layout/item_custom_outcoming_location_message.xml b/app/src/main/res/layout/item_custom_outcoming_location_message.xml index 2fc454916..076001dc4 100644 --- a/app/src/main/res/layout/item_custom_outcoming_location_message.xml +++ b/app/src/main/res/layout/item_custom_outcoming_location_message.xml @@ -59,7 +59,7 @@ android:lineSpacingMultiplier="1.2" android:textColorHighlight="@color/nc_grey" android:textIsSelectable="true" - tools:text="Talk to ayou later!" /> + tools:text="Talk to you later!" /> + ~ Copyright (C) 2021 Marcel Hibbe ~ Copyright (C) 2017-2018 Mario Danic ~ ~ This program is free software: you can redistribute it and/or modify @@ -60,32 +62,37 @@ android:progressTint="@color/fontAppbar" android:visibility="gone"/> - - + app:cornerRadius="@dimen/button_corner_radius" + app:icon="@drawable/ic_baseline_play_arrow_voice_message_24" + app:iconSize="40dp" + app:iconTint="@color/nc_outcoming_text_default" /> - - + app:cornerRadius="@dimen/button_corner_radius" + app:icon="@drawable/ic_baseline_pause_voice_message_24" + app:iconSize="40dp" + app:iconTint="@color/nc_outcoming_text_default" /> - + tools:progress="50" /> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d584c98d9..053868d7f 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -177,4 +177,18 @@ sans bold + + + + + From 19dcd8267a520bc20aa3ba3859ce69abfdee938d Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 21 Jun 2021 22:31:11 +0200 Subject: [PATCH 05/13] replace deprecated method calls Signed-off-by: Andy Scherzinger --- .../IncomingVoiceMessageViewHolder.kt | 14 ++++--- .../MagicOutcomingTextMessageViewHolder.kt | 37 ++++++++++--------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt index 029380b41..16fa75891 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt @@ -37,6 +37,8 @@ import android.view.View import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat import androidx.work.Data import androidx.work.OneTimeWorkRequest @@ -94,7 +96,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View) : MessageHolders colorizeMessageBubble(message) itemView.isSelected = false - binding.messageTime.setTextColor(context?.resources!!.getColor(R.color.warm_grey_four)) + binding.messageTime.setTextColor(ResourcesCompat.getColor(context?.resources!!, R.color.warm_grey_four, null)) // parent message handling setParentMessageDataOnMessageItem(message) @@ -172,7 +174,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View) : MessageHolders .endConfig() .buildRound( ">", - context!!.resources.getColor(R.color.black) + ResourcesCompat.getColor(context!!.resources, R.color.black, null) ) binding.messageUserAvatar.visibility = View.VISIBLE binding.messageUserAvatar.setImageDrawable(drawable) @@ -197,13 +199,13 @@ class IncomingVoiceMessageViewHolder(incomingView: View) : MessageHolders } val bgBubbleColor = if (message.isDeleted) { - resources.getColor(R.color.bg_message_list_incoming_bubble_deleted) + ResourcesCompat.getColor(resources, R.color.bg_message_list_incoming_bubble_deleted, null) } else { - resources.getColor(R.color.bg_message_list_incoming_bubble) + ResourcesCompat.getColor(resources, R.color.bg_message_list_incoming_bubble, null) } val bubbleDrawable = DisplayUtils.getMessageSelector( bgBubbleColor, - resources.getColor(R.color.transparent), + ResourcesCompat.getColor(resources, R.color.transparent, null), bgBubbleColor, bubbleResource ) ViewCompat.setBackground(bubble, bubbleDrawable) @@ -229,7 +231,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View) : MessageHolders binding.messageQuote.quotedMessage.text = parentChatMessage.text binding.messageQuote.quotedMessageAuthor - .setTextColor(context!!.resources.getColor(R.color.textColorMaxContrast)) + .setTextColor(ContextCompat.getColor(context!!, R.color.textColorMaxContrast)) if (parentChatMessage.actorId?.equals(message.activeUser.userId) == true) { binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.colorPrimary) diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/MagicOutcomingTextMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/MagicOutcomingTextMessageViewHolder.kt index 520e20294..9d04a33d7 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/MagicOutcomingTextMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/MagicOutcomingTextMessageViewHolder.kt @@ -2,8 +2,10 @@ * Nextcloud Talk application * * @author Mario Danic + * @author Marcel Hibbe * @author Andy Scherzinger * Copyright (C) 2021 Andy Scherzinger + * Copyright (C) 2021 Marcel Hibbe * Copyright (C) 2017-2018 Mario Danic * * This program is free software: you can redistribute it and/or modify @@ -29,6 +31,8 @@ import android.text.Spannable import android.text.SpannableString import android.util.TypedValue import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat import autodagger.AutoInjector import coil.load @@ -71,9 +75,9 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage for (key in messageParameters.keys) { val individualHashMap: HashMap? = message.messageParameters[key] if (individualHashMap != null) { - if (individualHashMap["type"] == "user" || ( - individualHashMap["type"] == "guest" - ) || individualHashMap["type"] == "call" + if (individualHashMap["type"] == "user" || + individualHashMap["type"] == "guest" || + individualHashMap["type"] == "call" ) { messageString = searchAndReplaceWithMentionSpan( binding.messageText.context, @@ -85,31 +89,30 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage R.xml.chip_others ) } else if (individualHashMap["type"] == "file") { - realView.setOnClickListener( - View.OnClickListener { v: View? -> - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"])) - context!!.startActivity(browserIntent) - } - ) + realView.setOnClickListener { v: View? -> + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(individualHashMap["link"])) + context!!.startActivity(browserIntent) + } } } } } else if (TextMatchers.isMessageWithSingleEmoticonOnly(message.text)) { textSize = (textSize * 2.5).toFloat() layoutParams.isWrapBefore = true - binding.messageTime.setTextColor(context!!.resources.getColor(R.color.warm_grey_four)) + binding.messageTime.setTextColor( + ResourcesCompat.getColor(context!!.resources, R.color.warm_grey_four, null)) realView.isSelected = true } val resources = sharedApplication!!.resources val bgBubbleColor = if (message.isDeleted) { - resources.getColor(R.color.bg_message_list_outcoming_bubble_deleted) + ResourcesCompat.getColor(resources, R.color.bg_message_list_outcoming_bubble_deleted, null) } else { - resources.getColor(R.color.bg_message_list_outcoming_bubble) + ResourcesCompat.getColor(resources, R.color.bg_message_list_outcoming_bubble, null) } if (message.isGrouped) { val bubbleDrawable = getMessageSelector( bgBubbleColor, - resources.getColor(R.color.transparent), + ResourcesCompat.getColor(resources, R.color.transparent, null), bgBubbleColor, R.drawable.shape_grouped_outcoming_message ) @@ -117,7 +120,7 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage } else { val bubbleDrawable = getMessageSelector( bgBubbleColor, - resources.getColor(R.color.transparent), + ResourcesCompat.getColor(resources, R.color.transparent, null), bgBubbleColor, R.drawable.shape_outcoming_message ) @@ -130,7 +133,7 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage // parent message handling if (!message.isDeleted && message.parentMessage != null) { - var parentChatMessage = message.parentMessage + val parentChatMessage = message.parentMessage parentChatMessage.activeUser = message.activeUser parentChatMessage.imageUrl?.let { binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE @@ -171,8 +174,8 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage } readStatusDrawableInt?.let { drawableInt -> - context?.resources?.getDrawable(drawableInt, null)?.let { - it.setColorFilter(context?.resources!!.getColor(R.color.white60), PorterDuff.Mode.SRC_ATOP) + ContextCompat.getDrawable(context!!, drawableInt)?.let { + it.setColorFilter(ContextCompat.getColor(context!!, R.color.white60), PorterDuff.Mode.SRC_ATOP) binding.checkMark.setImageDrawable(it) } } From 297ece8e7b3bfce3b938780b822f46a66c2b6751 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 21 Jun 2021 22:31:37 +0200 Subject: [PATCH 06/13] set seekbar default style Signed-off-by: Andy Scherzinger --- .../main/res/layout/item_custom_incoming_voice_message.xml | 4 ++-- app/src/main/res/values/styles.xml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) 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 00c6fbe1f..7c19ad934 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 @@ -104,9 +104,9 @@ + android:layout_height="wrap_content" + tools:progress="50" /> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 053868d7f..488c4d5b9 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -38,6 +38,8 @@ @style/menuTextAppearance @style/SearchView @color/bg_default + @style/Nextcloud.Material.Incoming.SeekBar + @style/Nextcloud.Material.Incoming.SeekBar