Merge pull request #1340 from nextcloud/feature/noid/voice-messages-rebased

Voice messages
This commit is contained in:
Andy Scherzinger 2021-06-22 11:36:07 +02:00 committed by GitHub
commit 51b8656d0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1736 additions and 154 deletions

View File

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

View File

@ -0,0 +1,403 @@
/*
* Nextcloud Talk application
*
* @author Mario Danic
* @author Marcel Hibbe
* @author Andy Scherzinger
* Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
* Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
* Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
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<ChatMessage>(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(ResourcesCompat.getColor(context?.resources!!, R.color.warm_grey_four, null))
// 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) {
// 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)
}
}
})
// 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<Drawable>(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(
">",
ResourcesCompat.getColor(context!!.resources, R.color.black, null)
)
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) {
ResourcesCompat.getColor(resources, R.color.bg_message_list_incoming_bubble_deleted, null)
} else {
ResourcesCompat.getColor(resources, R.color.bg_message_list_incoming_bubble, null)
}
val bubbleDrawable = DisplayUtils.getMessageSelector(
bgBubbleColor,
ResourcesCompat.getColor(resources, R.color.transparent, null),
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(ContextCompat.getColor(context!!, 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 / 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 -> {
}
}
}
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
}
}

View File

@ -32,6 +32,8 @@ import android.text.SpannableString
import android.text.TextUtils
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
@ -78,11 +80,13 @@ class MagicIncomingTextMessageViewHolder(itemView: View) : MessageHolders
if (message.actorType == "guests") {
// do nothing, avatar is set
} else if (message.actorType == "bots" && message.actorId == "changelog") {
val layers = arrayOfNulls<Drawable>(2)
layers[0] = context?.getDrawable(R.drawable.ic_launcher_background)
layers[1] = context?.getDrawable(R.drawable.ic_launcher_foreground)
val layerDrawable = LayerDrawable(layers)
binding.messageUserAvatar.setImageDrawable(DisplayUtils.getRoundedDrawable(layerDrawable))
if (context != null) {
val layers = arrayOfNulls<Drawable>(2)
layers[0] = ContextCompat.getDrawable(context!!, R.drawable.ic_launcher_background)
layers[1] = ContextCompat.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()
@ -90,7 +94,7 @@ class MagicIncomingTextMessageViewHolder(itemView: 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)
@ -107,9 +111,9 @@ class MagicIncomingTextMessageViewHolder(itemView: View) : MessageHolders
val resources = itemView.resources
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)
}
var bubbleResource = R.drawable.shape_incoming_message
@ -120,7 +124,7 @@ class MagicIncomingTextMessageViewHolder(itemView: View) : MessageHolders
val bubbleDrawable = DisplayUtils.getMessageSelector(
bgBubbleColor,
resources.getColor(R.color.transparent),
ResourcesCompat.getColor(resources, R.color.transparent, null),
bgBubbleColor, bubbleResource
)
ViewCompat.setBackground(bubble, bubbleDrawable)
@ -128,7 +132,7 @@ class MagicIncomingTextMessageViewHolder(itemView: View) : MessageHolders
val messageParameters = message.messageParameters
itemView.isSelected = false
binding.messageTime.setTextColor(context?.resources!!.getColor(R.color.warm_grey_four))
binding.messageTime.setTextColor(ResourcesCompat.getColor(resources, R.color.warm_grey_four, null))
var messageString: Spannable = SpannableString(message.text)
@ -201,7 +205,7 @@ class MagicIncomingTextMessageViewHolder(itemView: 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)

View File

@ -2,8 +2,10 @@
* Nextcloud Talk application
*
* @author Mario Danic
* @author Marcel Hibbe
* @author Andy Scherzinger
* Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
* Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
* Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
*
* 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<String, String>? = 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,31 @@ 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 +121,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 +134,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
@ -147,9 +151,9 @@ class MagicOutcomingTextMessageViewHolder(itemView: View) : OutcomingTextMessage
?: 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)
ContextCompat.getColor(context!!, R.color.nc_outcoming_text_default)
)
binding.messageQuote.quotedMessageAuthor.setTextColor(context!!.resources.getColor(R.color.nc_grey))
binding.messageQuote.quotedMessageAuthor.setTextColor(ContextCompat.getColor(context!!, R.color.nc_grey))
binding.messageQuote.quoteColoredView.setBackgroundResource(R.color.white)
@ -171,8 +175,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)
ResourcesCompat.getDrawable(context!!.resources, drawableInt, null)?.let {
it.setColorFilter(ContextCompat.getColor(context!!, R.color.white60), PorterDuff.Mode.SRC_ATOP)
binding.checkMark.setImageDrawable(it)
}
}

View File

@ -0,0 +1,379 @@
/*
* Nextcloud Talk application
*
* @author Mario Danic
* @author Marcel Hibbe
* Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
* Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<ChatMessage>(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.white60))
// 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) {
// 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)
}
}
})
// 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 / 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 -> {
}
}
}
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
}
}

View File

@ -359,7 +359,8 @@ public interface NcApi {
Observable<Void> 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

View File

@ -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,16 @@ 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.Build.VERSION_CODES.O
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,16 +53,22 @@ 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.core.widget.doAfterTextChanged
import androidx.emoji.text.EmojiCompat
import androidx.emoji.widget.EmojiTextView
import androidx.recyclerview.widget.ItemTouchHelper
@ -80,12 +93,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 +160,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 +242,10 @@ class ChatController(args: Bundle) :
val filesToUpload: MutableList<String> = ArrayList()
var sharedText: String
var isVoiceRecordingInProgress: Boolean = false
var currentVoiceRecordFile: String = ""
private var recorder: MediaRecorder? = null
init {
setHasOptionsMenu(true)
@ -436,6 +459,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 +608,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()
val 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
}
val 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 +749,144 @@ 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()
}
@Suppress("Detekt.TooGenericExceptionCaught")
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 >= O) {
vibrator.vibrate(VibrationEffect.createOneShot(SHORT_VIBRATE, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
vibrator.vibrate(SHORT_VIBRATE)
}
}
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 ||
@ -612,16 +895,16 @@ class ChatController(args: Bundle) :
Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY
) {
conversationVoiceCallMenuItem?.icon?.alpha = 99
conversationVideoMenuItem?.icon?.alpha = 99
conversationVoiceCallMenuItem?.icon?.alpha = SEMI_TRANSPARENT_INT
conversationVideoMenuItem?.icon?.alpha = SEMI_TRANSPARENT_INT
binding.messageInputView.visibility = View.GONE
} else {
if (conversationVoiceCallMenuItem != null) {
conversationVoiceCallMenuItem?.icon?.alpha = 255
conversationVoiceCallMenuItem?.icon?.alpha = FULLY_OPAQUE_INT
}
if (conversationVideoMenuItem != null) {
conversationVideoMenuItem?.icon?.alpha = 255
conversationVideoMenuItem?.icon?.alpha = FULLY_OPAQUE_INT
}
if (currentConversation != null && currentConversation!!.shouldShowLobby(conversationUser)
@ -722,7 +1005,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 +1026,36 @@ class ChatController(args: Bundle) :
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, 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<String>) {
private fun uploadFiles(files: MutableList<String>, isVoiceMessage: Boolean) {
var metaData = ""
if (isVoiceMessage) {
metaData = VOICE_MESSAGE_META_DATA
}
try {
require(files.isNotEmpty())
val data: Data = Data.Builder()
@ -764,16 +1065,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,15 +1186,13 @@ 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 ->
@ -924,7 +1226,6 @@ class ChatController(args: Bundle) :
private fun cancelReply() {
binding.messageInputView.findViewById<RelativeLayout>(R.id.quotedChatMessageView)?.visibility = View.GONE
binding.messageInputView.findViewById<ImageButton>(R.id.attachmentButton)?.visibility = View.VISIBLE
binding.messageInputView.findViewById<Space>(R.id.attachmentButtonSpace)?.visibility = View.VISIBLE
}
private fun cancelNotificationsForCurrentConversation() {
@ -1216,6 +1517,7 @@ class ChatController(args: Bundle) :
}
})
}
showMicrophoneButton(true)
}
private fun setupWebsocket() {
@ -1569,14 +1871,14 @@ class ChatController(args: Bundle) :
return true
}
R.id.conversation_video_call -> {
if (conversationVideoMenuItem?.icon?.alpha == 255) {
if (conversationVideoMenuItem?.icon?.alpha == FULLY_OPAQUE_INT) {
startACall(false)
return true
}
return false
}
R.id.conversation_voice_call -> {
if (conversationVoiceCallMenuItem?.icon?.alpha == 255) {
if (conversationVoiceCallMenuItem?.icon?.alpha == FULLY_OPAQUE_INT) {
startACall(true)
return true
}
@ -1817,8 +2119,6 @@ class ChatController(args: Bundle) :
chatMessage?.let {
binding.messageInputView.findViewById<ImageButton>(R.id.attachmentButton)?.visibility =
View.GONE
binding.messageInputView.findViewById<Space>(R.id.attachmentButtonSpace)?.visibility =
View.GONE
binding.messageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
View.VISIBLE
@ -1868,6 +2168,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 +2220,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 -> message.hasGeoLocation()
CONTENT_TYPE_VOICE_MESSAGE -> message.isVoiceMessage()
CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage)
CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == "-1"
else -> false
@ -2017,6 +2328,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 +2336,14 @@ 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"
private const val SHORT_VIBRATE: Long = 20
private const val FULLY_OPAQUE_INT: Int = 255
private const val SEMI_TRANSPARENT_INT: Int = 99
}
}

View File

@ -239,10 +239,7 @@ class LocationPickerController(args: Bundle) :
MIN_LOCATION_UPDATE_DISTANCE,
this
)
Log.d(
TAG, "Using LocationManager.GPS_PROVIDER because LocationManager.NETWORK_PROVIDER" +
" was not available"
)
Log.d(TAG, "LocationManager.NETWORK_PROVIDER falling back to LocationManager.GPS_PROVIDER")
}
else -> {
Log.e(

View File

@ -1,28 +0,0 @@
/*
* Nextcloud Talk application
*
* @author Marcel Hibbe
* Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.interfaces
import com.stfalcon.chatkit.commons.models.IMessage
interface ExtendedIMessage : IMessage {
fun isLocationMessage(): Boolean
}

View File

@ -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<String> 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<Void>() {
@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

View File

@ -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,29 @@ 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<String> = ArrayList()
paths.add("$ncTargetpath/$filename")
@ -177,6 +192,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 +206,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) {

View File

@ -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 = ReadStatus.NONE;
@JsonField(name = "messageType")
public String messageType;
@JsonIgnore
List<MessageType> 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<String, HashMap<String, String>> entry : messageParameters.entrySet()) {
Map<String, String> 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<String, HashMap<String, String>> entry : messageParameters.entrySet()) {
Map<String, String> 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 {

View File

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

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M2,12.5C2,9.46 4.46,7 7.5,7H18c2.21,0 4,1.79 4,4s-1.79,4 -4,4H9.5C8.12,15 7,13.88 7,12.5S8.12,10 9.5,10H17v2H9.41c-0.55,0 -0.55,1 0,1H18c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2H7.5C5.57,9 4,10.57 4,12.5S5.57,16 7.5,16H17v2H7.5C4.46,18 2,15.54 2,12.5z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20,5L4,5c-1.1,0 -1.99,0.9 -1.99,2L2,17c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,7c0,-1.1 -0.9,-2 -2,-2zM11,8h2v2h-2L11,8zM11,11h2v2h-2v-2zM8,8h2v2L8,10L8,8zM8,11h2v2L8,13v-2zM7,13L5,13v-2h2v2zM7,10L5,10L5,8h2v2zM16,17L8,17v-2h8v2zM16,13h-2v-2h2v2zM16,10h-2L14,8h2v2zM19,13h-2v-2h2v2zM19,10h-2L17,8h2v2z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@color/fontAppbar"
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/nc_darkRed">
<path
android:fillColor="@color/fontAppbar"
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/nc_voice_message_outgoing_controls"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/nc_voice_message_outgoing_controls"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Nextcloud Talk application
~
~ @author Marcel Hibbe
~ Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
~
~ 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 <http://www.gnu.org/licenses/>.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size android:height="12dp" android:width="12dp"/>
<solid android:color="#ffffff" />
<corners android:radius="1dp" />
</shape>

View File

@ -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"/>
<com.stfalcon.chatkit.messages.MessagesList
android:id="@+id/messagesListView"

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Nextcloud Talk application
~
~ @author Mario Danic
~ @author Marcel Hibbe
~ @author Andy Scherzinger
~ Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
~ Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
~ Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
~
~ 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 <http://www.gnu.org/licenses/>.
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginTop="2dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="2dp">
<com.facebook.drawee.view.SimpleDraweeView
android:id="@id/messageUserAvatar"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_alignParentTop="true"
android:layout_marginEnd="8dp"
app:roundAsCircle="true" />
<com.google.android.flexbox.FlexboxLayout
android:id="@id/bubble"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/message_incoming_bubble_margin_right"
android:layout_toEndOf="@id/messageUserAvatar"
android:orientation="vertical"
app:alignContent="stretch"
app:alignItems="stretch"
app:flexWrap="wrap"
app:justifyContent="flex_end">
<include
android:id="@+id/message_quote"
layout="@layout/item_message_quote"
android:visibility="gone" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/messageAuthor"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:textColor="@color/textColorMaxContrast"
android:textSize="12sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/playBtn"
style="@style/Widget.AppTheme.Button.IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/play_voice_message"
android:visibility="visible"
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" />
<com.google.android.material.button.MaterialButton
android:id="@+id/pauseBtn"
style="@style/Widget.AppTheme.Button.IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/pause_voice_message"
android:visibility="gone"
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" />
<SeekBar
android:id="@+id/seekbar"
android:layout_width="200dp"
android:layout_height="wrap_content"
tools:progress="50" />
</LinearLayout>
<TextView
android:id="@id/messageTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/messageText"
android:layout_marginStart="8dp"
app:layout_alignSelf="center"
tools:text="12:38"/>
</com.google.android.flexbox.FlexboxLayout>
</RelativeLayout>

View File

@ -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!" />
<TextView
android:id="@id/messageTime"

View File

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Nextcloud Talk application
~
~ @author Mario Danic
~ @author Marcel Hibbe
~ @author Andy Scherzinger
~ Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
~ Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
~ Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
~
~ 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 <http://www.gnu.org/licenses/>.
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginTop="2dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="2dp">
<com.google.android.flexbox.FlexboxLayout
android:id="@id/bubble"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentEnd="true"
android:layout_marginStart="@dimen/message_outcoming_bubble_margin_left"
app:alignContent="stretch"
app:alignItems="stretch"
app:flexWrap="wrap"
app:justifyContent="flex_end">
<include
android:id="@+id/message_quote"
layout="@layout/item_message_quote"
android:visibility="gone" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:progressTint="@color/fontAppbar"
android:visibility="gone"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/playBtn"
style="@style/Widget.AppTheme.Button.IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/play_voice_message"
android:visibility="visible"
app:rippleColor="#1FFFFFFF"
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" />
<com.google.android.material.button.MaterialButton
android:id="@+id/pauseBtn"
style="@style/Widget.AppTheme.Button.IconButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/pause_voice_message"
android:visibility="gone"
app:rippleColor="#1FFFFFFF"
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" />
<SeekBar
android:id="@+id/seekbar"
style="@style/Nextcloud.Material.Outgoing.SeekBar"
android:layout_width="200dp"
android:layout_height="40dp"
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
tools:progress="50" />
</LinearLayout>
<TextView
android:id="@id/messageTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/messageText"
android:layout_marginStart="8dp"
app:layout_alignSelf="center"
tools:text="10:35" />
<ImageView
android:id="@+id/checkMark"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/messageTime"
android:layout_marginStart="8dp"
app:layout_alignSelf="center"
android:contentDescription="@null" />
</com.google.android.flexbox.FlexboxLayout>
</RelativeLayout>

View File

@ -20,64 +20,143 @@
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RelativeLayout android:layout_height="wrap_content" android:layout_width="match_parent">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include layout="@layout/item_message_quote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:visibility="gone"/>
<ImageButton
android:id="@id/attachmentButton"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_below="@id/quotedChatMessageView"
android:scaleType="centerCrop"
android:layout_marginTop="5dp"
android:contentDescription="@string/nc_add_attachment" />
<ImageButton
android:id="@+id/smileyButton"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_below="@id/quotedChatMessageView"
android:layout_toEndOf="@id/attachmentButton"
android:background="@color/transparent"
android:src="@drawable/ic_insert_emoticon_black_24dp"
android:scaleType="centerCrop"
android:layout_marginTop="5dp"
android:layout_marginStart="8dp"
app:tint="?attr/colorControlNormal"
android:contentDescription="@string/nc_add_emojis" />
<androidx.emoji.widget.EmojiEditText
android:id="@id/messageInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/quotedChatMessageView"
android:layout_centerHorizontal="true"
android:layout_toStartOf="@id/sendButtonSpace"
android:layout_toEndOf="@id/attachmentButtonSpace"
android:layout_toEndOf="@id/smileyButton"
android:layout_toStartOf="@id/messageSendButton"
android:imeOptions="actionDone"
android:inputType="textAutoCorrect|textMultiLine|textCapSentences"
android:lineSpacingMultiplier="1.2" />
<ImageButton
android:id="@id/attachmentButton"
android:layout_width="36dp"
android:layout_height="36dp"
<TextView
android:id="@+id/slideToCancelDescription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/quotedChatMessageView"
android:layout_toStartOf="@id/recordAudioButton"
android:layout_toEndOf="@id/audioRecordDuration"
android:layout_centerInParent="true"
android:textAlignment="center"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/low_emphasis_text"
android:text="@string/nc_voice_message_slide_to_cancel"
android:visibility="gone"
tools:visibility="visible">
</TextView>
<ImageView
android:id="@+id/microphoneEnabledInfoBackground"
android:layout_width="35dp"
android:layout_height="35dp"
android:layout_below="@id/quotedChatMessageView"
android:scaleType="centerInside"
android:contentDescription="@string/nc_add_attachment" />
android:layout_alignParentStart="true"
android:background="@color/bg_default"
android:visibility="gone"
tools:visibility="visible"
android:contentDescription="@null" />
<ImageButton
android:id="@+id/smileyButton"
android:layout_width="36dp"
android:layout_height="36dp"
<!-- the height of this ImageView is used to define the overall height of the
parent layout whenever the voice recording mode is enabled. parent layout has
height=wrap_content because it must enlarge whenever user types a message with
linebreaks. -->
<ImageView
android:id="@+id/microphoneEnabledInfo"
android:layout_width="35dp"
android:layout_height="43dp"
android:layout_below="@id/quotedChatMessageView"
android:layout_toStartOf="@id/messageSendButton"
android:background="@color/transparent"
android:src="@drawable/ic_insert_emoticon_black_24dp"
app:tint="@color/emoji_icons"
android:contentDescription="@string/nc_add_emojis" />
android:scaleType="centerInside"
android:layout_alignParentStart="true"
android:src="@drawable/ic_baseline_mic_red_24"
android:contentDescription="@null"
android:visibility="gone"
tools:visibility="visible"/>
<Chronometer
android:id="@+id/audioRecordDuration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/quotedChatMessageView"
android:layout_toEndOf="@id/microphoneEnabledInfo"
android:layout_centerVertical="true"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/low_emphasis_text"
android:paddingStart="5dp"
android:paddingEnd="5dp"
android:background="@color/bg_default"
android:visibility="gone"
tools:visibility="visible" />
<ImageButton
android:id="@id/messageSendButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_below="@id/quotedChatMessageView"
android:layout_alignParentEnd="true"
android:adjustViewBounds="true"
android:padding="4dp"
android:scaleType="centerInside"
android:contentDescription="@string/nc_description_send_message_button" />
<ImageButton
android:id="@+id/recordAudioButton"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_below="@id/quotedChatMessageView"
android:layout_alignParentEnd="true"
android:background="@color/transparent"
android:src="@drawable/ic_baseline_mic_24"
android:contentDescription="@string/nc_description_record_voice" />
<Space
android:id="@id/attachmentButtonSpace"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_below="@id/quotedChatMessageView"
android:layout_toEndOf="@id/attachmentButton" />
android:layout_toEndOf="@id/attachmentButton"
android:visibility="gone"/>
<Space
android:id="@id/sendButtonSpace"

View File

@ -84,4 +84,8 @@
<!-- shimmer element colors -->
<color name="nc_shimmer_default_color">#D7D7D7</color>
<color name="nc_shimmer_darker_color">#B4B4B4</color>
<!-- voicemessage -->
<color name="nc_voice_message_outgoing_controls">#606060</color>
</resources>

View File

@ -276,6 +276,7 @@
<string name="nc_sent_a_video" formatted="true">%1$s sent a video.</string>
<string name="nc_sent_an_image" formatted="true">%1$s sent an image.</string>
<string name="nc_sent_location" formatted="true">%1$s sent a location.</string>
<string name="nc_sent_voice" formatted="true">%1$s sent a voice message.</string>
<string name="nc_sent_a_link_you">You sent a link.</string>
<string name="nc_sent_a_gif_you">You sent a GIF.</string>
<string name="nc_sent_an_attachment_you">You sent an attachment.</string>
@ -283,6 +284,7 @@
<string name="nc_sent_a_video_you">You sent a video.</string>
<string name="nc_sent_an_image_you">You sent an image.</string>
<string name="nc_sent_location_you">You sent a location.</string>
<string name="nc_sent_voice_you">You sent a voice message.</string>
<string name="nc_formatted_message" translatable="false">%1$s: %2$s</string>
<string name="nc_message_quote_cancel_reply">Cancel reply</string>
<!-- When translating to German, please use non-formal variant -->
@ -380,6 +382,15 @@
<string name="nc_share_this_location">Share this location</string>
<string name="nc_location_current_position_description">Your current location</string>
<!-- voice messages -->
<string name="nc_voice_message_filename">Talk recording from %1$s (%2$s)</string>
<string name="nc_voice_message_hold_to_record_info">Hold to record, release to send.</string>
<string name="nc_description_record_voice">Record voice</string>
<string name="nc_voice_message_slide_to_cancel">&lt;&lt; Slide to cancel</string>
<string name="play_voice_message">Play voice message</string>
<string name="pause_voice_message">Pause voice message</string>
<string name="nc_voice_message_missing_audio_permission">Permission for audio recording is required</string>
<!-- Phonebook Integration -->
<string name="nc_settings_phone_book_integration_key" translatable="false">phone_book_integration</string>
<string name="nc_settings_phone_book_integration_desc">Match contacts based on phone number to integrate Talk shortcut into system contacts app</string>

View File

@ -38,6 +38,8 @@
<item name="android:actionMenuTextAppearance">@style/menuTextAppearance</item>
<item name="searchViewStyle">@style/SearchView</item>
<item name="android:navigationBarColor">@color/bg_default</item>
<item name="android:seekBarStyle">@style/Nextcloud.Material.Incoming.SeekBar</item>
<item name="seekBarStyle">@style/Nextcloud.Material.Incoming.SeekBar</item>
</style>
<style name="ThemeOverlay.AppTheme.PopupMenu" parent="ThemeOverlay.MaterialComponents.Dark">
@ -177,4 +179,18 @@
<item name="android:typeface">sans</item>
<item name="android:textStyle">bold</item>
</style>
<style name="Nextcloud.Material.Incoming.SeekBar" parent="Widget.AppCompat.SeekBar">
<item name="android:progressBackgroundTint">@color/medium_emphasis_text</item>
<item name="android:progressTint">@color/colorPrimary</item>
<item name="android:colorControlActivated">@color/colorPrimary</item>
</style>
<style name="Nextcloud.Material.Outgoing.SeekBar" parent="Widget.AppCompat.SeekBar">
<item name="android:progressBackgroundTint">#deffffff</item>
<item name="android:progressTint">#ffffff</item>
<item name="android:colorControlActivated">#ffffff</item>
<item name="android:colorControlNormal">#ffffff</item>
</style>
</resources>

View File

@ -1,5 +1,5 @@
build:
maxIssues: 202
maxIssues: 223
weights:
# complexity: 2
# LongParameterList: 1

View File

@ -1 +1 @@
436
440

View File

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