Merge pull request #3598 from vector-im/feature/ons/voice_message
Voice Message
This commit is contained in:
commit
c6bd6e4961
|
@ -48,6 +48,9 @@ allprojects {
|
|||
// Chat effects
|
||||
includeGroupByRegex 'com\\.github\\.jetradarmobile'
|
||||
includeGroupByRegex 'nl\\.dionsegijn'
|
||||
|
||||
// Voice RecordView
|
||||
includeGroupByRegex 'com\\.github\\.Armen101'
|
||||
}
|
||||
}
|
||||
maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
|
||||
|
|
|
@ -60,4 +60,6 @@ dependencies {
|
|||
implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12'
|
||||
// dialpad dimen
|
||||
implementation 'im.dlg:android-dialer:1.2.5'
|
||||
// AudioRecordView attr
|
||||
implementation 'com.github.Armen101:AudioRecordView:1.0.5'
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<solid android:color="#F00" />
|
||||
|
||||
<corners android:radius="8dp" />
|
||||
|
||||
</shape>
|
|
@ -1,6 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<integer name="rtl_x_multiplier">-1</integer>
|
||||
<integer name="rtl_mirror_flip">180</integer>
|
||||
|
||||
</resources>
|
|
@ -128,4 +128,8 @@
|
|||
<color name="vctr_chat_effect_snow_background_light">@color/black_alpha</color>
|
||||
<color name="vctr_chat_effect_snow_background_dark">@android:color/transparent</color>
|
||||
|
||||
<attr name="vctr_voice_message_toast_background" format="color" />
|
||||
<color name="vctr_voice_message_toast_background_light">@color/palette_black_900</color>
|
||||
<color name="vctr_voice_message_toast_background_dark">@color/palette_gray_400</color>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
<integer name="default_animation_offset">200</integer>
|
||||
|
||||
<integer name="rtl_x_multiplier">1</integer>
|
||||
<integer name="rtl_mirror_flip">0</integer>
|
||||
|
||||
<integer name="splash_animation_velocity">750</integer>
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
|
||||
<!-- For light themes -->
|
||||
<color name="palette_gray_25">#F4F6FA</color>
|
||||
<color name="palette_gray_50">#E6E8F0</color>
|
||||
<color name="palette_gray_50">#E3E8F0</color>
|
||||
<color name="palette_gray_100">#C1C6CD</color>
|
||||
<color name="palette_gray_150">#8D97A5</color>
|
||||
<color name="palette_gray_200">#737D8C</color>
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="VoicePlaybackWaveform">
|
||||
<item name="chunkColor">?vctr_content_secondary</item>
|
||||
<item name="chunkAlignTo">center</item>
|
||||
<item name="chunkMinHeight">1dp</item>
|
||||
<item name="chunkRoundedCorners">true</item>
|
||||
<item name="chunkSoftTransition">true</item>
|
||||
<item name="chunkSpace">2dp</item>
|
||||
<item name="chunkWidth">2dp</item>
|
||||
<item name="direction">rightToLeft</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Vector.TextView.Caption.Toast">
|
||||
<item name="android:paddingTop">8dp</item>
|
||||
<item name="android:paddingBottom">8dp</item>
|
||||
<item name="android:paddingStart">12dp</item>
|
||||
<item name="android:paddingEnd">12dp</item>
|
||||
<item name="android:background">@drawable/bg_round_corner_8dp</item>
|
||||
<item name="android:backgroundTint">?vctr_voice_message_toast_background</item>
|
||||
<item name="android:textColor">@color/palette_white</item>
|
||||
<item name="android:gravity">center</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
|
@ -135,6 +135,8 @@
|
|||
|
||||
<item name="vctr_jump_to_unread_style">@style/Widget.Vector.JumpToUnread.Dark</item>
|
||||
|
||||
<!-- Voice Message -->
|
||||
<item name="vctr_voice_message_toast_background">@color/vctr_voice_message_toast_background_dark</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Vector.Dark" parent="Base.Theme.Vector.Dark" />
|
||||
|
|
|
@ -137,6 +137,8 @@
|
|||
|
||||
<item name="vctr_jump_to_unread_style">@style/Widget.Vector.JumpToUnread.Light</item>
|
||||
|
||||
<!-- Voice Message -->
|
||||
<item name="vctr_voice_message_toast_background">@color/vctr_voice_message_toast_background_light</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Vector.Light" parent="Base.Theme.Vector.Light" />
|
||||
|
|
|
@ -23,6 +23,7 @@ import io.reactivex.Single
|
|||
import kotlinx.coroutines.rx2.rxCompletable
|
||||
import kotlinx.coroutines.rx2.rxSingle
|
||||
import org.matrix.android.sdk.api.query.QueryStringValue
|
||||
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
|
||||
import org.matrix.android.sdk.api.session.events.model.Event
|
||||
import org.matrix.android.sdk.api.session.identity.ThreePid
|
||||
import org.matrix.android.sdk.api.session.room.Room
|
||||
|
@ -146,6 +147,10 @@ class RxRoom(private val room: Room) {
|
|||
fun deleteAvatar(): Completable = rxCompletable {
|
||||
room.deleteAvatar()
|
||||
}
|
||||
|
||||
fun sendMedia(attachment: ContentAttachmentData, compressBeforeSending: Boolean, roomIds: Set<String>): Completable = rxCompletable {
|
||||
room.sendMedia(attachment, compressBeforeSending, roomIds)
|
||||
}
|
||||
}
|
||||
|
||||
fun Room.rx(): RxRoom {
|
||||
|
|
|
@ -35,7 +35,8 @@ data class ContentAttachmentData(
|
|||
val name: String? = null,
|
||||
val queryUri: Uri,
|
||||
val mimeType: String?,
|
||||
val type: Type
|
||||
val type: Type,
|
||||
val waveform: List<Int>? = null
|
||||
) : Parcelable {
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
|
|
|
@ -24,15 +24,15 @@ data class AudioInfo(
|
|||
/**
|
||||
* The mimetype of the audio e.g. "audio/aac".
|
||||
*/
|
||||
@Json(name = "mimetype") val mimeType: String?,
|
||||
@Json(name = "mimetype") val mimeType: String? = null,
|
||||
|
||||
/**
|
||||
* The size of the audio clip in bytes.
|
||||
*/
|
||||
@Json(name = "size") val size: Long = 0,
|
||||
@Json(name = "size") val size: Long? = null,
|
||||
|
||||
/**
|
||||
* The duration of the audio in milliseconds.
|
||||
*/
|
||||
@Json(name = "duration") val duration: Int = 0
|
||||
@Json(name = "duration") val duration: Int? = null
|
||||
)
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.matrix.android.sdk.api.session.room.model.message
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/**
|
||||
* See https://github.com/matrix-org/matrix-doc/blob/travis/msc/audio-waveform/proposals/3246-audio-waveform.md
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AudioWaveformInfo(
|
||||
@Json(name = "duration")
|
||||
val duration: Int? = null,
|
||||
|
||||
/**
|
||||
* The array should have no less than 30 elements and no more than 120.
|
||||
* List of integers between zero and 1024, inclusive.
|
||||
*/
|
||||
@Json(name = "waveform")
|
||||
val waveform: List<Int>? = null
|
||||
)
|
|
@ -20,6 +20,7 @@ import com.squareup.moshi.Json
|
|||
import com.squareup.moshi.JsonClass
|
||||
import org.matrix.android.sdk.api.session.events.model.Content
|
||||
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
|
||||
import org.matrix.android.sdk.api.util.JsonDict
|
||||
import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
|
@ -50,7 +51,17 @@ data class MessageAudioContent(
|
|||
/**
|
||||
* Required if the file is encrypted. Information on the encrypted file, as specified in End-to-end encryption.
|
||||
*/
|
||||
@Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
|
||||
@Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null,
|
||||
|
||||
/**
|
||||
* Encapsulates waveform and duration of the audio.
|
||||
*/
|
||||
@Json(name = "org.matrix.msc1767.audio") val audioWaveformInfo: AudioWaveformInfo? = null,
|
||||
|
||||
/**
|
||||
* Indicates that is a voice message.
|
||||
*/
|
||||
@Json(name = "org.matrix.msc3245.voice") val voiceMessageIndicator: JsonDict? = null
|
||||
) : MessageWithAttachmentContent {
|
||||
|
||||
override val mimeType: String?
|
||||
|
|
|
@ -31,6 +31,8 @@ object MimeTypes {
|
|||
const val Jpeg = "image/jpeg"
|
||||
const val Gif = "image/gif"
|
||||
|
||||
const val Ogg = "audio/ogg"
|
||||
|
||||
fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this
|
||||
|
||||
fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse()
|
||||
|
|
|
@ -25,6 +25,7 @@ import kotlinx.coroutines.completeWith
|
|||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.matrix.android.sdk.api.failure.Failure
|
||||
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
|
||||
import org.matrix.android.sdk.api.session.file.FileService
|
||||
import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt
|
||||
|
@ -124,13 +125,21 @@ internal class DefaultFileService @Inject constructor(
|
|||
.header(DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER, url)
|
||||
.build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException()
|
||||
val response = try {
|
||||
okHttpClient.newCall(request).execute()
|
||||
} catch (failure: Throwable) {
|
||||
throw if (failure is IOException) {
|
||||
Failure.NetworkConnection(failure)
|
||||
} else {
|
||||
failure
|
||||
}
|
||||
}
|
||||
|
||||
val source = response.body?.source() ?: throw IOException()
|
||||
if (!response.isSuccessful) {
|
||||
throw Failure.NetworkConnection(IOException())
|
||||
}
|
||||
|
||||
val source = response.body?.source() ?: throw Failure.NetworkConnection(IOException())
|
||||
|
||||
Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${!source.exhausted()}")
|
||||
|
||||
|
|
|
@ -184,7 +184,8 @@ internal class DefaultSendService @AssistedInject constructor(
|
|||
mimeType = messageContent.mimeType,
|
||||
name = messageContent.body,
|
||||
queryUri = Uri.parse(messageContent.url),
|
||||
type = ContentAttachmentData.Type.AUDIO
|
||||
type = ContentAttachmentData.Type.AUDIO,
|
||||
waveform = messageContent.audioWaveformInfo?.waveform
|
||||
)
|
||||
localEchoRepository.updateSendState(localEcho.eventId, roomId, SendState.UNSENT)
|
||||
internalSendMedia(listOf(localEcho.root), attachmentData, true)
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.matrix.android.sdk.api.session.events.model.RelationType
|
|||
import org.matrix.android.sdk.api.session.events.model.UnsignedData
|
||||
import org.matrix.android.sdk.api.session.events.model.toContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.AudioInfo
|
||||
import org.matrix.android.sdk.api.session.room.model.message.AudioWaveformInfo
|
||||
import org.matrix.android.sdk.api.session.room.model.message.FileInfo
|
||||
import org.matrix.android.sdk.api.session.room.model.message.ImageInfo
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
|
||||
|
@ -74,6 +75,7 @@ internal class LocalEchoEventFactory @Inject constructor(
|
|||
private val markdownParser: MarkdownParser,
|
||||
private val textPillsUtils: TextPillsUtils,
|
||||
private val thumbnailExtractor: ThumbnailExtractor,
|
||||
private val waveformSanitizer: WaveFormSanitizer,
|
||||
private val localEchoRepository: LocalEchoRepository,
|
||||
private val permalinkFactory: PermalinkFactory
|
||||
) {
|
||||
|
@ -289,14 +291,21 @@ internal class LocalEchoEventFactory @Inject constructor(
|
|||
}
|
||||
|
||||
private fun createAudioEvent(roomId: String, attachment: ContentAttachmentData): Event {
|
||||
val isVoiceMessage = attachment.waveform != null
|
||||
val content = MessageAudioContent(
|
||||
msgType = MessageType.MSGTYPE_AUDIO,
|
||||
body = attachment.name ?: "audio",
|
||||
audioInfo = AudioInfo(
|
||||
duration = attachment.duration?.toInt(),
|
||||
mimeType = attachment.getSafeMimeType()?.takeIf { it.isNotBlank() },
|
||||
size = attachment.size
|
||||
),
|
||||
url = attachment.queryUri.toString()
|
||||
url = attachment.queryUri.toString(),
|
||||
audioWaveformInfo = if (!isVoiceMessage) null else AudioWaveformInfo(
|
||||
duration = attachment.duration?.toInt(),
|
||||
waveform = waveformSanitizer.sanitize(attachment.waveform)
|
||||
),
|
||||
voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap()
|
||||
)
|
||||
return createMessageEvent(roomId, content)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.matrix.android.sdk.internal.session.room.send
|
||||
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
|
||||
internal class WaveFormSanitizer @Inject constructor() {
|
||||
private companion object {
|
||||
const val MIN_NUMBER_OF_VALUES = 30
|
||||
const val MAX_NUMBER_OF_VALUES = 120
|
||||
|
||||
const val MAX_VALUE = 1024
|
||||
}
|
||||
|
||||
/**
|
||||
* The array should have no less than 30 elements and no more than 120.
|
||||
* List of integers between zero and 1024, inclusive.
|
||||
*/
|
||||
fun sanitize(waveForm: List<Int>?): List<Int>? {
|
||||
if (waveForm.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Limit the number of items
|
||||
val sizeInRangeList = mutableListOf<Int>()
|
||||
when {
|
||||
waveForm.size < MIN_NUMBER_OF_VALUES -> {
|
||||
// Repeat the same value to have at least 30 items
|
||||
val repeatTimes = ceil(MIN_NUMBER_OF_VALUES / waveForm.size.toDouble()).toInt()
|
||||
waveForm.map { value ->
|
||||
repeat(repeatTimes) {
|
||||
sizeInRangeList.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
waveForm.size > MAX_NUMBER_OF_VALUES -> {
|
||||
val keepOneOf = ceil(waveForm.size.toDouble() / MAX_NUMBER_OF_VALUES).toInt()
|
||||
waveForm.mapIndexed { idx, value ->
|
||||
if (idx % keepOneOf == 0) {
|
||||
sizeInRangeList.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
sizeInRangeList.addAll(waveForm)
|
||||
}
|
||||
}
|
||||
|
||||
// OK, ensure all items are positive
|
||||
val positiveList = sizeInRangeList.map {
|
||||
abs(it)
|
||||
}
|
||||
|
||||
// Ensure max is not above MAX_VALUE
|
||||
val max = positiveList.maxOrNull() ?: MAX_VALUE
|
||||
|
||||
val finalList = if (max > MAX_VALUE) {
|
||||
// Reduce the values
|
||||
positiveList.map {
|
||||
it * MAX_VALUE / max
|
||||
}
|
||||
} else {
|
||||
positiveList
|
||||
}
|
||||
|
||||
Timber.d("Sanitize from ${waveForm.size} items to ${finalList.size} items. Max value was $max")
|
||||
return finalList
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.matrix.android.sdk.internal.session.room.send
|
||||
|
||||
import org.amshove.kluent.shouldBe
|
||||
import org.amshove.kluent.shouldBeInRange
|
||||
import org.junit.Test
|
||||
|
||||
class WaveFormSanitizerTest {
|
||||
|
||||
private val waveFormSanitizer = WaveFormSanitizer()
|
||||
|
||||
@Test
|
||||
fun sanitizeNull() {
|
||||
waveFormSanitizer.sanitize(null) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeEmpty() {
|
||||
waveFormSanitizer.sanitize(emptyList()) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeSingleton() {
|
||||
val result = waveFormSanitizer.sanitize(listOf(1))!!
|
||||
result.size shouldBe 30
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitize29() {
|
||||
val list = generateSequence { 1 }.take(29).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitize30() {
|
||||
val list = generateSequence { 1 }.take(30).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
result.size shouldBe 30
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitize31() {
|
||||
val list = generateSequence { 1 }.take(31).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitize119() {
|
||||
val list = generateSequence { 1 }.take(119).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitize120() {
|
||||
val list = generateSequence { 1 }.take(120).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
result.size shouldBe 120
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitize121() {
|
||||
val list = generateSequence { 1 }.take(121).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitize1024() {
|
||||
val list = generateSequence { 1 }.take(1024).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNegative() {
|
||||
val list = generateSequence { -1 }.take(30).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeMaxValue() {
|
||||
val list = generateSequence { 1025 }.take(30).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNegativeMaxValue() {
|
||||
val list = generateSequence { -1025 }.take(30).toList()
|
||||
val result = waveFormSanitizer.sanitize(list)!!
|
||||
checkResult(result)
|
||||
}
|
||||
|
||||
private fun checkResult(result: List<Int>) {
|
||||
result.forEach {
|
||||
it shouldBeInRange 0..1024
|
||||
}
|
||||
|
||||
result.size shouldBeInRange 30..120
|
||||
}
|
||||
}
|
|
@ -23,5 +23,6 @@ data class MultiPickerAudioType(
|
|||
override val size: Long,
|
||||
override val mimeType: String?,
|
||||
override val contentUri: Uri,
|
||||
val duration: Long
|
||||
val duration: Long,
|
||||
var waveform: List<Int>? = null
|
||||
) : MultiPickerBaseType
|
||||
|
|
|
@ -111,7 +111,7 @@ internal fun Uri.toMultiPickerVideoType(context: Context): MultiPickerVideoType?
|
|||
}
|
||||
}
|
||||
|
||||
internal fun Uri.toMultiPickerAudioType(context: Context): MultiPickerAudioType? {
|
||||
fun Uri.toMultiPickerAudioType(context: Context): MultiPickerAudioType? {
|
||||
val projection = arrayOf(
|
||||
MediaStore.Audio.Media.DISPLAY_NAME,
|
||||
MediaStore.Audio.Media.SIZE
|
||||
|
@ -141,7 +141,7 @@ internal fun Uri.toMultiPickerAudioType(context: Context): MultiPickerAudioType?
|
|||
MultiPickerAudioType(
|
||||
name,
|
||||
size,
|
||||
context.contentResolver.getType(this),
|
||||
sanitize(context.contentResolver.getType(this)),
|
||||
this,
|
||||
duration
|
||||
)
|
||||
|
@ -150,3 +150,11 @@ internal fun Uri.toMultiPickerAudioType(context: Context): MultiPickerAudioType?
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitize(type: String?): String? {
|
||||
if (type == "application/ogg") {
|
||||
// Not supported on old system
|
||||
return "audio/ogg"
|
||||
}
|
||||
return type
|
||||
}
|
||||
|
|
|
@ -162,7 +162,7 @@ Formatter\.formatShortFileSize===1
|
|||
# android\.text\.TextUtils
|
||||
|
||||
### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt
|
||||
enum class===102
|
||||
enum class===103
|
||||
|
||||
### Do not import temporary legacy classes
|
||||
import org.matrix.android.sdk.internal.legacy.riot===3
|
||||
|
|
|
@ -144,6 +144,8 @@ android {
|
|||
|
||||
buildConfigField "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy", "outboundSessionKeySharingStrategy", "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy.WhenTyping"
|
||||
|
||||
buildConfigField "Long", "VOICE_MESSAGE_DURATION_LIMIT_MS", "120_000L"
|
||||
|
||||
// If set, MSC3086 asserted identity messages sent on VoIP calls will cause the call to appear in the room corresponding to the asserted identity.
|
||||
// This *must* only be set in trusted environments.
|
||||
buildConfigField "Boolean", "handleCallAssertedIdentityEvents", "false"
|
||||
|
@ -339,7 +341,7 @@ dependencies {
|
|||
implementation "androidx.recyclerview:recyclerview:1.2.1"
|
||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
||||
implementation "androidx.fragment:fragment-ktx:$fragment_version"
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.0-beta02'
|
||||
implementation "androidx.sharetarget:sharetarget:1.1.0"
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation "androidx.media:media:1.4.0"
|
||||
|
@ -401,6 +403,7 @@ dependencies {
|
|||
implementation "androidx.autofill:autofill:$autofill_version"
|
||||
implementation 'jp.wasabeef:glide-transformations:4.3.0'
|
||||
implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12'
|
||||
implementation 'com.github.Armen101:AudioRecordView:1.0.5'
|
||||
|
||||
// Custom Tab
|
||||
implementation 'androidx.browser:browser:1.3.0'
|
||||
|
@ -408,6 +411,9 @@ dependencies {
|
|||
// Passphrase strength helper
|
||||
implementation 'com.nulab-inc:zxcvbn:1.5.2'
|
||||
|
||||
// To convert voice message on old platforms
|
||||
implementation 'com.arthenica:ffmpeg-kit-audio:4.4.LTS'
|
||||
|
||||
//Alerter
|
||||
implementation 'com.tapadoo.android:alerter:7.0.1'
|
||||
|
||||
|
|
|
@ -391,6 +391,11 @@ SOFTWARE.
|
|||
<br/>
|
||||
Copyright (c) 2017-present, dialog LLC <info@dlg.im>
|
||||
</li>
|
||||
<li>
|
||||
<b>Armen101 / AudioRecordView</b>
|
||||
<br/>
|
||||
Copyright 2019 Armen Gevorgyan
|
||||
</li>
|
||||
</ul>
|
||||
<pre>
|
||||
Apache License
|
||||
|
@ -590,5 +595,18 @@ Apache License
|
|||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
<br/>
|
||||
Version 3, 29 June 2007
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<b>ffmpeg-kit</b>
|
||||
<br/>
|
||||
Copyright (c) 2021 Taner Sener
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -19,6 +19,7 @@ package im.vector.app.core.error
|
|||
import im.vector.app.R
|
||||
import im.vector.app.core.resources.StringProvider
|
||||
import im.vector.app.features.call.dialpad.DialPadLookup
|
||||
import im.vector.app.features.voice.VoiceFailure
|
||||
import org.matrix.android.sdk.api.failure.Failure
|
||||
import org.matrix.android.sdk.api.failure.MatrixError
|
||||
import org.matrix.android.sdk.api.failure.MatrixIdFailure
|
||||
|
@ -123,11 +124,19 @@ class DefaultErrorFormatter @Inject constructor(
|
|||
stringProvider.getString(R.string.call_dial_pad_lookup_error)
|
||||
is MatrixIdFailure.InvalidMatrixId ->
|
||||
stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id)
|
||||
is VoiceFailure -> voiceMessageError(throwable)
|
||||
else -> throwable.localizedMessage
|
||||
}
|
||||
?: stringProvider.getString(R.string.unknown_error)
|
||||
}
|
||||
|
||||
private fun voiceMessageError(throwable: VoiceFailure): String {
|
||||
return when (throwable) {
|
||||
is VoiceFailure.UnableToPlay -> stringProvider.getString(R.string.error_voice_message_unable_to_play)
|
||||
is VoiceFailure.UnableToRecord -> stringProvider.getString(R.string.error_voice_message_unable_to_record)
|
||||
}
|
||||
}
|
||||
|
||||
private fun limitExceededError(error: MatrixError): String {
|
||||
val delay = error.retryAfterMillis
|
||||
|
||||
|
|
|
@ -136,6 +136,9 @@ class CallRingPlayerOutgoing(
|
|||
mediaPlayer.setAudioAttributes(AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
// TODO Change to ?
|
||||
// .setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN)
|
||||
// .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build())
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
|
|
|
@ -21,16 +21,17 @@ import java.util.concurrent.TimeUnit
|
|||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class CountUpTimer(private val intervalInMs: Long) {
|
||||
class CountUpTimer(private val intervalInMs: Long = 1_000) {
|
||||
|
||||
private val elapsedTime: AtomicLong = AtomicLong()
|
||||
private val resumed: AtomicBoolean = AtomicBoolean(false)
|
||||
|
||||
private val disposable = Observable.interval(intervalInMs, TimeUnit.MILLISECONDS)
|
||||
private val disposable = Observable.interval(intervalInMs / 10, TimeUnit.MILLISECONDS)
|
||||
.filter { resumed.get() }
|
||||
.doOnNext { elapsedTime.addAndGet(intervalInMs) }
|
||||
.map { elapsedTime.addAndGet(intervalInMs / 10) }
|
||||
.filter { it % intervalInMs == 0L }
|
||||
.subscribe {
|
||||
tickListener?.onTick(elapsedTime.get())
|
||||
tickListener?.onTick(it)
|
||||
}
|
||||
|
||||
var tickListener: TickListener? = null
|
||||
|
|
|
@ -34,6 +34,7 @@ import im.vector.app.core.platform.VectorBaseActivity
|
|||
// Permissions sets
|
||||
val PERMISSIONS_FOR_AUDIO_IP_CALL = listOf(Manifest.permission.RECORD_AUDIO)
|
||||
val PERMISSIONS_FOR_VIDEO_IP_CALL = listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
|
||||
val PERMISSIONS_FOR_VOICE_MESSAGE = listOf(Manifest.permission.RECORD_AUDIO)
|
||||
val PERMISSIONS_FOR_TAKING_PHOTO = listOf(Manifest.permission.CAMERA)
|
||||
val PERMISSIONS_FOR_MEMBERS_SEARCH = listOf(Manifest.permission.READ_CONTACTS)
|
||||
val PERMISSIONS_FOR_ROOM_AVATAR = listOf(Manifest.permission.CAMERA)
|
||||
|
|
|
@ -57,7 +57,8 @@ fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData {
|
|||
size = size,
|
||||
name = displayName,
|
||||
duration = duration,
|
||||
queryUri = contentUri
|
||||
queryUri = contentUri,
|
||||
waveform = waveform
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import android.net.Uri
|
|||
import android.view.View
|
||||
import im.vector.app.core.platform.VectorViewModelAction
|
||||
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
|
||||
import org.matrix.android.sdk.api.session.room.timeline.Timeline
|
||||
|
@ -107,5 +108,14 @@ sealed class RoomDetailAction : VectorViewModelAction {
|
|||
|
||||
// Failed messages
|
||||
object RemoveAllFailedMessages : RoomDetailAction()
|
||||
|
||||
data class RoomUpgradeSuccess(val replacementRoomId: String): RoomDetailAction()
|
||||
|
||||
// Voice Message
|
||||
object StartRecordingVoiceMessage : RoomDetailAction()
|
||||
data class EndRecordingVoiceMessage(val isCancelled: Boolean) : RoomDetailAction()
|
||||
object PauseRecordingVoiceMessage : RoomDetailAction()
|
||||
data class PlayOrPauseVoicePlayback(val eventId: String, val messageAudioContent: MessageAudioContent) : RoomDetailAction()
|
||||
object PlayOrPauseRecordingPlayback : RoomDetailAction()
|
||||
object EndAllVoiceActions : RoomDetailAction()
|
||||
}
|
||||
|
|
|
@ -51,6 +51,8 @@ class RoomDetailActivity :
|
|||
return ActivityRoomDetailBinding.inflate(layoutInflater)
|
||||
}
|
||||
|
||||
override fun getCoordinatorLayout() = views.coordinatorLayout
|
||||
|
||||
private lateinit var sharedActionViewModel: RoomDetailSharedActionViewModel
|
||||
private val requireActiveMembershipViewModel: RequireActiveMembershipViewModel by viewModel()
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ import android.os.Build
|
|||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.text.Spannable
|
||||
import android.text.format.DateUtils
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
|
@ -81,6 +82,7 @@ import im.vector.app.core.extensions.showKeyboard
|
|||
import im.vector.app.core.extensions.trackItemsVisibilityChange
|
||||
import im.vector.app.core.glide.GlideApp
|
||||
import im.vector.app.core.glide.GlideRequests
|
||||
import im.vector.app.core.hardware.vibrate
|
||||
import im.vector.app.core.intent.getFilenameFromUri
|
||||
import im.vector.app.core.intent.getMimeTypeFromUri
|
||||
import im.vector.app.core.platform.VectorBaseFragment
|
||||
|
@ -94,6 +96,7 @@ import im.vector.app.core.ui.views.NotificationAreaView
|
|||
import im.vector.app.core.utils.Debouncer
|
||||
import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.app.core.utils.KeyboardStateUtils
|
||||
import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_MESSAGE
|
||||
import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES
|
||||
import im.vector.app.core.utils.checkPermissions
|
||||
import im.vector.app.core.utils.colorizeMatchingText
|
||||
|
@ -102,6 +105,7 @@ import im.vector.app.core.utils.createJSonViewerStyleProvider
|
|||
import im.vector.app.core.utils.createUIHandler
|
||||
import im.vector.app.core.utils.isValidUrl
|
||||
import im.vector.app.core.utils.onPermissionDeniedDialog
|
||||
import im.vector.app.core.utils.onPermissionDeniedSnackbar
|
||||
import im.vector.app.core.utils.openUrlInExternalBrowser
|
||||
import im.vector.app.core.utils.registerForPermissionsResult
|
||||
import im.vector.app.core.utils.saveMedia
|
||||
|
@ -126,6 +130,7 @@ import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivit
|
|||
import im.vector.app.features.crypto.verification.VerificationBottomSheet
|
||||
import im.vector.app.features.home.AvatarRenderer
|
||||
import im.vector.app.features.home.room.detail.composer.TextComposerView
|
||||
import im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
|
||||
import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
|
||||
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
|
||||
import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
|
||||
|
@ -133,12 +138,14 @@ import im.vector.app.features.home.room.detail.timeline.action.MessageActionsBot
|
|||
import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel
|
||||
import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||
import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData
|
||||
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageImageVideoItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
|
||||
import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet
|
||||
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
|
||||
|
@ -162,6 +169,7 @@ import im.vector.app.features.settings.VectorSettingsActivity
|
|||
import im.vector.app.features.share.SharedData
|
||||
import im.vector.app.features.spaces.share.ShareSpaceBottomSheet
|
||||
import im.vector.app.features.themes.ThemeUtils
|
||||
import im.vector.app.features.voice.VoiceFailure
|
||||
import im.vector.app.features.widgets.WidgetActivity
|
||||
import im.vector.app.features.widgets.WidgetArgs
|
||||
import im.vector.app.features.widgets.WidgetKind
|
||||
|
@ -174,11 +182,13 @@ import nl.dionsegijn.konfetti.models.Shape
|
|||
import nl.dionsegijn.konfetti.models.Size
|
||||
import org.billcarsonfr.jsonviewer.JSonViewerDialog
|
||||
import org.commonmark.parser.Parser
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
|
||||
import org.matrix.android.sdk.api.session.events.model.toModel
|
||||
import org.matrix.android.sdk.api.session.room.model.Membership
|
||||
import org.matrix.android.sdk.api.session.room.model.RoomSummary
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
|
||||
|
@ -229,7 +239,8 @@ class RoomDetailFragment @Inject constructor(
|
|||
private val imageContentRenderer: ImageContentRenderer,
|
||||
private val roomDetailPendingActionStore: RoomDetailPendingActionStore,
|
||||
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
|
||||
private val callManager: WebRtcCallManager
|
||||
private val callManager: WebRtcCallManager,
|
||||
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker
|
||||
) :
|
||||
VectorBaseFragment<FragmentRoomDetailBinding>(),
|
||||
TimelineEventController.Callback,
|
||||
|
@ -336,6 +347,7 @@ class RoomDetailFragment @Inject constructor(
|
|||
setupConfBannerView()
|
||||
setupEmojiPopup()
|
||||
setupFailedMessagesWarningView()
|
||||
setupVoiceMessageView()
|
||||
|
||||
views.roomToolbarContentView.debouncedClicks {
|
||||
navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId)
|
||||
|
@ -377,7 +389,12 @@ class RoomDetailFragment @Inject constructor(
|
|||
|
||||
roomDetailViewModel.observeViewEvents {
|
||||
when (it) {
|
||||
is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable)
|
||||
is RoomDetailViewEvents.Failure -> {
|
||||
if (it.throwable is VoiceFailure.UnableToRecord) {
|
||||
onCannotRecord()
|
||||
}
|
||||
showErrorInSnackbar(it.throwable)
|
||||
}
|
||||
is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds)
|
||||
is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it)
|
||||
is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it)
|
||||
|
@ -419,6 +436,11 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun onCannotRecord() {
|
||||
// Update the UI, cancel the animation
|
||||
views.voiceMessageRecorderView.initVoiceRecordingViews()
|
||||
}
|
||||
|
||||
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
|
||||
val intent = VectorCallActivity.newIntent(
|
||||
context = vectorBaseActivity,
|
||||
|
@ -605,6 +627,45 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private val permissionVoiceMessageLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
|
||||
if (allGranted) {
|
||||
// In this case, let the user start again the gesture
|
||||
} else if (deniedPermanently) {
|
||||
vectorBaseActivity.onPermissionDeniedSnackbar(R.string.denied_permission_voice_message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupVoiceMessageView() {
|
||||
views.voiceMessageRecorderView.voiceMessagePlaybackTracker = voiceMessagePlaybackTracker
|
||||
|
||||
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
|
||||
override fun onVoiceRecordingStarted(): Boolean {
|
||||
return if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
|
||||
views.composerLayout.isInvisible = true
|
||||
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
|
||||
vibrate(requireContext())
|
||||
true
|
||||
} else {
|
||||
// Permission dialog is displayed
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVoiceRecordingEnded(isCancelled: Boolean) {
|
||||
views.composerLayout.isInvisible = false
|
||||
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled))
|
||||
}
|
||||
|
||||
override fun onVoiceRecordingPlaybackModeOn() {
|
||||
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
|
||||
}
|
||||
|
||||
override fun onVoicePlaybackButtonClicked() {
|
||||
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) {
|
||||
navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo))
|
||||
}
|
||||
|
@ -910,6 +971,8 @@ class RoomDetailFragment @Inject constructor(
|
|||
autoCompleter.exitSpecialMode()
|
||||
views.composerLayout.collapse()
|
||||
|
||||
views.voiceMessageRecorderView.isVisible = text.isBlank() && vectorPreferences.labsUseVoiceMessage()
|
||||
|
||||
updateComposerText(text)
|
||||
views.composerLayout.views.sendButton.contentDescription = getString(R.string.send)
|
||||
}
|
||||
|
@ -926,7 +989,12 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
|
||||
val messageContent: MessageContent? = event.getLastMessageContent()
|
||||
val nonFormattedBody = messageContent?.body ?: ""
|
||||
val nonFormattedBody = if (messageContent is MessageAudioContent && messageContent.voiceMessageIndicator != null) {
|
||||
val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong())
|
||||
getString(R.string.voice_message_reply_content, formattedDuration)
|
||||
} else {
|
||||
messageContent?.body ?: ""
|
||||
}
|
||||
var formattedBody: CharSequence? = null
|
||||
if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) {
|
||||
val parser = Parser.builder().build()
|
||||
|
@ -956,6 +1024,7 @@ class RoomDetailFragment @Inject constructor(
|
|||
// need to do it here also when not using quick reply
|
||||
focusComposerAndShowKeyboard()
|
||||
views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible
|
||||
views.voiceMessageRecorderView.isVisible = false
|
||||
}
|
||||
}
|
||||
focusComposerAndShowKeyboard()
|
||||
|
@ -996,6 +1065,10 @@ class RoomDetailFragment @Inject constructor(
|
|||
notificationDrawerManager.setCurrentRoom(null)
|
||||
|
||||
roomDetailViewModel.handle(RoomDetailAction.SaveDraft(views.composerLayout.text.toString()))
|
||||
|
||||
// We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed.
|
||||
roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions)
|
||||
views.voiceMessageRecorderView.initVoiceRecordingViews()
|
||||
}
|
||||
|
||||
private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
|
||||
|
@ -1123,6 +1196,7 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
return when (model) {
|
||||
is MessageFileItem,
|
||||
is MessageVoiceItem,
|
||||
is MessageImageVideoItem,
|
||||
is MessageTextItem -> {
|
||||
return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED
|
||||
|
@ -1224,7 +1298,14 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
|
||||
override fun onTextBlankStateChanged(isBlank: Boolean) {
|
||||
// No op
|
||||
if (!views.composerLayout.views.sendButton.isVisible && vectorPreferences.labsUseVoiceMessage()) {
|
||||
// Animate alpha to prevent overlapping with the animation of the send button
|
||||
views.voiceMessageRecorderView.alpha = 0f
|
||||
views.voiceMessageRecorderView.isVisible = true
|
||||
views.voiceMessageRecorderView.animate().alpha(1f).setDuration(300).start()
|
||||
} else {
|
||||
views.voiceMessageRecorderView.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1235,8 +1316,9 @@ class RoomDetailFragment @Inject constructor(
|
|||
return
|
||||
}
|
||||
if (text.isNotBlank()) {
|
||||
// We collapse ASAP, if not there will be a slight anoying delay
|
||||
// We collapse ASAP, if not there will be a slight annoying delay
|
||||
views.composerLayout.collapse(true)
|
||||
views.voiceMessageRecorderView.isVisible = vectorPreferences.labsUseVoiceMessage()
|
||||
lockSendButton = true
|
||||
roomDetailViewModel.handle(RoomDetailAction.SendMessage(text, vectorPreferences.isMarkdownEnabled()))
|
||||
emojiPopup.dismiss()
|
||||
|
@ -1285,22 +1367,28 @@ class RoomDetailFragment @Inject constructor(
|
|||
views.jumpToBottomView.count = summary.notificationCount
|
||||
views.jumpToBottomView.drawBadge = summary.hasUnreadMessages
|
||||
timelineEventController.update(state)
|
||||
views.inviteView.visibility = View.GONE
|
||||
views.inviteView.isVisible = false
|
||||
if (state.tombstoneEvent == null) {
|
||||
if (state.canSendMessage) {
|
||||
views.composerLayout.visibility = View.VISIBLE
|
||||
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
|
||||
views.notificationAreaView.render(NotificationAreaView.State.Hidden)
|
||||
if (!views.voiceMessageRecorderView.isActive()) {
|
||||
views.composerLayout.isVisible = true
|
||||
views.voiceMessageRecorderView.isVisible = vectorPreferences.labsUseVoiceMessage() && views.composerLayout.text?.isBlank().orFalse()
|
||||
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
|
||||
views.notificationAreaView.render(NotificationAreaView.State.Hidden)
|
||||
views.composerLayout.alwaysShowSendButton = !vectorPreferences.labsUseVoiceMessage()
|
||||
}
|
||||
} else {
|
||||
views.composerLayout.visibility = View.GONE
|
||||
views.composerLayout.isVisible = false
|
||||
views.voiceMessageRecorderView.isVisible = false
|
||||
views.notificationAreaView.render(NotificationAreaView.State.NoPermissionToPost)
|
||||
}
|
||||
} else {
|
||||
views.composerLayout.visibility = View.GONE
|
||||
views.composerLayout.isVisible = false
|
||||
views.voiceMessageRecorderView.isVisible = false
|
||||
views.notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent))
|
||||
}
|
||||
} else if (summary?.membership == Membership.INVITE && inviter != null) {
|
||||
views.inviteView.visibility = View.VISIBLE
|
||||
views.inviteView.isVisible = true
|
||||
views.inviteView.render(inviter, VectorInviteView.Mode.LARGE, state.changeMembershipState)
|
||||
// Intercept click event
|
||||
views.inviteView.setOnClickListener { }
|
||||
|
@ -1726,6 +1814,10 @@ class RoomDetailFragment @Inject constructor(
|
|||
navigator.openBigImageViewer(requireActivity(), sharedView, mxcUrl, title)
|
||||
}
|
||||
|
||||
override fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent) {
|
||||
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseVoicePlayback(eventId, messageAudioContent))
|
||||
}
|
||||
|
||||
private fun onShareActionClicked(action: EventSharedAction.Share) {
|
||||
if (action.messageContent is MessageTextContent) {
|
||||
shareText(requireContext(), action.messageContent.body)
|
||||
|
@ -1828,13 +1920,21 @@ class RoomDetailFragment @Inject constructor(
|
|||
roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
|
||||
}
|
||||
is EventSharedAction.Edit -> {
|
||||
roomDetailViewModel.handle(RoomDetailAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
|
||||
if (!views.voiceMessageRecorderView.isActive()) {
|
||||
roomDetailViewModel.handle(RoomDetailAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
|
||||
} else {
|
||||
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
|
||||
}
|
||||
}
|
||||
is EventSharedAction.Quote -> {
|
||||
roomDetailViewModel.handle(RoomDetailAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString()))
|
||||
}
|
||||
is EventSharedAction.Reply -> {
|
||||
roomDetailViewModel.handle(RoomDetailAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString()))
|
||||
if (!views.voiceMessageRecorderView.isActive()) {
|
||||
roomDetailViewModel.handle(RoomDetailAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString()))
|
||||
} else {
|
||||
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
|
||||
}
|
||||
}
|
||||
is EventSharedAction.CopyPermalink -> {
|
||||
val permalink = session.permalinkService().createPermalink(roomDetailArgs.roomId, action.eventId)
|
||||
|
|
|
@ -38,6 +38,7 @@ import im.vector.app.core.extensions.exhaustive
|
|||
import im.vector.app.core.mvrx.runCatchingToAsync
|
||||
import im.vector.app.core.platform.VectorViewModel
|
||||
import im.vector.app.core.resources.StringProvider
|
||||
import im.vector.app.features.attachments.toContentAttachmentData
|
||||
import im.vector.app.features.call.conference.JitsiService
|
||||
import im.vector.app.features.call.lookup.CallProtocolsChecker
|
||||
import im.vector.app.features.call.webrtc.WebRtcCallManager
|
||||
|
@ -46,6 +47,7 @@ import im.vector.app.features.command.ParsedCommand
|
|||
import im.vector.app.features.createdirect.DirectRoomHelper
|
||||
import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy
|
||||
import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider
|
||||
import im.vector.app.features.home.room.detail.composer.VoiceMessageHelper
|
||||
import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator
|
||||
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
|
||||
import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory
|
||||
|
@ -55,6 +57,7 @@ import im.vector.app.features.home.room.typing.TypingHelper
|
|||
import im.vector.app.features.powerlevel.PowerLevelsObservableFactory
|
||||
import im.vector.app.features.session.coroutineScope
|
||||
import im.vector.app.features.settings.VectorPreferences
|
||||
import im.vector.app.features.voice.VoicePlayerHelper
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.rxkotlin.subscribeBy
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
|
@ -118,6 +121,8 @@ class RoomDetailViewModel @AssistedInject constructor(
|
|||
private val chatEffectManager: ChatEffectManager,
|
||||
private val directRoomHelper: DirectRoomHelper,
|
||||
private val jitsiService: JitsiService,
|
||||
private val voiceMessageHelper: VoiceMessageHelper,
|
||||
private val voicePlayerHelper: VoicePlayerHelper,
|
||||
timelineFactory: TimelineFactory
|
||||
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
|
||||
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener {
|
||||
|
@ -320,6 +325,12 @@ class RoomDetailViewModel @AssistedInject constructor(
|
|||
is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action)
|
||||
RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages()
|
||||
RoomDetailAction.ResendAll -> handleResendAll()
|
||||
RoomDetailAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage()
|
||||
is RoomDetailAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled)
|
||||
is RoomDetailAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action)
|
||||
RoomDetailAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage()
|
||||
RoomDetailAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback()
|
||||
RoomDetailAction.EndAllVoiceActions -> handleEndAllVoiceActions()
|
||||
is RoomDetailAction.RoomUpgradeSuccess -> {
|
||||
setState {
|
||||
copy(joinUpgradedRoomAsync = Success(action.replacementRoomId))
|
||||
|
@ -611,6 +622,56 @@ class RoomDetailViewModel @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleStartRecordingVoiceMessage() {
|
||||
try {
|
||||
voiceMessageHelper.startRecording()
|
||||
} catch (failure: Throwable) {
|
||||
_viewEvents.post(RoomDetailViewEvents.Failure(failure))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEndRecordingVoiceMessage(isCancelled: Boolean) {
|
||||
voiceMessageHelper.stopPlayback()
|
||||
if (isCancelled) {
|
||||
voiceMessageHelper.deleteRecording()
|
||||
} else {
|
||||
voiceMessageHelper.stopRecording()?.let { audioType ->
|
||||
if (audioType.duration > 1000) {
|
||||
room.sendMedia(audioType.toContentAttachmentData(), false, emptySet())
|
||||
} else {
|
||||
voiceMessageHelper.deleteRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlayOrPauseVoicePlayback(action: RoomDetailAction.PlayOrPauseVoicePlayback) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Download can fail
|
||||
val audioFile = session.fileService().downloadFile(action.messageAudioContent)
|
||||
// Conversion can fail, fallback to the original file in this case and let the player fail for us
|
||||
val convertedFile = voicePlayerHelper.convertFile(audioFile) ?: audioFile
|
||||
// Play can fail
|
||||
voiceMessageHelper.startOrPausePlayback(action.eventId, convertedFile)
|
||||
} catch (failure: Throwable) {
|
||||
_viewEvents.post(RoomDetailViewEvents.Failure(failure))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlayOrPauseRecordingPlayback() {
|
||||
voiceMessageHelper.startOrPauseRecordingPlayback()
|
||||
}
|
||||
|
||||
private fun handleEndAllVoiceActions() {
|
||||
voiceMessageHelper.stopAllVoiceActions()
|
||||
}
|
||||
|
||||
private fun handlePauseRecordingVoiceMessage() {
|
||||
voiceMessageHelper.pauseRecording()
|
||||
}
|
||||
|
||||
private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled()
|
||||
|
||||
fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state ->
|
||||
|
|
|
@ -24,6 +24,7 @@ import android.view.ViewGroup
|
|||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
import androidx.core.text.toSpannable
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.transition.AutoTransition
|
||||
import androidx.transition.ChangeBounds
|
||||
|
@ -33,6 +34,7 @@ import androidx.transition.TransitionManager
|
|||
import androidx.transition.TransitionSet
|
||||
import im.vector.app.R
|
||||
import im.vector.app.databinding.ComposerLayoutBinding
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
|
||||
/**
|
||||
* Encapsulate the timeline composer UX.
|
||||
|
@ -59,6 +61,13 @@ class TextComposerView @JvmOverloads constructor(
|
|||
val text: Editable?
|
||||
get() = views.composerEditText.text
|
||||
|
||||
var alwaysShowSendButton = false
|
||||
set(value) {
|
||||
field = value
|
||||
val shouldShowSendButton = currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded || text?.isNotBlank().orFalse() || value
|
||||
views.sendButton.isInvisible = !shouldShowSendButton
|
||||
}
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.composer_layout, this)
|
||||
views = ComposerLayoutBinding.bind(this)
|
||||
|
@ -71,16 +80,16 @@ class TextComposerView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
override fun onTextBlankStateChanged(isBlank: Boolean) {
|
||||
callback?.onTextBlankStateChanged(isBlank)
|
||||
val shouldBeVisible = currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded || !isBlank
|
||||
val shouldShowSendButton = currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded || !isBlank || alwaysShowSendButton
|
||||
TransitionManager.endTransitions(this@TextComposerView)
|
||||
if (views.sendButton.isVisible != shouldBeVisible) {
|
||||
if (views.sendButton.isVisible != shouldShowSendButton) {
|
||||
TransitionManager.beginDelayedTransition(
|
||||
this@TextComposerView,
|
||||
AutoTransition().also { it.duration = 150 }
|
||||
)
|
||||
views.sendButton.isVisible = shouldBeVisible
|
||||
views.sendButton.isInvisible = !shouldShowSendButton
|
||||
}
|
||||
callback?.onTextBlankStateChanged(isBlank)
|
||||
}
|
||||
}
|
||||
views.composerRelatedMessageCloseButton.setOnClickListener {
|
||||
|
@ -105,7 +114,9 @@ class TextComposerView @JvmOverloads constructor(
|
|||
}
|
||||
currentConstraintSetId = R.layout.composer_layout_constraint_set_compact
|
||||
applyNewConstraintSet(animate, transitionComplete)
|
||||
views.sendButton.isVisible = !views.composerEditText.text.isNullOrEmpty()
|
||||
|
||||
val shouldShowSendButton = !views.composerEditText.text.isNullOrEmpty() || alwaysShowSendButton
|
||||
views.sendButton.isInvisible = !shouldShowSendButton
|
||||
}
|
||||
|
||||
fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
|
||||
|
@ -115,7 +126,7 @@ class TextComposerView @JvmOverloads constructor(
|
|||
}
|
||||
currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded
|
||||
applyNewConstraintSet(animate, transitionComplete)
|
||||
views.sendButton.isVisible = true
|
||||
views.sendButton.isInvisible = false
|
||||
}
|
||||
|
||||
private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) {
|
||||
|
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.home.room.detail.composer
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import androidx.core.content.FileProvider
|
||||
import im.vector.app.BuildConfig
|
||||
import im.vector.app.core.utils.CountUpTimer
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||
import im.vector.app.features.voice.VoiceFailure
|
||||
import im.vector.app.features.voice.VoiceRecorder
|
||||
import im.vector.app.features.voice.VoiceRecorderProvider
|
||||
import im.vector.lib.multipicker.entity.MultiPickerAudioType
|
||||
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import org.matrix.android.sdk.api.extensions.tryOrNull
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileNotFoundException
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Helper class to record audio for voice messages.
|
||||
*/
|
||||
class VoiceMessageHelper @Inject constructor(
|
||||
private val context: Context,
|
||||
private val playbackTracker: VoiceMessagePlaybackTracker,
|
||||
voiceRecorderProvider: VoiceRecorderProvider
|
||||
) {
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var voiceRecorder: VoiceRecorder = voiceRecorderProvider.provideVoiceRecorder()
|
||||
|
||||
private val amplitudeList = mutableListOf<Int>()
|
||||
|
||||
private var amplitudeTicker: CountUpTimer? = null
|
||||
private var playbackTicker: CountUpTimer? = null
|
||||
|
||||
fun startRecording() {
|
||||
stopPlayback()
|
||||
playbackTracker.makeAllPlaybacksIdle()
|
||||
amplitudeList.clear()
|
||||
|
||||
try {
|
||||
voiceRecorder.startRecord()
|
||||
} catch (failure: Throwable) {
|
||||
throw VoiceFailure.UnableToRecord(failure)
|
||||
}
|
||||
startRecordingAmplitudes()
|
||||
}
|
||||
|
||||
fun stopRecording(): MultiPickerAudioType? {
|
||||
tryOrNull("Cannot stop media recording amplitude") {
|
||||
stopRecordingAmplitudes()
|
||||
}
|
||||
val voiceMessageFile = tryOrNull("Cannot stop media recorder!") {
|
||||
voiceRecorder.stopRecord()
|
||||
voiceRecorder.getVoiceMessageFile()
|
||||
}
|
||||
try {
|
||||
voiceMessageFile?.let {
|
||||
val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it)
|
||||
return outputFileUri
|
||||
?.toMultiPickerAudioType(context)
|
||||
?.apply {
|
||||
waveform = if (amplitudeList.size < 50) {
|
||||
amplitudeList
|
||||
} else {
|
||||
amplitudeList.chunked(amplitudeList.size / 50) { items -> items.maxOrNull() ?: 0 }
|
||||
}
|
||||
}
|
||||
} ?: return null
|
||||
} catch (e: FileNotFoundException) {
|
||||
Timber.e(e, "Cannot stop voice recording")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When entering in playback mode actually
|
||||
*/
|
||||
fun pauseRecording() {
|
||||
voiceRecorder.stopRecord()
|
||||
stopRecordingAmplitudes()
|
||||
}
|
||||
|
||||
fun deleteRecording() {
|
||||
tryOrNull("Cannot stop media recording amplitude") {
|
||||
stopRecordingAmplitudes()
|
||||
}
|
||||
tryOrNull("Cannot stop media recorder!") {
|
||||
voiceRecorder.cancelRecord()
|
||||
}
|
||||
}
|
||||
|
||||
fun startOrPauseRecordingPlayback() {
|
||||
voiceRecorder.getCurrentRecord()?.let {
|
||||
startOrPausePlayback(VoiceMessagePlaybackTracker.RECORDING_ID, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun startOrPausePlayback(id: String, file: File) {
|
||||
stopPlayback()
|
||||
stopRecordingAmplitudes()
|
||||
if (playbackTracker.getPlaybackState(id) is VoiceMessagePlaybackTracker.Listener.State.Playing) {
|
||||
playbackTracker.pausePlayback(id)
|
||||
} else {
|
||||
startPlayback(id, file)
|
||||
playbackTracker.startPlayback(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPlayback(id: String, file: File) {
|
||||
val currentPlaybackTime = playbackTracker.getPlaybackTime(id)
|
||||
|
||||
try {
|
||||
FileInputStream(file).use { fis ->
|
||||
mediaPlayer = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
// Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build()
|
||||
)
|
||||
setDataSource(fis.fd)
|
||||
prepare()
|
||||
start()
|
||||
seekTo(currentPlaybackTime)
|
||||
}
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
throw VoiceFailure.UnableToPlay(failure)
|
||||
}
|
||||
startPlaybackTicker(id)
|
||||
}
|
||||
|
||||
fun stopPlayback() {
|
||||
mediaPlayer?.stop()
|
||||
stopPlaybackTicker()
|
||||
}
|
||||
|
||||
private fun startRecordingAmplitudes() {
|
||||
amplitudeTicker?.stop()
|
||||
amplitudeTicker = CountUpTimer(50).apply {
|
||||
tickListener = object : CountUpTimer.TickListener {
|
||||
override fun onTick(milliseconds: Long) {
|
||||
onAmplitudeTick()
|
||||
}
|
||||
}
|
||||
resume()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAmplitudeTick() {
|
||||
try {
|
||||
val maxAmplitude = voiceRecorder.getMaxAmplitude()
|
||||
amplitudeList.add(maxAmplitude)
|
||||
playbackTracker.updateCurrentRecording(VoiceMessagePlaybackTracker.RECORDING_ID, amplitudeList)
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.e(e, "Cannot get max amplitude. Amplitude recording timer will be stopped.")
|
||||
stopRecordingAmplitudes()
|
||||
} catch (e: RuntimeException) {
|
||||
Timber.e(e, "Cannot get max amplitude (native error). Amplitude recording timer will be stopped.")
|
||||
stopRecordingAmplitudes()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecordingAmplitudes() {
|
||||
amplitudeTicker?.stop()
|
||||
amplitudeTicker = null
|
||||
}
|
||||
|
||||
private fun startPlaybackTicker(id: String) {
|
||||
playbackTicker?.stop()
|
||||
playbackTicker = CountUpTimer().apply {
|
||||
tickListener = object : CountUpTimer.TickListener {
|
||||
override fun onTick(milliseconds: Long) {
|
||||
onPlaybackTick(id)
|
||||
}
|
||||
}
|
||||
resume()
|
||||
}
|
||||
onPlaybackTick(id)
|
||||
}
|
||||
|
||||
private fun onPlaybackTick(id: String) {
|
||||
if (mediaPlayer?.isPlaying.orFalse()) {
|
||||
val currentPosition = mediaPlayer?.currentPosition ?: 0
|
||||
playbackTracker.updateCurrentPlaybackTime(id, currentPosition)
|
||||
} else {
|
||||
playbackTracker.stopPlayback(id)
|
||||
stopPlaybackTicker()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPlaybackTicker() {
|
||||
playbackTicker?.stop()
|
||||
playbackTicker = null
|
||||
}
|
||||
|
||||
fun stopAllVoiceActions() {
|
||||
stopRecording()
|
||||
stopPlayback()
|
||||
deleteRecording()
|
||||
playbackTracker.clear()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,519 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.home.room.detail.composer
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import im.vector.app.BuildConfig
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.hardware.vibrate
|
||||
import im.vector.app.core.utils.CountUpTimer
|
||||
import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import timber.log.Timber
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* Encapsulates the voice message recording view and animations.
|
||||
*/
|
||||
class VoiceMessageRecorderView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
|
||||
|
||||
interface Callback {
|
||||
// Return true if the recording is started
|
||||
fun onVoiceRecordingStarted(): Boolean
|
||||
fun onVoiceRecordingEnded(isCancelled: Boolean)
|
||||
fun onVoiceRecordingPlaybackModeOn()
|
||||
fun onVoicePlaybackButtonClicked()
|
||||
}
|
||||
|
||||
private val views: ViewVoiceMessageRecorderBinding
|
||||
|
||||
var callback: Callback? = null
|
||||
var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker? = null
|
||||
set(value) {
|
||||
field = value
|
||||
value?.track(VoiceMessagePlaybackTracker.RECORDING_ID, this)
|
||||
}
|
||||
|
||||
private var recordingState: RecordingState = RecordingState.NONE
|
||||
|
||||
private var firstX: Float = 0f
|
||||
private var firstY: Float = 0f
|
||||
private var lastX: Float = 0f
|
||||
private var lastY: Float = 0f
|
||||
private var lastDistanceX: Float = 0f
|
||||
private var lastDistanceY: Float = 0f
|
||||
|
||||
private var recordingTicker: CountUpTimer? = null
|
||||
|
||||
private val dimensionConverter = DimensionConverter(context.resources)
|
||||
private val minimumMove = dimensionConverter.dpToPx(16)
|
||||
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
|
||||
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
|
||||
private val rtlXMultiplier = context.resources.getInteger(R.integer.rtl_x_multiplier)
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.view_voice_message_recorder, this)
|
||||
views = ViewVoiceMessageRecorderBinding.bind(this)
|
||||
|
||||
initVoiceRecordingViews()
|
||||
initListeners()
|
||||
}
|
||||
|
||||
fun initVoiceRecordingViews() {
|
||||
recordingState = RecordingState.NONE
|
||||
|
||||
hideRecordingViews(null)
|
||||
stopRecordingTicker()
|
||||
|
||||
views.voiceMessageMicButton.isVisible = true
|
||||
views.voiceMessageSendButton.isVisible = false
|
||||
|
||||
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
|
||||
}
|
||||
|
||||
private fun initListeners() {
|
||||
views.voiceMessageSendButton.setOnClickListener {
|
||||
stopRecordingTicker()
|
||||
hideRecordingViews(isCancelled = false)
|
||||
views.voiceMessageSendButton.isVisible = false
|
||||
recordingState = RecordingState.NONE
|
||||
}
|
||||
|
||||
views.voiceMessageDeletePlayback.setOnClickListener {
|
||||
stopRecordingTicker()
|
||||
hideRecordingViews(isCancelled = true)
|
||||
views.voiceMessageSendButton.isVisible = false
|
||||
recordingState = RecordingState.NONE
|
||||
}
|
||||
|
||||
views.voicePlaybackWaveform.setOnClickListener {
|
||||
if (recordingState != RecordingState.PLAYBACK) {
|
||||
recordingState = RecordingState.PLAYBACK
|
||||
showPlaybackViews()
|
||||
}
|
||||
}
|
||||
|
||||
views.voicePlaybackControlButton.setOnClickListener {
|
||||
callback?.onVoicePlaybackButtonClicked()
|
||||
}
|
||||
|
||||
views.voiceMessageMicButton.setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
handleMicActionDown(event)
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
handleMicActionUp()
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (recordingState == RecordingState.CANCELLED) return@setOnTouchListener false
|
||||
handleMicActionMove(event)
|
||||
true
|
||||
}
|
||||
else ->
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMicActionDown(event: MotionEvent) {
|
||||
val recordingStarted = callback?.onVoiceRecordingStarted().orFalse()
|
||||
if (recordingStarted) {
|
||||
startRecordingTicker()
|
||||
renderToast(context.getString(R.string.voice_message_release_to_send_toast))
|
||||
recordingState = RecordingState.STARTED
|
||||
showRecordingViews()
|
||||
|
||||
firstX = event.rawX
|
||||
firstY = event.rawY
|
||||
lastX = firstX
|
||||
lastY = firstY
|
||||
lastDistanceX = 0F
|
||||
lastDistanceY = 0F
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMicActionUp() {
|
||||
if (recordingState != RecordingState.LOCKED && recordingState != RecordingState.NONE) {
|
||||
stopRecordingTicker()
|
||||
val isCancelled = recordingState == RecordingState.NONE || recordingState == RecordingState.CANCELLED
|
||||
recordingState = RecordingState.NONE
|
||||
hideRecordingViews(isCancelled = isCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMicActionMove(event: MotionEvent) {
|
||||
val currentX = event.rawX
|
||||
val currentY = event.rawY
|
||||
|
||||
val distanceX = abs(firstX - currentX)
|
||||
val distanceY = abs(firstY - currentY)
|
||||
|
||||
val isRecordingStateChanged = updateRecordingState(currentX, currentY, distanceX, distanceY)
|
||||
|
||||
when (recordingState) {
|
||||
RecordingState.CANCELLING -> {
|
||||
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
|
||||
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
|
||||
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
|
||||
val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
|
||||
views.voiceMessageSlideToCancel.alpha = reducedAlpha
|
||||
views.voiceMessageTimerIndicator.alpha = reducedAlpha
|
||||
views.voiceMessageTimer.alpha = reducedAlpha
|
||||
views.voiceMessageLockBackground.isVisible = false
|
||||
views.voiceMessageLockImage.isVisible = false
|
||||
views.voiceMessageLockArrow.isVisible = false
|
||||
// Reset Y translations
|
||||
views.voiceMessageMicButton.translationY = 0F
|
||||
views.voiceMessageLockArrow.translationY = 0F
|
||||
}
|
||||
RecordingState.LOCKING -> {
|
||||
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
|
||||
val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
|
||||
views.voiceMessageMicButton.translationY = translationAmount
|
||||
views.voiceMessageLockArrow.translationY = translationAmount
|
||||
views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
|
||||
// Reset X translations
|
||||
views.voiceMessageMicButton.translationX = 0F
|
||||
views.voiceMessageSlideToCancel.translationX = 0F
|
||||
}
|
||||
RecordingState.CANCELLED -> {
|
||||
hideRecordingViews(isCancelled = true)
|
||||
}
|
||||
RecordingState.LOCKED -> {
|
||||
if (isRecordingStateChanged) { // Do not update views if it was already in locked state.
|
||||
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
|
||||
views.voiceMessageLockImage.postDelayed({
|
||||
showRecordingLockedViews()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
RecordingState.STARTED -> {
|
||||
showRecordingViews()
|
||||
}
|
||||
RecordingState.NONE -> Timber.d("VoiceMessageRecorderView shouldn't be in NONE state while moving.")
|
||||
RecordingState.PLAYBACK -> Timber.d("VoiceMessageRecorderView shouldn't be in PLAYBACK state while moving.")
|
||||
}
|
||||
lastX = currentX
|
||||
lastY = currentY
|
||||
lastDistanceX = distanceX
|
||||
lastDistanceY = distanceY
|
||||
}
|
||||
|
||||
private fun updateRecordingState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): Boolean {
|
||||
val previousRecordingState = recordingState
|
||||
if (recordingState == RecordingState.STARTED) {
|
||||
// Determine if cancelling or locking for the first move action.
|
||||
if (((currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1))
|
||||
&& distanceX > distanceY) {
|
||||
recordingState = RecordingState.CANCELLING
|
||||
} else if (currentY < firstY && distanceY > distanceX) {
|
||||
recordingState = RecordingState.LOCKING
|
||||
}
|
||||
} else if (recordingState == RecordingState.CANCELLING) {
|
||||
// Check if cancelling conditions met, also check if it should be initial state
|
||||
if (distanceX < minimumMove && distanceX < lastDistanceX) {
|
||||
recordingState = RecordingState.STARTED
|
||||
} else if (shouldCancelRecording(distanceX)) {
|
||||
recordingState = RecordingState.CANCELLED
|
||||
}
|
||||
} else if (recordingState == RecordingState.LOCKING) {
|
||||
// Check if locking conditions met, also check if it should be initial state
|
||||
if (distanceY < minimumMove && distanceY < lastDistanceY) {
|
||||
recordingState = RecordingState.STARTED
|
||||
} else if (shouldLockRecording(distanceY)) {
|
||||
recordingState = RecordingState.LOCKED
|
||||
}
|
||||
}
|
||||
return previousRecordingState != recordingState
|
||||
}
|
||||
|
||||
private fun shouldCancelRecording(distanceX: Float): Boolean {
|
||||
return distanceX >= distanceToCancel
|
||||
}
|
||||
|
||||
private fun shouldLockRecording(distanceY: Float): Boolean {
|
||||
return distanceY >= distanceToLock
|
||||
}
|
||||
|
||||
private fun startRecordingTicker() {
|
||||
recordingTicker?.stop()
|
||||
recordingTicker = CountUpTimer().apply {
|
||||
tickListener = object : CountUpTimer.TickListener {
|
||||
override fun onTick(milliseconds: Long) {
|
||||
onRecordingTick(milliseconds)
|
||||
}
|
||||
}
|
||||
resume()
|
||||
}
|
||||
onRecordingTick(0L)
|
||||
}
|
||||
|
||||
private fun onRecordingTick(milliseconds: Long) {
|
||||
renderRecordingTimer(milliseconds / 1_000)
|
||||
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
|
||||
if (timeDiffToRecordingLimit <= 0) {
|
||||
views.voiceMessageRecordingLayout.post {
|
||||
recordingState = RecordingState.PLAYBACK
|
||||
showPlaybackViews()
|
||||
stopRecordingTicker()
|
||||
}
|
||||
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
|
||||
views.voiceMessageRecordingLayout.post {
|
||||
renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, floor(timeDiffToRecordingLimit / 1000f).toInt()))
|
||||
vibrate(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderToast(message: String) {
|
||||
views.voiceMessageToast.removeCallbacks(hideToastRunnable)
|
||||
views.voiceMessageToast.text = message
|
||||
views.voiceMessageToast.isVisible = true
|
||||
views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
|
||||
}
|
||||
|
||||
private fun hideToast() {
|
||||
views.voiceMessageToast.isVisible = false
|
||||
}
|
||||
|
||||
private val hideToastRunnable = Runnable {
|
||||
views.voiceMessageToast.isVisible = false
|
||||
}
|
||||
|
||||
private fun renderRecordingTimer(recordingTimeMillis: Long) {
|
||||
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
|
||||
if (recordingState == RecordingState.LOCKED) {
|
||||
views.voicePlaybackTime.apply {
|
||||
post {
|
||||
text = formattedTimerText
|
||||
}
|
||||
}
|
||||
} else {
|
||||
views.voiceMessageTimer.post {
|
||||
views.voiceMessageTimer.text = formattedTimerText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderRecordingWaveform(amplitudeList: List<Int>) {
|
||||
views.voicePlaybackWaveform.apply {
|
||||
post {
|
||||
amplitudeList.forEach { amplitude ->
|
||||
update(amplitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecordingTicker() {
|
||||
recordingTicker?.stop()
|
||||
recordingTicker = null
|
||||
}
|
||||
|
||||
private fun showRecordingViews() {
|
||||
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
|
||||
views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
|
||||
setMargins(0, 0, 0, 0)
|
||||
}
|
||||
views.voiceMessageMicButton.animate().scaleX(1.5f).scaleY(1.5f).setDuration(300).start()
|
||||
|
||||
views.voiceMessageLockBackground.isVisible = true
|
||||
views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
|
||||
views.voiceMessageLockImage.isVisible = true
|
||||
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
|
||||
views.voiceMessageLockImage.animate().setDuration(500).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
|
||||
views.voiceMessageLockArrow.isVisible = true
|
||||
views.voiceMessageLockArrow.alpha = 1f
|
||||
views.voiceMessageSlideToCancel.isVisible = true
|
||||
views.voiceMessageTimerIndicator.isVisible = true
|
||||
views.voiceMessageTimer.isVisible = true
|
||||
views.voiceMessageSlideToCancel.alpha = 1f
|
||||
views.voiceMessageTimerIndicator.alpha = 1f
|
||||
views.voiceMessageTimer.alpha = 1f
|
||||
views.voiceMessageSendButton.isVisible = false
|
||||
}
|
||||
|
||||
private fun hideRecordingViews(isCancelled: Boolean?) {
|
||||
// We need to animate the lock image first
|
||||
if (recordingState != RecordingState.LOCKED || isCancelled.orFalse()) {
|
||||
views.voiceMessageLockImage.isVisible = false
|
||||
views.voiceMessageLockImage.animate().translationY(0f).start()
|
||||
views.voiceMessageLockBackground.isVisible = false
|
||||
views.voiceMessageLockBackground.animate().translationY(0f).start()
|
||||
} else {
|
||||
animateLockImageWithBackground()
|
||||
}
|
||||
views.voiceMessageLockArrow.isVisible = false
|
||||
views.voiceMessageLockArrow.animate().translationY(0f).start()
|
||||
views.voiceMessageSlideToCancel.isVisible = false
|
||||
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
|
||||
views.voiceMessagePlaybackLayout.isVisible = false
|
||||
|
||||
if (recordingState != RecordingState.LOCKED) {
|
||||
views.voiceMessageMicButton
|
||||
.animate()
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
.translationX(0f)
|
||||
.translationY(0f)
|
||||
.setDuration(150)
|
||||
.withEndAction {
|
||||
views.voiceMessageTimerIndicator.isVisible = false
|
||||
views.voiceMessageTimer.isVisible = false
|
||||
resetMicButtonUi()
|
||||
isCancelled?.let {
|
||||
callback?.onVoiceRecordingEnded(it)
|
||||
}
|
||||
}
|
||||
.start()
|
||||
} else {
|
||||
views.voiceMessageTimerIndicator.isVisible = false
|
||||
views.voiceMessageTimer.isVisible = false
|
||||
views.voiceMessageMicButton.apply {
|
||||
scaleX = 1f
|
||||
scaleY = 1f
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
isCancelled?.let {
|
||||
callback?.onVoiceRecordingEnded(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Hide toasts if user cancelled recording before the timeout of the toast.
|
||||
if (recordingState == RecordingState.CANCELLED || recordingState == RecordingState.NONE) {
|
||||
hideToast()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetMicButtonUi() {
|
||||
views.voiceMessageMicButton.isVisible = true
|
||||
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
|
||||
views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
|
||||
if (rtlXMultiplier == -1) {
|
||||
// RTL
|
||||
setMargins(dimensionConverter.dpToPx(12), 0, 0, dimensionConverter.dpToPx(12))
|
||||
} else {
|
||||
setMargins(0, 0, dimensionConverter.dpToPx(12), dimensionConverter.dpToPx(12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateLockImageWithBackground() {
|
||||
views.voiceMessageLockBackground.updateLayoutParams {
|
||||
height = dimensionConverter.dpToPx(78)
|
||||
}
|
||||
views.voiceMessageLockBackground.apply {
|
||||
animate()
|
||||
.scaleX(0f)
|
||||
.scaleY(0f)
|
||||
.setDuration(400L)
|
||||
.withEndAction {
|
||||
updateLayoutParams {
|
||||
height = dimensionConverter.dpToPx(180)
|
||||
}
|
||||
isVisible = false
|
||||
scaleX = 1f
|
||||
scaleY = 1f
|
||||
animate().translationY(0f).start()
|
||||
}
|
||||
.start()
|
||||
}
|
||||
|
||||
// Lock image animation
|
||||
views.voiceMessageMicButton.isInvisible = true
|
||||
views.voiceMessageLockImage.apply {
|
||||
isVisible = true
|
||||
animate()
|
||||
.scaleX(0f)
|
||||
.scaleY(0f)
|
||||
.setDuration(400L)
|
||||
.withEndAction {
|
||||
isVisible = false
|
||||
scaleX = 1f
|
||||
scaleY = 1f
|
||||
translationY = 0f
|
||||
resetMicButtonUi()
|
||||
}
|
||||
.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showRecordingLockedViews() {
|
||||
hideRecordingViews(null)
|
||||
views.voiceMessagePlaybackLayout.isVisible = true
|
||||
views.voiceMessagePlaybackTimerIndicator.isVisible = true
|
||||
views.voicePlaybackControlButton.isVisible = false
|
||||
views.voiceMessageSendButton.isVisible = true
|
||||
renderToast(context.getString(R.string.voice_message_tap_to_stop_toast))
|
||||
}
|
||||
|
||||
private fun showPlaybackViews() {
|
||||
views.voiceMessagePlaybackTimerIndicator.isVisible = false
|
||||
views.voicePlaybackControlButton.isVisible = true
|
||||
callback?.onVoiceRecordingPlaybackModeOn()
|
||||
}
|
||||
|
||||
private enum class RecordingState {
|
||||
NONE,
|
||||
STARTED,
|
||||
CANCELLING,
|
||||
CANCELLED,
|
||||
LOCKING,
|
||||
LOCKED,
|
||||
PLAYBACK
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the voice message is recording or is in playback mode
|
||||
*/
|
||||
fun isActive() = recordingState !in listOf(RecordingState.NONE, RecordingState.CANCELLED)
|
||||
|
||||
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
|
||||
when (state) {
|
||||
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
|
||||
renderRecordingWaveform(state.amplitudeList)
|
||||
}
|
||||
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
|
||||
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
|
||||
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
|
||||
views.voicePlaybackTime.text = formattedTimerText
|
||||
}
|
||||
is VoiceMessagePlaybackTracker.Listener.State.Paused,
|
||||
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
|
||||
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -65,6 +65,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel
|
|||
import org.matrix.android.sdk.api.session.room.model.Membership
|
||||
import org.matrix.android.sdk.api.session.room.model.ReadReceipt
|
||||
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
|
||||
import org.matrix.android.sdk.api.session.room.timeline.Timeline
|
||||
|
@ -111,6 +112,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
|||
|
||||
// Introduce ViewModel scoped component (or Hilt?)
|
||||
fun getPreviewUrlRetriever(): PreviewUrlRetriever
|
||||
|
||||
fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent)
|
||||
}
|
||||
|
||||
interface ReactionPillCallback {
|
||||
|
|
|
@ -25,6 +25,7 @@ import android.text.style.ForegroundColorSpan
|
|||
import android.view.View
|
||||
import dagger.Lazy
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.epoxy.ClickListener
|
||||
import im.vector.app.core.epoxy.VectorEpoxyModel
|
||||
import im.vector.app.core.files.LocalFilesHelper
|
||||
import im.vector.app.core.resources.ColorProvider
|
||||
|
@ -38,6 +39,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStat
|
|||
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem_
|
||||
|
@ -50,6 +52,8 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageOptionsItem_
|
|||
import im.vector.app.features.home.room.detail.timeline.item.MessagePollItem_
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem_
|
||||
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_
|
||||
import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem
|
||||
|
@ -110,7 +114,8 @@ class MessageItemFactory @Inject constructor(
|
|||
private val avatarSizeProvider: AvatarSizeProvider,
|
||||
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
|
||||
private val spanUtils: SpanUtils,
|
||||
private val session: Session) {
|
||||
private val session: Session,
|
||||
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker) {
|
||||
|
||||
// TODO inject this properly?
|
||||
private var roomId: String = ""
|
||||
|
@ -154,7 +159,13 @@ class MessageItemFactory @Inject constructor(
|
|||
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
|
||||
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes)
|
||||
is MessageAudioContent -> {
|
||||
if (messageContent.voiceMessageIndicator != null) {
|
||||
buildVoiceMessageItem(params, messageContent, informationData, highlight, attributes)
|
||||
} else {
|
||||
buildAudioMessageItem(messageContent, informationData, highlight, attributes)
|
||||
}
|
||||
}
|
||||
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessagePollResponseContent -> noticeItemFactory.create(params)
|
||||
|
@ -223,6 +234,46 @@ class MessageItemFactory @Inject constructor(
|
|||
.iconRes(R.drawable.ic_headphones)
|
||||
}
|
||||
|
||||
private fun buildVoiceMessageItem(params: TimelineItemFactoryParams,
|
||||
messageContent: MessageAudioContent,
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
informationData: MessageInformationData,
|
||||
highlight: Boolean,
|
||||
attributes: AbsMessageItem.Attributes): MessageVoiceItem? {
|
||||
val fileUrl = messageContent.getFileUrl()?.let {
|
||||
if (informationData.sentByMe && !informationData.sendState.isSent()) {
|
||||
it
|
||||
} else {
|
||||
it.takeIf { it.startsWith("mxc://") }
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
val playbackControlButtonClickListener: ClickListener = object : ClickListener {
|
||||
override fun invoke(view: View) {
|
||||
params.callback?.onVoiceControlButtonClicked(informationData.eventId, messageContent)
|
||||
}
|
||||
}
|
||||
|
||||
return MessageVoiceItem_()
|
||||
.attributes(attributes)
|
||||
.duration(messageContent.audioWaveformInfo?.duration ?: 0)
|
||||
.waveform(messageContent.audioWaveformInfo?.waveform?.toFft().orEmpty())
|
||||
.playbackControlButtonClickListener(playbackControlButtonClickListener)
|
||||
.voiceMessagePlaybackTracker(voiceMessagePlaybackTracker)
|
||||
.izLocalFile(localFilesHelper.isLocalFile(fileUrl))
|
||||
.izDownloaded(session.fileService().isFileInCache(
|
||||
fileUrl,
|
||||
messageContent.getFileName(),
|
||||
messageContent.mimeType,
|
||||
messageContent.encryptedFileInfo?.toElementToDecrypt())
|
||||
)
|
||||
.mxcUrl(fileUrl)
|
||||
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
|
||||
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
|
||||
.highlighted(highlight)
|
||||
.leftGuideline(avatarSizeProvider.leftGuideline)
|
||||
}
|
||||
|
||||
private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent,
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
informationData: MessageInformationData,
|
||||
|
@ -571,6 +622,13 @@ class MessageItemFactory @Inject constructor(
|
|||
.highlighted(highlight)
|
||||
}
|
||||
|
||||
private fun List<Int>?.toFft(): List<Int>? {
|
||||
return this?.map {
|
||||
// Value comes from AudioRecordView.maxReportableAmp, and 1024 is the max value in the Matrix spec
|
||||
it * 22760 / 1024
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT = 5
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import im.vector.app.core.resources.StringProvider
|
|||
import me.gujun.android.span.span
|
||||
import org.matrix.android.sdk.api.session.events.model.EventType
|
||||
import org.matrix.android.sdk.api.session.events.model.toModel
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageType
|
||||
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS
|
||||
|
@ -72,7 +73,11 @@ class DisplayableEventFormatter @Inject constructor(
|
|||
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor)
|
||||
}
|
||||
MessageType.MSGTYPE_AUDIO -> {
|
||||
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor)
|
||||
if ((messageContent as? MessageAudioContent)?.voiceMessageIndicator != null) {
|
||||
return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_voice_message), appendAuthor)
|
||||
} else {
|
||||
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor)
|
||||
}
|
||||
}
|
||||
MessageType.MSGTYPE_VIDEO -> {
|
||||
return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor)
|
||||
|
|
|
@ -76,7 +76,11 @@ class EventDetailsFormatter @Inject constructor(
|
|||
*/
|
||||
private fun formatForAudioMessage(event: Event): CharSequence? {
|
||||
return event.getClearContent().toModel<MessageAudioContent>()?.audioInfo
|
||||
?.let { "${it.duration.asDuration()} - ${it.size.asFileSize()}" }
|
||||
?.let { audioInfo ->
|
||||
listOfNotNull(audioInfo.duration?.asDuration(), audioInfo.size?.asFileSize())
|
||||
.joinToString(" - ")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.home.room.detail.timeline.helper
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import im.vector.app.core.di.ScreenScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ScreenScope
|
||||
class VoiceMessagePlaybackTracker @Inject constructor() {
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val listeners = mutableMapOf<String, Listener>()
|
||||
private val states = mutableMapOf<String, Listener.State>()
|
||||
|
||||
fun track(id: String, listener: Listener) {
|
||||
listeners[id] = listener
|
||||
|
||||
val currentState = states[id] ?: Listener.State.Idle
|
||||
mainHandler.post {
|
||||
listener.onUpdate(currentState)
|
||||
}
|
||||
}
|
||||
|
||||
fun unTrack(id: String) {
|
||||
listeners.remove(id)
|
||||
}
|
||||
|
||||
fun makeAllPlaybacksIdle() {
|
||||
listeners.keys.forEach { key ->
|
||||
setState(key, Listener.State.Idle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set state and notify the listeners
|
||||
*/
|
||||
private fun setState(key: String, state: Listener.State) {
|
||||
states[key] = state
|
||||
mainHandler.post {
|
||||
listeners[key]?.onUpdate(state)
|
||||
}
|
||||
}
|
||||
|
||||
fun startPlayback(id: String) {
|
||||
val currentPlaybackTime = getPlaybackTime(id)
|
||||
val currentState = Listener.State.Playing(currentPlaybackTime)
|
||||
setState(id, currentState)
|
||||
// Pause any active playback
|
||||
states
|
||||
.filter { it.key != id }
|
||||
.keys
|
||||
.forEach { key ->
|
||||
val state = states[key]
|
||||
if (state is Listener.State.Playing) {
|
||||
setState(key, Listener.State.Paused(state.playbackTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pausePlayback(id: String) {
|
||||
val currentPlaybackTime = getPlaybackTime(id)
|
||||
setState(id, Listener.State.Paused(currentPlaybackTime))
|
||||
}
|
||||
|
||||
fun stopPlayback(id: String) {
|
||||
setState(id, Listener.State.Idle)
|
||||
}
|
||||
|
||||
fun updateCurrentPlaybackTime(id: String, time: Int) {
|
||||
setState(id, Listener.State.Playing(time))
|
||||
}
|
||||
|
||||
fun updateCurrentRecording(id: String, amplitudeList: List<Int>) {
|
||||
setState(id, Listener.State.Recording(amplitudeList))
|
||||
}
|
||||
|
||||
fun getPlaybackState(id: String) = states[id]
|
||||
|
||||
fun getPlaybackTime(id: String): Int {
|
||||
return when (val state = states[id]) {
|
||||
is Listener.State.Playing -> state.playbackTime
|
||||
is Listener.State.Paused -> state.playbackTime
|
||||
/* Listener.State.Idle, */
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
listeners.forEach {
|
||||
it.value.onUpdate(Listener.State.Idle)
|
||||
}
|
||||
listeners.clear()
|
||||
states.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RECORDING_ID = "RECORDING_ID"
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
|
||||
fun onUpdate(state: State)
|
||||
|
||||
sealed class State {
|
||||
object Idle : State()
|
||||
data class Playing(val playbackTime: Int) : State()
|
||||
data class Paused(val playbackTime: Int) : State()
|
||||
data class Recording(val amplitudeList: List<Int>) : State()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -43,17 +43,28 @@ abstract class BaseEventItem<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>
|
|||
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
|
||||
lateinit var dimensionConverter: DimensionConverter
|
||||
|
||||
protected var ignoreSendStatusVisibility = false
|
||||
|
||||
@CallSuper
|
||||
override fun bind(holder: H) {
|
||||
super.bind(holder)
|
||||
holder.leftGuideline.updateLayoutParams<RelativeLayout.LayoutParams> {
|
||||
this.marginStart = leftGuideline
|
||||
}
|
||||
// Ignore visibility of the send status icon?
|
||||
holder.contentContainer.updateLayoutParams<RelativeLayout.LayoutParams> {
|
||||
if (ignoreSendStatusVisibility) {
|
||||
addRule(RelativeLayout.ALIGN_PARENT_END)
|
||||
} else {
|
||||
removeRule(RelativeLayout.ALIGN_PARENT_END)
|
||||
}
|
||||
}
|
||||
holder.checkableBackground.isChecked = highlighted
|
||||
}
|
||||
|
||||
abstract class BaseHolder(@IdRes val stubId: Int) : VectorEpoxyHolder() {
|
||||
val leftGuideline by bind<View>(R.id.messageStartGuideline)
|
||||
val contentContainer by bind<View>(R.id.viewStubContainer)
|
||||
val checkableBackground by bind<CheckableView>(R.id.messageSelectedBackground)
|
||||
|
||||
override fun bindView(itemView: View) {
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.home.room.detail.timeline.item
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import com.visualizer.amplitude.AudioRecordView
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.epoxy.ClickListener
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||
|
||||
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
|
||||
abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
|
||||
|
||||
init {
|
||||
ignoreSendStatusVisibility = true
|
||||
}
|
||||
|
||||
@EpoxyAttribute
|
||||
var mxcUrl: String = ""
|
||||
|
||||
@EpoxyAttribute
|
||||
var duration: Int = 0
|
||||
|
||||
@EpoxyAttribute
|
||||
var waveform: List<Int> = emptyList()
|
||||
|
||||
@EpoxyAttribute
|
||||
var izLocalFile = false
|
||||
|
||||
@EpoxyAttribute
|
||||
var izDownloaded = false
|
||||
|
||||
@EpoxyAttribute
|
||||
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
|
||||
|
||||
@EpoxyAttribute
|
||||
lateinit var contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder
|
||||
|
||||
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
|
||||
var playbackControlButtonClickListener: ClickListener? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
lateinit var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
renderSendState(holder.voiceLayout, null)
|
||||
if (!attributes.informationData.sendState.hasFailed()) {
|
||||
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout)
|
||||
} else {
|
||||
holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_cross)
|
||||
holder.progressLayout.isVisible = false
|
||||
}
|
||||
|
||||
holder.voicePlaybackWaveform.setOnLongClickListener(attributes.itemLongClickListener)
|
||||
|
||||
holder.voicePlaybackWaveform.post {
|
||||
holder.voicePlaybackWaveform.recreate()
|
||||
waveform.forEach { amplitude ->
|
||||
holder.voicePlaybackWaveform.update(amplitude)
|
||||
}
|
||||
}
|
||||
|
||||
holder.voicePlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) }
|
||||
|
||||
voiceMessagePlaybackTracker.track(attributes.informationData.eventId, object : VoiceMessagePlaybackTracker.Listener {
|
||||
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
|
||||
when (state) {
|
||||
is VoiceMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder)
|
||||
is VoiceMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state)
|
||||
is VoiceMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun renderIdleState(holder: Holder) {
|
||||
holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
|
||||
holder.voicePlaybackTime.text = formatPlaybackTime(duration)
|
||||
}
|
||||
|
||||
private fun renderPlayingState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Playing) {
|
||||
holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
|
||||
holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime)
|
||||
}
|
||||
|
||||
private fun renderPausedState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Paused) {
|
||||
holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
|
||||
holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime)
|
||||
}
|
||||
|
||||
private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong())
|
||||
|
||||
override fun unbind(holder: Holder) {
|
||||
super.unbind(holder)
|
||||
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
|
||||
contentDownloadStateTrackerBinder.unbind(mxcUrl)
|
||||
voiceMessagePlaybackTracker.unTrack(attributes.informationData.eventId)
|
||||
}
|
||||
|
||||
override fun getViewType() = STUB_ID
|
||||
|
||||
class Holder : AbsMessageItem.Holder(STUB_ID) {
|
||||
val voiceLayout by bind<ViewGroup>(R.id.voiceLayout)
|
||||
val voicePlaybackControlButton by bind<ImageButton>(R.id.voicePlaybackControlButton)
|
||||
val voicePlaybackTime by bind<TextView>(R.id.voicePlaybackTime)
|
||||
val voicePlaybackWaveform by bind<AudioRecordView>(R.id.voicePlaybackWaveform)
|
||||
val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STUB_ID = R.id.messageContentVoiceStub
|
||||
}
|
||||
}
|
|
@ -154,6 +154,8 @@ class VectorPreferences @Inject constructor(private val context: Context) {
|
|||
const val SETTINGS_LABS_USE_RESTRICTED_JOIN_RULE = "SETTINGS_LABS_USE_RESTRICTED_JOIN_RULE"
|
||||
const val SETTINGS_LABS_SPACES_HOME_AS_ORPHAN = "SETTINGS_LABS_SPACES_HOME_AS_ORPHAN"
|
||||
|
||||
const val SETTINGS_LABS_VOICE_MESSAGE = "SETTINGS_LABS_VOICE_MESSAGE"
|
||||
|
||||
private const val SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY = "SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY"
|
||||
private const val SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY = "SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY"
|
||||
private const val SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY = "SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY"
|
||||
|
@ -987,4 +989,8 @@ class VectorPreferences @Inject constructor(private val context: Context) {
|
|||
putInt(TAKE_PHOTO_VIDEO_MODE, mode)
|
||||
}
|
||||
}
|
||||
|
||||
fun labsUseVoiceMessage(): Boolean {
|
||||
return defaultPrefs.getBoolean(SETTINGS_LABS_VOICE_MESSAGE, false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
abstract class AbstractVoiceRecorder(
|
||||
context: Context,
|
||||
private val filenameExt: String
|
||||
) : VoiceRecorder {
|
||||
private val outputDirectory = File(context.cacheDir, "voice_records")
|
||||
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
private var outputFile: File? = null
|
||||
|
||||
init {
|
||||
if (!outputDirectory.exists()) {
|
||||
outputDirectory.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun setOutputFormat(mediaRecorder: MediaRecorder)
|
||||
abstract fun convertFile(recordedFile: File?): File?
|
||||
|
||||
private fun init() {
|
||||
MediaRecorder().let {
|
||||
it.setAudioSource(MediaRecorder.AudioSource.DEFAULT)
|
||||
setOutputFormat(it)
|
||||
it.setAudioEncodingBitRate(24000)
|
||||
it.setAudioSamplingRate(48000)
|
||||
mediaRecorder = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun startRecord() {
|
||||
init()
|
||||
outputFile = File(outputDirectory, "Voice message.$filenameExt")
|
||||
|
||||
val mr = mediaRecorder ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
mr.setOutputFile(outputFile)
|
||||
} else {
|
||||
mr.setOutputFile(FileOutputStream(outputFile).fd)
|
||||
}
|
||||
mr.prepare()
|
||||
mr.start()
|
||||
}
|
||||
|
||||
override fun stopRecord() {
|
||||
// Can throw when the record is less than 1 second.
|
||||
mediaRecorder?.let {
|
||||
it.stop()
|
||||
it.reset()
|
||||
it.release()
|
||||
}
|
||||
mediaRecorder = null
|
||||
}
|
||||
|
||||
override fun cancelRecord() {
|
||||
stopRecord()
|
||||
|
||||
outputFile?.delete()
|
||||
outputFile = null
|
||||
}
|
||||
|
||||
override fun getMaxAmplitude(): Int {
|
||||
return mediaRecorder?.maxAmplitude ?: 0
|
||||
}
|
||||
|
||||
override fun getCurrentRecord(): File? {
|
||||
return outputFile
|
||||
}
|
||||
|
||||
override fun getVoiceMessageFile(): File? {
|
||||
return convertFile(outputFile)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.voice
|
||||
|
||||
sealed class VoiceFailure(cause: Throwable? = null) : Throwable(cause = cause) {
|
||||
data class UnableToPlay(val throwable: Throwable) : VoiceFailure(throwable)
|
||||
data class UnableToRecord(val throwable: Throwable) : VoiceFailure(throwable)
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class VoicePlayerHelper @Inject constructor(
|
||||
context: Context
|
||||
) {
|
||||
private val outputDirectory = File(context.cacheDir, "voice_records")
|
||||
|
||||
init {
|
||||
if (!outputDirectory.exists()) {
|
||||
outputDirectory.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the file is encoded using aac audio codec
|
||||
*/
|
||||
fun convertFile(file: File): File? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Nothing to do
|
||||
file
|
||||
} else {
|
||||
// Convert to mp4
|
||||
val targetFile = File(outputDirectory, "Voice.mp4")
|
||||
if (targetFile.exists()) {
|
||||
targetFile.delete()
|
||||
}
|
||||
val start = System.currentTimeMillis()
|
||||
val session = FFmpegKit.execute("-i \"${file.path}\" -c:a aac \"${targetFile.path}\"")
|
||||
val duration = System.currentTimeMillis() - start
|
||||
Timber.d("Convert to mp4 in $duration ms. Size in bytes from ${file.length()} to ${targetFile.length()}")
|
||||
return when {
|
||||
ReturnCode.isSuccess(session.returnCode) -> {
|
||||
// SUCCESS
|
||||
targetFile
|
||||
}
|
||||
ReturnCode.isCancel(session.returnCode) -> {
|
||||
// CANCEL
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
// FAILURE
|
||||
Timber.e("Command failed with state ${session.state} and rc ${session.returnCode}.${session.failStackTrace}")
|
||||
// TODO throw?
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.voice
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface VoiceRecorder {
|
||||
/**
|
||||
* Start the recording
|
||||
*/
|
||||
fun startRecord()
|
||||
|
||||
/**
|
||||
* Stop the recording
|
||||
*/
|
||||
fun stopRecord()
|
||||
|
||||
/**
|
||||
* Remove the file
|
||||
*/
|
||||
fun cancelRecord()
|
||||
|
||||
fun getMaxAmplitude(): Int
|
||||
|
||||
/**
|
||||
* Not guaranteed to be a ogg file
|
||||
*/
|
||||
fun getCurrentRecord(): File?
|
||||
|
||||
/**
|
||||
* Guaranteed to be a ogg file
|
||||
*/
|
||||
fun getVoiceMessageFile(): File?
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.Level
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import im.vector.app.BuildConfig
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
class VoiceRecorderL(context: Context) : AbstractVoiceRecorder(context, "mp4") {
|
||||
override fun setOutputFormat(mediaRecorder: MediaRecorder) {
|
||||
// Use AAC/MP4 format here
|
||||
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
}
|
||||
|
||||
override fun convertFile(recordedFile: File?): File? {
|
||||
if (BuildConfig.DEBUG) {
|
||||
FFmpegKitConfig.setLogLevel(Level.AV_LOG_INFO)
|
||||
}
|
||||
recordedFile ?: return null
|
||||
// Convert to OGG
|
||||
val targetFile = File(recordedFile.path.removeSuffix("mp4") + "ogg")
|
||||
if (targetFile.exists()) {
|
||||
targetFile.delete()
|
||||
}
|
||||
val start = System.currentTimeMillis()
|
||||
val session = FFmpegKit.execute("-i \"${recordedFile.path}\" -c:a libvorbis \"${targetFile.path}\"")
|
||||
val duration = System.currentTimeMillis() - start
|
||||
Timber.d("Convert to ogg in $duration ms. Size in bytes from ${recordedFile.length()} to ${targetFile.length()}")
|
||||
return when {
|
||||
ReturnCode.isSuccess(session.returnCode) -> {
|
||||
// SUCCESS
|
||||
targetFile
|
||||
}
|
||||
ReturnCode.isCancel(session.returnCode) -> {
|
||||
// CANCEL
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
// FAILURE
|
||||
Timber.e("Command failed with state ${session.state} and rc ${session.returnCode}.${session.failStackTrace}")
|
||||
// TODO throw?
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import javax.inject.Inject
|
||||
|
||||
class VoiceRecorderProvider @Inject constructor(
|
||||
private val context: Context
|
||||
) {
|
||||
fun provideVoiceRecorder(): VoiceRecorder {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
VoiceRecorderQ(context)
|
||||
} else {
|
||||
VoiceRecorderL(context)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2021 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.io.File
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
class VoiceRecorderQ(context: Context) : AbstractVoiceRecorder(context, "ogg") {
|
||||
override fun setOutputFormat(mediaRecorder: MediaRecorder) {
|
||||
// We can directly use OGG here
|
||||
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.OGG)
|
||||
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
|
||||
}
|
||||
|
||||
override fun convertFile(recordedFile: File?): File? {
|
||||
// Nothing to do here
|
||||
return recordedFile
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<size
|
||||
android:width="78dp"
|
||||
android:height="160dp" />
|
||||
|
||||
<solid android:color="#F00" />
|
||||
|
||||
<corners
|
||||
android:bottomLeftRadius="39dp"
|
||||
android:bottomRightRadius="39dp"
|
||||
android:topLeftRadius="39dp"
|
||||
android:topRightRadius="39dp" />
|
||||
</shape>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<size android:width="32dp" android:height="32dp" />
|
||||
<!-- Tint color is provided by the theme -->
|
||||
<solid android:color="@android:color/black" />
|
||||
</shape>
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Tint color is provided by the theme -->
|
||||
<solid android:color="@android:color/black" />
|
||||
<size
|
||||
android:width="240dp"
|
||||
android:height="44dp" />
|
||||
<corners
|
||||
android:bottomLeftRadius="12dp"
|
||||
android:bottomRightRadius="12dp"
|
||||
android:topLeftRadius="12dp"
|
||||
android:topRightRadius="12dp" />
|
||||
</shape>
|
|
@ -3,13 +3,13 @@
|
|||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#F00"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M12,22C17.5228,22 22,17.5228 22,12C22,6.4771 17.5228,2 12,2C6.4771,2 2,6.4771 2,12C2,17.5228 6.4771,22 12,22ZM15.389,13.7659C15.6794,13.3162 16.2793,13.187 16.729,13.4774C17.1788,13.7677 17.308,14.3676 17.0176,14.8174C15.9565,16.461 14.1059,17.5526 11.9996,17.5526C9.8934,17.5526 8.0428,16.461 6.9817,14.8174C6.6913,14.3677 6.8205,13.7677 7.2702,13.4774C7.72,13.187 8.3199,13.3162 8.6103,13.7659C9.3295,14.88 10.5791,15.6141 11.9996,15.6141C13.4202,15.6141 14.6698,14.88 15.389,13.7659ZM10,10C10,10.8284 9.4404,11.5 8.75,11.5C8.0596,11.5 7.5,10.8284 7.5,10C7.5,9.1716 8.0596,8.5 8.75,8.5C9.4404,8.5 10,9.1716 10,10ZM15.25,11.5C15.9404,11.5 16.5,10.8284 16.5,10C16.5,9.1716 15.9404,8.5 15.25,8.5C14.5596,8.5 14,9.1716 14,10C14,10.8284 14.5596,11.5 15.25,11.5Z" />
|
||||
<group>
|
||||
<clip-path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M12,22C17.5228,22 22,17.5228 22,12C22,6.4771 17.5228,2 12,2C6.4771,2 2,6.4771 2,12C2,17.5228 6.4771,22 12,22ZM15.389,13.7659C15.6794,13.3162 16.2793,13.187 16.729,13.4774C17.1788,13.7677 17.308,14.3676 17.0176,14.8174C15.9565,16.461 14.1059,17.5526 11.9996,17.5526C9.8934,17.5526 8.0428,16.461 6.9817,14.8174C6.6913,14.3677 6.8205,13.7677 7.2702,13.4774C7.72,13.187 8.3199,13.3162 8.6103,13.7659C9.3295,14.88 10.5791,15.6141 11.9996,15.6141C13.4202,15.6141 14.6698,14.88 15.389,13.7659ZM10,10C10,10.8284 9.4404,11.5 8.75,11.5C8.0596,11.5 7.5,10.8284 7.5,10C7.5,9.1716 8.0596,8.5 8.75,8.5C9.4404,8.5 10,9.1716 10,10ZM15.25,11.5C15.9404,11.5 16.5,10.8284 16.5,10C16.5,9.1716 15.9404,8.5 15.25,8.5C14.5596,8.5 14,9.1716 14,10C14,10.8284 14.5596,11.5 15.25,11.5Z" />
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M12,22C17.5228,22 22,17.5228 22,12C22,6.4771 17.5228,2 12,2C6.4771,2 2,6.4771 2,12C2,17.5228 6.4771,22 12,22ZM15.389,13.7659C15.6794,13.3162 16.2793,13.187 16.729,13.4774C17.1788,13.7677 17.308,14.3676 17.0176,14.8174C15.9565,16.461 14.1059,17.5526 11.9996,17.5526C9.8934,17.5526 8.0428,16.461 6.9817,14.8174C6.6913,14.3677 6.8205,13.7677 7.2702,13.4774C7.72,13.187 8.3199,13.3162 8.6103,13.7659C9.3295,14.88 10.5791,15.6141 11.9996,15.6141C13.4202,15.6141 14.6698,14.88 15.389,13.7659ZM10,10C10,10.8284 9.4404,11.5 8.75,11.5C8.0596,11.5 7.5,10.8284 7.5,10C7.5,9.1716 8.0596,8.5 8.75,8.5C9.4404,8.5 10,9.1716 10,10ZM15.25,11.5C15.9404,11.5 16.5,10.8284 16.5,10C16.5,9.1716 15.9404,8.5 15.25,8.5C14.5596,8.5 14,9.1716 14,10C14,10.8284 14.5596,11.5 15.25,11.5Z"
|
||||
android:fillColor="#F00"
|
||||
android:fillType="evenOdd"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M12,22C17.5228,22 22,17.5228 22,12C22,6.4771 17.5228,2 12,2C6.4771,2 2,6.4771 2,12C2,17.5228 6.4771,22 12,22ZM15.389,13.7659C15.6794,13.3162 16.2793,13.187 16.729,13.4774C17.1788,13.7677 17.308,14.3676 17.0176,14.8174C15.9565,16.461 14.1059,17.5526 11.9996,17.5526C9.8934,17.5526 8.0428,16.461 6.9817,14.8174C6.6913,14.3677 6.8205,13.7677 7.2702,13.4774C7.72,13.187 8.3199,13.3162 8.6103,13.7659C9.3295,14.88 10.5791,15.6141 11.9996,15.6141C13.4202,15.6141 14.6698,14.88 15.389,13.7659ZM10,10C10,10.8284 9.4404,11.5 8.75,11.5C8.0596,11.5 7.5,10.8284 7.5,10C7.5,9.1716 8.0596,8.5 8.75,8.5C9.4404,8.5 10,9.1716 10,10ZM15.25,11.5C15.9404,11.5 16.5,10.8284 16.5,10C16.5,9.1716 15.9404,8.5 15.25,8.5C14.5596,8.5 14,9.1716 14,10C14,10.8284 14.5596,11.5 15.25,11.5Z"
|
||||
android:fillType="evenOdd"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="16">
|
||||
<path
|
||||
android:fillColor="#F00"
|
||||
android:pathData="M3,3C3,2.4477 3.4477,2 4,2H5C5.5523,2 6,2.4477 6,3V13C6,13.5523 5.5523,14 5,14H4C3.4477,14 3,13.5523 3,13V3Z" />
|
||||
<path
|
||||
android:fillColor="#F00"
|
||||
android:pathData="M10,3C10,2.4477 10.4477,2 11,2H12C12.5523,2 13,2.4477 13,3V13C13,13.5523 12.5523,14 12,14H11C10.4477,14 10,13.5523 10,13V3Z" />
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="16">
|
||||
<path
|
||||
android:fillColor="#F00"
|
||||
android:pathData="M3,14.2104V1.7896C3,1.0072 3.8578,0.5279 4.5241,0.9379L14.6161,7.1483C15.2506,7.5388 15.2506,8.4612 14.6161,8.8517L4.5241,15.0621C3.8578,15.4721 3,14.9928 3,14.2104Z" />
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="14dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="14"
|
||||
android:viewportHeight="18">
|
||||
<path
|
||||
android:pathData="M1,16C1,17.1 1.9,18 3,18H11C12.1,18 13,17.1 13,16V6C13,4.9 12.1,4 11,4H3C1.9,4 1,4.9 1,6V16ZM13,1H10.5L9.79,0.29C9.61,0.11 9.35,0 9.09,0H4.91C4.65,0 4.39,0.11 4.21,0.29L3.5,1H1C0.45,1 0,1.45 0,2C0,2.55 0.45,3 1,3H13C13.55,3 14,2.55 14,2C14,1.45 13.55,1 13,1Z"
|
||||
android:fillColor="#8D99A5"/>
|
||||
</vector>
|
|
@ -0,0 +1,13 @@
|
|||
<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="#00000000"
|
||||
android:pathData="M6,15L12,9L18,15"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#F00"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:autoMirrored="true" android:height="24dp"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#0DBD8B" android:fillType="evenOdd" android:pathData="M11.3333,2C8.3878,2 6,4.3878 6,7.3333V10C4.8954,10 4,10.8954 4,12V20C4,21.1046 4.8954,22 6,22H18C19.1046,22 20,21.1046 20,20V12C20,10.8954 19.1046,10 18,10V7.3333C18,4.3878 15.6122,2 12.6667,2H11.3333ZM15.3333,10V7.3333C15.3333,5.8606 14.1394,4.6667 12.6667,4.6667H11.3333C9.8606,4.6667 8.6667,5.8606 8.6667,7.3333V10H15.3333Z"/>
|
||||
</vector>
|
|
@ -0,0 +1,5 @@
|
|||
<vector android:autoMirrored="true" android:height="16dp"
|
||||
android:viewportHeight="16" android:viewportWidth="16"
|
||||
android:width="16dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#8E99A4" android:fillType="evenOdd" android:pathData="M7.4444,0C4.9899,0 3,1.9334 3,4.3183V6.1369C2.4115,6.3926 2,6.979 2,7.6615V14.3385C2,15.2561 2.7439,16 3.6615,16H12.3385C13.2561,16 14,15.2561 14,14.3385V7.6615C14,6.7439 13.2561,6 12.3385,6H5.2222V4.3183C5.2222,3.1259 6.2171,2.1592 7.4444,2.1592H8.5556C9.7829,2.1592 10.7778,3.1259 10.7778,4.3183H13C13,1.9334 11.0102,0 8.5556,0H7.4444Z"/>
|
||||
</vector>
|
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M10.8,8.2C10.8,5.3281 13.1282,3 16,3C18.8719,3 21.2,5.3281 21.2,8.2V15.9767C21.2,18.8486 18.8719,21.1767 16,21.1767C13.1282,21.1767 10.8,18.8486 10.8,15.9767V8.2Z"
|
||||
android:fillColor="?vctr_content_tertiary"/>
|
||||
<path
|
||||
android:pathData="M6.8998,14.3167C7.8203,14.3167 8.5665,15.0629 8.5665,15.9834C8.5665,20.0737 11.8818,23.3944 15.98,23.4051C15.9867,23.405 15.9934,23.4049 16.0001,23.4049C16.0068,23.4049 16.0134,23.405 16.0201,23.4051C20.1181,23.3941 23.4332,20.0735 23.4332,15.9834C23.4332,15.0629 24.1793,14.3167 25.0998,14.3167C26.0203,14.3167 26.7665,15.0629 26.7665,15.9834C26.7665,21.3586 22.8201,25.8101 17.6667,26.6103V27.6683C17.6667,28.5888 16.9206,29.335 16.0001,29.335C15.0796,29.335 14.3334,28.5888 14.3334,27.6683V26.6104C9.1798,25.8104 5.2332,21.3587 5.2332,15.9834C5.2332,15.0629 5.9794,14.3167 6.8998,14.3167Z"
|
||||
android:fillColor="?vctr_content_tertiary"/>
|
||||
</vector>
|
|
@ -0,0 +1,18 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="52dp"
|
||||
android:height="52dp"
|
||||
android:viewportWidth="52"
|
||||
android:viewportHeight="52">
|
||||
<path
|
||||
android:pathData="M26.173,26.1729m-22.7631,0a22.7631,22.7631 0,1 1,45.5262 0a22.7631,22.7631 0,1 1,-45.5262 0"
|
||||
android:fillColor="#0DBD8B"/>
|
||||
<path
|
||||
android:pathData="M26,26m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0"
|
||||
android:strokeAlpha="0.2"
|
||||
android:fillColor="#0DBD8B"
|
||||
android:fillAlpha="0.2"/>
|
||||
<path
|
||||
android:pathData="M21.2414,18.7749C21.2414,16.051 23.4496,13.8429 26.1734,13.8429C28.8973,13.8429 31.1054,16.051 31.1054,18.7749V26.1509C31.1054,28.8747 28.8973,31.0829 26.1734,31.0829C23.4496,31.0829 21.2414,28.8747 21.2414,26.1509V18.7749ZM17.542,24.2475C18.5968,24.2475 19.4518,25.1025 19.4518,26.1572C19.4518,29.8561 22.4509,32.8596 26.1586,32.8675C26.1637,32.8674 26.1689,32.8674 26.174,32.8674C26.179,32.8674 26.184,32.8674 26.189,32.8675C29.896,32.8589 32.8944,29.8556 32.8944,26.1572C32.8944,25.1025 33.7494,24.2475 34.8041,24.2475C35.8588,24.2475 36.7138,25.1025 36.7138,26.1572C36.7138,31.3227 32.9916,35.6165 28.0837,36.5143V37.24C28.0837,38.2947 27.2287,39.1497 26.174,39.1497C25.1193,39.1497 24.2643,38.2947 24.2643,37.24V36.5147C19.3555,35.6176 15.6323,31.3233 15.6323,26.1572C15.6323,25.1025 16.4873,24.2475 17.542,24.2475Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
|
@ -0,0 +1,14 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M15,18L9,12L15,6"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#F00"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
|
@ -131,4 +131,16 @@
|
|||
android:src="@drawable/ic_send"
|
||||
tools:ignore="MissingConstraints" />
|
||||
|
||||
<!--
|
||||
<ImageButton
|
||||
android:id="@+id/voiceMessageMicButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:contentDescription="@string/a11y_start_voice_message"
|
||||
android:src="@drawable/ic_voice_mic" />
|
||||
-->
|
||||
|
||||
</merge>
|
||||
|
|
|
@ -178,4 +178,18 @@
|
|||
tools:ignore="MissingPrefix"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<!--
|
||||
<ImageButton
|
||||
android:id="@+id/voiceMessageMicButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:contentDescription="@string/a11y_start_voice_message"
|
||||
android:src="@drawable/ic_voice_mic"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
-->
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -130,14 +130,14 @@
|
|||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/jumpToReadMarkerView"
|
||||
style="?vctr_jump_to_unread_style"
|
||||
app:chipIcon="@drawable/ic_jump_to_unread"
|
||||
app:closeIcon="@drawable/ic_close_24dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/room_jump_to_first_unread"
|
||||
android:visibility="invisible"
|
||||
app:chipIcon="@drawable/ic_jump_to_unread"
|
||||
app:closeIcon="@drawable/ic_close_24dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/activeConferenceView"
|
||||
|
@ -170,9 +170,21 @@
|
|||
android:background="?android:colorBackground"
|
||||
android:minHeight="56dp"
|
||||
android:transitionName="composer"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
|
||||
android:id="@+id/voiceMessageRecorderView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<im.vector.app.features.invite.VectorInviteView
|
||||
android:id="@+id/inviteView"
|
||||
|
|
|
@ -132,6 +132,13 @@
|
|||
android:layout_marginEnd="56dp"
|
||||
android:layout="@layout/item_timeline_event_option_buttons_stub" />
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/messageContentVoiceStub"
|
||||
style="@style/TimelineContentStubBaseParams"
|
||||
android:layout_marginEnd="56dp"
|
||||
android:layout="@layout/item_timeline_event_voice_stub"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<im.vector.app.core.ui.views.SendStateImageView
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/voiceLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/voicePlaybackLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_voice_playback"
|
||||
android:backgroundTint="?vctr_content_quinary"
|
||||
android:minHeight="48dp"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingBottom="6dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/voicePlaybackControlButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:background="@drawable/bg_voice_play_pause_button"
|
||||
android:backgroundTint="?android:colorBackground"
|
||||
android:contentDescription="@string/a11y_play_voice_message"
|
||||
android:src="@drawable/ic_play_pause_play"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:tint="?vctr_content_secondary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/voicePlaybackTime"
|
||||
style="@style/Widget.Vector.TextView.Body.Medium"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="6dp"
|
||||
android:textColor="?vctr_content_secondary"
|
||||
app:layout_constraintBottom_toBottomOf="@id/voicePlaybackControlButton"
|
||||
app:layout_constraintStart_toEndOf="@id/voicePlaybackControlButton"
|
||||
app:layout_constraintTop_toTopOf="@id/voicePlaybackControlButton"
|
||||
tools:text="0:23" />
|
||||
|
||||
<com.visualizer.amplitude.AudioRecordView
|
||||
android:id="@+id/voicePlaybackWaveform"
|
||||
style="@style/VoicePlaybackWaveform"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/voicePlaybackTime"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
||||
<include
|
||||
android:id="@+id/messageFileUploadProgressLayout"
|
||||
layout="@layout/media_upload_download_progress_layout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="46dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/voicePlaybackLayout"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,240 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/voice_message_recording_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:minHeight="200dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/voiceMessageLockBackground"
|
||||
android:layout_width="78dp"
|
||||
android:layout_height="180dp"
|
||||
android:background="@drawable/bg_voice_message_lock"
|
||||
android:backgroundTint="?vctr_system"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/voiceMessageMicButton"
|
||||
tools:translationY="-180dp"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/voiceMessageMicButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:contentDescription="@string/a11y_start_voice_message"
|
||||
android:src="@drawable/ic_voice_mic"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/voiceMessageSendButton"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:background="@drawable/bg_send"
|
||||
android:contentDescription="@string/send"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_send"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
tools:layout_marginBottom="180dp"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/voiceMessageTimerIndicator"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:contentDescription="@string/a11y_recording_voice_message"
|
||||
android:src="@drawable/circle"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/voiceMessageMicButton"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/voiceMessageMicButton"
|
||||
app:tint="@color/palette_vermilion"
|
||||
tools:ignore="MissingPrefix"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/voiceMessageTimer"
|
||||
style="@style/Widget.Vector.TextView.Body.Medium"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="?vctr_content_secondary"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@id/voiceMessageMicButton"
|
||||
app:layout_constraintStart_toEndOf="@id/voiceMessageTimerIndicator"
|
||||
app:layout_constraintTop_toTopOf="@id/voiceMessageMicButton"
|
||||
tools:text="0:03"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/voiceMessageSlideToCancel"
|
||||
style="@style/Widget.Vector.TextView.Body"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:text="@string/voice_message_slide_to_cancel"
|
||||
android:textColor="?vctr_content_secondary"
|
||||
android:visibility="gone"
|
||||
app:drawableStartCompat="@drawable/ic_voice_slide_to_cancel_arrow"
|
||||
app:drawableTint="?vctr_content_secondary"
|
||||
app:layout_constraintBottom_toBottomOf="@id/voiceMessageMicButton"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/voiceMessageMicButton"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<!-- Slide to cancel text should go under this view -->
|
||||
<View
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="0dp"
|
||||
android:background="?android:colorBackground"
|
||||
app:layout_constraintBottom_toBottomOf="@id/voiceMessageTimer"
|
||||
app:layout_constraintStart_toEndOf="@id/voiceMessageTimer"
|
||||
app:layout_constraintTop_toTopOf="@id/voiceMessageTimer" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/voiceMessageLockImage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:contentDescription="@string/a11y_lock_voice_message"
|
||||
android:src="@drawable/ic_voice_message_unlocked"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="@id/voiceMessageMicButton"
|
||||
app:layout_constraintStart_toStartOf="@id/voiceMessageMicButton"
|
||||
app:layout_constraintTop_toTopOf="@id/voiceMessageLockBackground"
|
||||
tools:translationY="-180dp"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/voiceMessageLockArrow"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_voice_lock_arrow"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/voiceMessageMicButton"
|
||||
app:layout_constraintEnd_toEndOf="@id/voiceMessageLockBackground"
|
||||
app:layout_constraintStart_toStartOf="@id/voiceMessageLockBackground"
|
||||
app:tint="?vctr_content_secondary"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/voiceMessagePlaybackLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/voiceMessageMicButton"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:layout_marginBottom="120dp"
|
||||
tools:visibility="visible">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/voiceMessageDeletePlayback"
|
||||
android:layout_width="@dimen/layout_touch_size"
|
||||
android:layout_height="0dp"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:contentDescription="@string/a11y_delete_recorded_voice_message"
|
||||
android:src="@drawable/ic_recycle_bin"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:tint="?vctr_content_tertiary"
|
||||
tools:ignore="MissingPrefix" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:background="@drawable/bg_voice_playback"
|
||||
android:backgroundTint="?vctr_content_quinary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/voiceMessageDeletePlayback"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/voiceMessagePlaybackTimerIndicator"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/a11y_recording_voice_message"
|
||||
android:src="@drawable/circle"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_goneMarginStart="24dp"
|
||||
app:tint="@color/palette_vermilion"
|
||||
tools:ignore="MissingPrefix" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/voicePlaybackControlButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/bg_voice_play_pause_button"
|
||||
android:backgroundTint="?vctr_system"
|
||||
android:contentDescription="@string/a11y_play_voice_message"
|
||||
android:src="@drawable/ic_play_pause_play"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:tint="?vctr_content_secondary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/voicePlaybackTime"
|
||||
style="@style/Widget.Vector.TextView.Body.Medium"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:textColor="?vctr_content_secondary"
|
||||
app:layout_constraintBottom_toBottomOf="@id/voicePlaybackControlButton"
|
||||
app:layout_constraintStart_toEndOf="@id/voicePlaybackControlButton"
|
||||
app:layout_constraintTop_toTopOf="@id/voicePlaybackControlButton"
|
||||
app:layout_goneMarginStart="24dp"
|
||||
tools:text="0:23" />
|
||||
|
||||
<com.visualizer.amplitude.AudioRecordView
|
||||
android:id="@+id/voicePlaybackWaveform"
|
||||
style="@style/VoicePlaybackWaveform"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/voicePlaybackTime"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/voiceMessageToast"
|
||||
style="@style/Widget.Vector.TextView.Caption.Toast"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="84dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:text="@string/voice_message_release_to_send_toast"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -389,6 +389,7 @@
|
|||
<!-- Permissions denied forever -->
|
||||
<string name="denied_permission_generic">Some permissions are missing to perform this action, please grant the permissions from the system settings.</string>
|
||||
<string name="denied_permission_camera">To perform this action, please grant the Camera permission from the system settings.</string>
|
||||
<string name="denied_permission_voice_message">To send voice messages, please grant the Microphone permission.</string>
|
||||
|
||||
<!-- First param will be replace by the value of ongoing_conference_call_voice, and second one by the value of ongoing_conference_call_video -->
|
||||
<string name="ongoing_conference_call">Ongoing conference call.\nJoin as %1$s or %2$s</string>
|
||||
|
@ -2622,6 +2623,7 @@
|
|||
<string name="sent_a_video">Video.</string>
|
||||
<string name="sent_an_image">Image.</string>
|
||||
<string name="sent_an_audio_file">Audio</string>
|
||||
<string name="sent_a_voice_message">Voice</string>
|
||||
<string name="sent_a_file">File</string>
|
||||
<string name="send_a_sticker">Sticker</string>
|
||||
<string name="sent_a_poll">Poll</string>
|
||||
|
@ -3445,4 +3447,20 @@
|
|||
<string name="room_upgrade_to_recommended_version">Upgrade to the recommended room version</string>
|
||||
|
||||
<string name="error_failed_to_join_room">Sorry, an error occurred while trying to join: %s</string>
|
||||
|
||||
<string name="a11y_start_voice_message">Start Voice Message</string>
|
||||
<string name="voice_message_slide_to_cancel">Slide to cancel</string>
|
||||
<string name="a11y_lock_voice_message">Voice Message Lock</string>
|
||||
<string name="a11y_play_voice_message">Play Voice Message</string>
|
||||
<string name="a11y_pause_voice_message">Pause Voice Message</string>
|
||||
<string name="a11y_recording_voice_message">Recording voice message</string>
|
||||
<string name="a11y_delete_recorded_voice_message">Delete recorded voice message</string>
|
||||
<string name="voice_message_release_to_send_toast">Hold to record, release to send</string>
|
||||
<string name="voice_message_n_seconds_warning_toast">%1$ds left</string>
|
||||
<string name="voice_message_tap_to_stop_toast">Tap on your recording to stop or listen</string>
|
||||
<string name="labs_use_voice_message">Enable voice message</string>
|
||||
<string name="error_voice_message_unable_to_play">Cannot play this voice message</string>
|
||||
<string name="error_voice_message_unable_to_record">Cannot record a voice message</string>
|
||||
<string name="error_voice_message_cannot_reply_or_edit">Cannot reply or edit while voice message is active</string>
|
||||
<string name="voice_message_reply_content">Voice Message (%1$s)</string>
|
||||
</resources>
|
||||
|
|
|
@ -57,4 +57,9 @@
|
|||
android:key="SETTINGS_LABS_SPACES_HOME_AS_ORPHAN"
|
||||
android:title="@string/labs_space_show_orphan_in_home"/>
|
||||
|
||||
<im.vector.app.core.preference.VectorSwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:key="SETTINGS_LABS_VOICE_MESSAGE"
|
||||
android:title="@string/labs_use_voice_message"/>
|
||||
|
||||
</androidx.preference.PreferenceScreen>
|
Loading…
Reference in New Issue