Merge branch 'develop' into main

* develop: (127 commits)
  fastlane changelog added.
  Changelog added.
  Fix lint error.
  Translated using Weblate (Slovenian)
  Lint fixes.
  cleanup
  Update initial recording state to restore from background.
  Hide mic if there is a draft message.
  Migration to cleanup orphan TrustLevelEntities
  Design review fixes.
  Better algorithm to update user devices Should fix the problem of too many TrustLevelEntity objects
  Small optimization
  Translated using Weblate (Chinese (Traditional))
  Translated using Weblate (Italian)
  Translated using Weblate (Hungarian)
  Translated using Weblate (Hungarian)
  Fix wording when verification is cancelled.
  Design review fixes.
  Do not check the baseURL to override if it is the same than the one previously known and used
  Translated using Weblate (Portuguese (Brazil))
  ...
This commit is contained in:
Onuray Sahin 2021-07-30 17:55:56 +03:00
commit 53568f1477
116 changed files with 3962 additions and 533 deletions

View file

@ -1,3 +1,15 @@
Changes in Element v1.1.15 (2021-07-30)
=======================================
Features ✨
----------
- Voice Message implementation (Currently under Labs Settings and disabled by default). ([#3598](https://github.com/vector-im/element-android/issues/3598))
SDK API changes ⚠️
------------------
- updatePushRuleActions signature has been updated to more explicitly enabled/disable the rule and update the actions. It's behaviour has also been changed to match the web with the enable/disable requests being sent on every invocation and actions sent when needed(not null). ([#3681](https://github.com/vector-im/element-android/issues/3681))
Changes in Element 1.1.14 (2021-07-23)
======================================

View file

@ -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/' }

View file

@ -0,0 +1,2 @@
Hlavní změny v této verzi: aktualizace motivu a stylu a oprava pádu aplikace po videohovoru
Úplný seznam změn: https://github.com/vector-im/element-android/releases/tag/v1.1.12

View file

@ -0,0 +1,2 @@
Main changes in this version: voice message implementation under labs settings.
Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.1.15

View file

@ -0,0 +1,2 @@
Põhilised muutused selles versioonis: teemade ja välimuse uuendused ning videokõne-järgse kokkujooksmise parandused
Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases/tag/v1.1.12

View file

@ -0,0 +1,2 @@
Főbb változtatások ebben a verzióban: kinézet és stílus frissítések és a videóhívás utáni összeomlás javítása
Teljes változásnapló: https://github.com/vector-im/element-android/releases/tag/v1.1.12

View file

@ -0,0 +1,2 @@
Modifiche principali in questa versione: aggiornati tema e stile, corretto un crash dopo videochiamata
Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.1.12

View file

@ -0,0 +1,2 @@
Principais mudanças nesta versão: atualização de tema e estilo e consertar um crash depois de chamada de vídeo
Changelog completo: https://github.com/vector-im/element-android/releases/tag/v1.1.12

View file

@ -0,0 +1,2 @@
此版本中的主要變動:佈景主題與樣式更新,以及修復視訊通話後當機的問題
完整的變更紀錄https://github.com/vector-im/element-android/releases/tag/v1.1.12

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

@ -31,7 +31,13 @@ interface PushRuleService {
suspend fun addPushRule(kind: RuleKind, pushRule: PushRule)
suspend fun updatePushRuleActions(kind: RuleKind, oldPushRule: PushRule, newPushRule: PushRule)
/**
* Enables/Disables a push rule and updates the actions if necessary
* @param enable Enables/Disables the rule
* @param actions Actions to update if not null
*/
suspend fun updatePushRuleActions(kind: RuleKind, ruleId: String, enable: Boolean, actions: List<Action>?)
suspend fun removePushRule(kind: RuleKind, pushRule: PushRule)

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.pushrules.rest
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.pushrules.Action
import org.matrix.android.sdk.api.pushrules.getActions
import org.matrix.android.sdk.api.pushrules.toJson
@ -100,6 +101,13 @@ data class PushRule(
)
}
/**
* Get the highlight status. As spec mentions assume false if no tweak present.
*/
fun getHighlight(): Boolean {
return getActions().filterIsInstance<Action.Highlight>().firstOrNull()?.highlight.orFalse()
}
/**
* Set the notification status.
*

View file

@ -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)

View file

@ -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
)

View file

@ -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
)

View file

@ -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?

View file

@ -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()

View file

@ -49,6 +49,8 @@ internal class DefaultSessionCreator @Inject constructor(
// remove trailing "/"
?.trim { it == '/' }
?.takeIf { it.isNotBlank() }
// It can be the same value, so in this case, do not check again the validity
?.takeIf { it != homeServerConnectionConfig.homeServerUriBase.toString() }
?.also { Timber.d("Overriding homeserver url to $it (will check if valid)") }
?.let { Uri.parse(it) }
?.takeIf {

View file

@ -18,8 +18,6 @@ package org.matrix.android.sdk.internal.crypto.model
import org.matrix.android.sdk.internal.crypto.crosssigning.DeviceTrustLevel
import org.matrix.android.sdk.internal.crypto.model.rest.DeviceKeys
import org.matrix.android.sdk.internal.crypto.model.rest.UnsignedDeviceInfo
import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoMapper
import org.matrix.android.sdk.internal.crypto.store.db.model.DeviceInfoEntity
data class CryptoDeviceInfo(
val deviceId: String,
@ -77,7 +75,3 @@ data class CryptoDeviceInfo(
internal fun CryptoDeviceInfo.toRest(): DeviceKeys {
return CryptoInfoMapper.map(this)
}
internal fun CryptoDeviceInfo.toEntity(): DeviceInfoEntity {
return CryptoMapper.mapToEntity(this)
}

View file

@ -51,7 +51,6 @@ import org.matrix.android.sdk.internal.crypto.model.OutboundGroupSessionWrapper
import org.matrix.android.sdk.internal.crypto.model.event.RoomKeyWithHeldContent
import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo
import org.matrix.android.sdk.internal.crypto.model.rest.RoomKeyRequestBody
import org.matrix.android.sdk.internal.crypto.model.toEntity
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
import org.matrix.android.sdk.internal.crypto.store.PrivateKeysInfo
import org.matrix.android.sdk.internal.crypto.store.SavedKeyBackupKeyInfo
@ -280,24 +279,34 @@ internal class RealmCryptoStore @Inject constructor(
override fun storeUserDevices(userId: String, devices: Map<String, CryptoDeviceInfo>?) {
doRealmTransaction(realmConfiguration) { realm ->
if (devices == null) {
Timber.d("Remove user $userId")
// Remove the user
UserEntity.delete(realm, userId)
} else {
UserEntity.getOrCreate(realm, userId)
.let { u ->
// Add the devices
val currentKnownDevices = u.devices.toList()
val new = devices.map { entry -> entry.value.toEntity() }
new.forEach { entity ->
// Maintain first time seen
val existing = currentKnownDevices.firstOrNull { it.deviceId == entity.deviceId && it.identityKey == entity.identityKey }
entity.firstTimeSeenLocalTs = existing?.firstTimeSeenLocalTs ?: System.currentTimeMillis()
realm.insertOrUpdate(entity)
}
// Ensure all other devices are deleted
u.devices.clearWith { it.deleteOnCascade() }
u.devices.addAll(new)
}
val userEntity = UserEntity.getOrCreate(realm, userId)
// First delete the removed devices
val deviceIds = devices.keys
userEntity.devices.iterator().forEach { deviceInfoEntity ->
if (deviceInfoEntity.deviceId !in deviceIds) {
Timber.d("Remove device ${deviceInfoEntity.deviceId} of user $userId")
deviceInfoEntity.deleteOnCascade()
}
}
// Then update existing devices or add new one
devices.values.forEach { cryptoDeviceInfo ->
val existingDeviceInfoEntity = userEntity.devices.firstOrNull { it.deviceId == cryptoDeviceInfo.deviceId }
if (existingDeviceInfoEntity == null) {
// Add the device
Timber.d("Add device ${cryptoDeviceInfo.deviceId} of user $userId")
val newEntity = CryptoMapper.mapToEntity(cryptoDeviceInfo)
newEntity.firstTimeSeenLocalTs = System.currentTimeMillis()
userEntity.devices.add(newEntity)
} else {
// Update the device
Timber.d("Update device ${cryptoDeviceInfo.deviceId} of user $userId")
CryptoMapper.updateDeviceInfoEntity(existingDeviceInfoEntity, cryptoDeviceInfo)
}
}
}
}
}

View file

@ -55,7 +55,7 @@ internal object RealmCryptoStoreMigration : RealmMigration {
// 0, 1, 2: legacy Riot-Android
// 3: migrate to RiotX schema
// 4, 5, 6, 7, 8, 9: migrations from RiotX (which was previously 1, 2, 3, 4, 5, 6)
const val CRYPTO_STORE_SCHEMA_VERSION = 12L
const val CRYPTO_STORE_SCHEMA_VERSION = 13L
private fun RealmObjectSchema.addFieldIfNotExists(fieldName: String, fieldType: Class<*>): RealmObjectSchema {
if (!hasField(fieldName)) {
@ -93,6 +93,7 @@ internal object RealmCryptoStoreMigration : RealmMigration {
if (oldVersion <= 9) migrateTo10(realm)
if (oldVersion <= 10) migrateTo11(realm)
if (oldVersion <= 11) migrateTo12(realm)
if (oldVersion <= 12) migrateTo13(realm)
}
private fun migrateTo1Legacy(realm: DynamicRealm) {
@ -497,4 +498,60 @@ internal object RealmCryptoStoreMigration : RealmMigration {
realm.schema.get("CryptoRoomEntity")
?.addRealmObjectField(CryptoRoomEntityFields.OUTBOUND_SESSION_INFO.`$`, outboundEntitySchema)
}
// Version 13L delete unreferenced TrustLevelEntity
private fun migrateTo13(realm: DynamicRealm) {
Timber.d("Step 12 -> 13")
// Use a trick to do that... Ref: https://stackoverflow.com/questions/55221366
val trustLevelEntitySchema = realm.schema.get("TrustLevelEntity")
/*
Creating a new temp field called isLinked which is set to true for those which are
references by other objects. Rest of them are set to false. Then removing all
those which are false and hence duplicate and unnecessary. Then removing the temp field
isLinked
*/
var mainCounter = 0
var deviceInfoCounter = 0
var keyInfoCounter = 0
val deleteCounter: Int
trustLevelEntitySchema
?.addField("isLinked", Boolean::class.java)
?.transform { obj ->
// Setting to false for all by default
obj.set("isLinked", false)
mainCounter++
}
realm.schema.get("DeviceInfoEntity")?.transform { obj ->
// Setting to true for those which are referenced in DeviceInfoEntity
deviceInfoCounter++
obj.getObject("trustLevelEntity")?.set("isLinked", true)
}
realm.schema.get("KeyInfoEntity")?.transform { obj ->
// Setting to true for those which are referenced in KeyInfoEntity
keyInfoCounter++
obj.getObject("trustLevelEntity")?.set("isLinked", true)
}
// Removing all those which are set as false
realm.where("TrustLevelEntity")
.equalTo("isLinked", false)
.findAll()
.also { deleteCounter = it.size }
.deleteAllFromRealm()
trustLevelEntitySchema?.removeField("isLinked")
Timber.w("TrustLevelEntity cleanup: $mainCounter entities")
Timber.w("TrustLevelEntity cleanup: $deviceInfoCounter entities referenced in DeviceInfoEntities")
Timber.w("TrustLevelEntity cleanup: $keyInfoCounter entities referenced in KeyInfoEntity")
Timber.w("TrustLevelEntity cleanup: $deleteCounter entities deleted!")
if (mainCounter != deviceInfoCounter + keyInfoCounter + deleteCounter) {
Timber.e("TrustLevelEntity cleanup: Something is not correct...")
}
}
}

View file

@ -44,23 +44,32 @@ object CryptoMapper {
))
internal fun mapToEntity(deviceInfo: CryptoDeviceInfo): DeviceInfoEntity {
return DeviceInfoEntity(
primaryKey = DeviceInfoEntity.createPrimaryKey(deviceInfo.userId, deviceInfo.deviceId),
userId = deviceInfo.userId,
deviceId = deviceInfo.deviceId,
algorithmListJson = listMigrationAdapter.toJson(deviceInfo.algorithms),
keysMapJson = mapMigrationAdapter.toJson(deviceInfo.keys),
signatureMapJson = mapMigrationAdapter.toJson(deviceInfo.signatures),
isBlocked = deviceInfo.isBlocked,
trustLevelEntity = deviceInfo.trustLevel?.let {
TrustLevelEntity(
crossSignedVerified = it.crossSigningVerified,
locallyVerified = it.locallyVerified
)
},
// We store the device name if present now
unsignedMapJson = deviceInfo.unsigned?.deviceDisplayName
)
return DeviceInfoEntity(primaryKey = DeviceInfoEntity.createPrimaryKey(deviceInfo.userId, deviceInfo.deviceId))
.also { updateDeviceInfoEntity(it, deviceInfo) }
}
internal fun updateDeviceInfoEntity(entity: DeviceInfoEntity, deviceInfo: CryptoDeviceInfo) {
entity.userId = deviceInfo.userId
entity.deviceId = deviceInfo.deviceId
entity.algorithmListJson = listMigrationAdapter.toJson(deviceInfo.algorithms)
entity.keysMapJson = mapMigrationAdapter.toJson(deviceInfo.keys)
entity.signatureMapJson = mapMigrationAdapter.toJson(deviceInfo.signatures)
entity.isBlocked = deviceInfo.isBlocked
val deviceInfoTrustLevel = deviceInfo.trustLevel
if (deviceInfoTrustLevel == null) {
entity.trustLevelEntity?.deleteFromRealm()
entity.trustLevelEntity = null
} else {
if (entity.trustLevelEntity == null) {
// Create a new TrustLevelEntity object
entity.trustLevelEntity = TrustLevelEntity()
}
// Update the existing TrustLevelEntity object
entity.trustLevelEntity?.crossSignedVerified = deviceInfoTrustLevel.crossSigningVerified
entity.trustLevelEntity?.locallyVerified = deviceInfoTrustLevel.locallyVerified
}
// We store the device name if present now
entity.unsignedMapJson = deviceInfo.unsigned?.deviceDisplayName
}
internal fun mapToModel(deviceInfoEntity: DeviceInfoEntity): CryptoDeviceInfo {

View file

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

View file

@ -113,8 +113,8 @@ internal class DefaultPushRuleService @Inject constructor(
addPushRuleTask.execute(AddPushRuleTask.Params(kind, pushRule))
}
override suspend fun updatePushRuleActions(kind: RuleKind, oldPushRule: PushRule, newPushRule: PushRule) {
updatePushRuleActionsTask.execute(UpdatePushRuleActionsTask.Params(kind, oldPushRule, newPushRule))
override suspend fun updatePushRuleActions(kind: RuleKind, ruleId: String, enable: Boolean, actions: List<Action>?) {
updatePushRuleActionsTask.execute(UpdatePushRuleActionsTask.Params(kind, ruleId, enable, actions))
}
override suspend fun removePushRule(kind: RuleKind, pushRule: PushRule) {

View file

@ -15,8 +15,9 @@
*/
package org.matrix.android.sdk.internal.session.pushers
import org.matrix.android.sdk.api.pushrules.Action
import org.matrix.android.sdk.api.pushrules.RuleKind
import org.matrix.android.sdk.api.pushrules.rest.PushRule
import org.matrix.android.sdk.api.pushrules.toJson
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
@ -25,8 +26,9 @@ import javax.inject.Inject
internal interface UpdatePushRuleActionsTask : Task<UpdatePushRuleActionsTask.Params, Unit> {
data class Params(
val kind: RuleKind,
val oldPushRule: PushRule,
val newPushRule: PushRule
val ruleId: String,
val enable: Boolean,
val actions: List<Action>?
)
}
@ -36,20 +38,14 @@ internal class DefaultUpdatePushRuleActionsTask @Inject constructor(
) : UpdatePushRuleActionsTask {
override suspend fun execute(params: UpdatePushRuleActionsTask.Params) {
if (params.oldPushRule.enabled != params.newPushRule.enabled) {
// First change enabled state
executeRequest(globalErrorReceiver) {
pushRulesApi.updateEnableRuleStatus(params.kind.value, params.newPushRule.ruleId, params.newPushRule.enabled)
pushRulesApi.updateEnableRuleStatus(params.kind.value, params.ruleId, enable = params.enable)
}
}
if (params.newPushRule.enabled) {
// Also ensure the actions are up to date
val body = mapOf("actions" to params.newPushRule.actions)
executeRequest(globalErrorReceiver) {
pushRulesApi.updateRuleActions(params.kind.value, params.newPushRule.ruleId, body)
if (params.actions != null) {
val body = mapOf("actions" to params.actions.toJson())
executeRequest(globalErrorReceiver) {
pushRulesApi.updateRuleActions(params.kind.value, params.ruleId, body)
}
}
}
}
}

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ kapt {
// Note: 2 digits max for each value
ext.versionMajor = 1
ext.versionMinor = 1
ext.versionPatch = 14
ext.versionPatch = 15
static def getGitTimestamp() {
def cmd = 'git show -s --format=%ct'
@ -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'

View file

@ -391,6 +391,11 @@ SOFTWARE.
<br/>
Copyright (c) 2017-present, dialog LLC &lt;info@dlg.im&gt;
</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>

View file

@ -111,12 +111,12 @@ import im.vector.app.features.roomprofile.settings.RoomSettingsFragment
import im.vector.app.features.roomprofile.uploads.RoomUploadsFragment
import im.vector.app.features.roomprofile.uploads.files.RoomUploadsFilesFragment
import im.vector.app.features.roomprofile.uploads.media.RoomUploadsMediaFragment
import im.vector.app.features.settings.VectorSettingsAdvancedNotificationPreferenceFragment
import im.vector.app.features.settings.notifications.VectorSettingsAdvancedNotificationPreferenceFragment
import im.vector.app.features.settings.VectorSettingsGeneralFragment
import im.vector.app.features.settings.VectorSettingsHelpAboutFragment
import im.vector.app.features.settings.VectorSettingsLabsFragment
import im.vector.app.features.settings.VectorSettingsNotificationPreferenceFragment
import im.vector.app.features.settings.VectorSettingsNotificationsTroubleshootFragment
import im.vector.app.features.settings.notifications.VectorSettingsNotificationPreferenceFragment
import im.vector.app.features.settings.notifications.VectorSettingsNotificationsTroubleshootFragment
import im.vector.app.features.settings.VectorSettingsPinFragment
import im.vector.app.features.settings.VectorSettingsPreferencesFragment
import im.vector.app.features.settings.VectorSettingsSecurityPrivacyFragment

View file

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

View file

@ -22,18 +22,23 @@ import android.view.View
import android.widget.RadioGroup
import androidx.preference.PreferenceViewHolder
import im.vector.app.R
import org.matrix.android.sdk.api.pushrules.Action
import org.matrix.android.sdk.api.pushrules.RuleIds
import org.matrix.android.sdk.api.pushrules.RuleSetKey
import org.matrix.android.sdk.api.pushrules.rest.PushRule
import org.matrix.android.sdk.api.pushrules.rest.PushRuleAndKind
class PushRulePreference : VectorPreference {
enum class NotificationIndex(val index: Int) {
OFF(0),
SILENT(1),
NOISY(2);
companion object {
fun fromInt(index: Int) = values().first { it.index == index }
}
}
/**
* @return the selected push rule and its kind
* @return the selected push rule index
*/
var ruleAndKind: PushRuleAndKind? = null
var index: NotificationIndex? = null
private set
constructor(context: Context) : super(context)
@ -47,44 +52,12 @@ class PushRulePreference : VectorPreference {
}
/**
* @return the bing rule status index
*/
private val ruleStatusIndex: Int
get() {
val safeRule = ruleAndKind?.pushRule ?: return NOTIFICATION_OFF_INDEX
if (safeRule.ruleId == RuleIds.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS) {
if (safeRule.shouldNotNotify()) {
return if (safeRule.enabled) {
NOTIFICATION_OFF_INDEX
} else {
NOTIFICATION_SILENT_INDEX
}
} else if (safeRule.shouldNotify()) {
return NOTIFICATION_NOISY_INDEX
}
}
if (safeRule.enabled) {
return if (safeRule.shouldNotNotify()) {
NOTIFICATION_OFF_INDEX
} else if (safeRule.getNotificationSound() != null) {
NOTIFICATION_NOISY_INDEX
} else {
NOTIFICATION_SILENT_INDEX
}
}
return NOTIFICATION_OFF_INDEX
}
/**
* Update the push rule.
* Update the notification index.
*
* @param pushRule
*/
fun setPushRule(pushRuleAndKind: PushRuleAndKind?) {
ruleAndKind = pushRuleAndKind
fun setIndex(notificationIndex: NotificationIndex?) {
index = notificationIndex
refreshSummary()
}
@ -92,74 +65,13 @@ class PushRulePreference : VectorPreference {
* Refresh the summary
*/
private fun refreshSummary() {
summary = context.getString(when (ruleStatusIndex) {
NOTIFICATION_OFF_INDEX -> R.string.notification_off
NOTIFICATION_SILENT_INDEX -> R.string.notification_silent
else -> R.string.notification_noisy
summary = context.getString(when (index) {
NotificationIndex.OFF -> R.string.notification_off
NotificationIndex.SILENT -> R.string.notification_silent
NotificationIndex.NOISY, null -> R.string.notification_noisy
})
}
/**
* Create a push rule with the updated required at index.
*
* @param index index
* @return a push rule with the updated flags / null if there is no update
*/
fun createNewRule(index: Int): PushRule? {
val safeRule = ruleAndKind?.pushRule ?: return null
val safeKind = ruleAndKind?.kind ?: return null
return if (index != ruleStatusIndex) {
if (safeRule.ruleId == RuleIds.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS) {
when (index) {
NOTIFICATION_OFF_INDEX -> {
safeRule.copy(enabled = true)
.setNotify(false)
.removeNotificationSound()
}
NOTIFICATION_SILENT_INDEX -> {
safeRule.copy(enabled = false)
.setNotify(false)
}
NOTIFICATION_NOISY_INDEX -> {
safeRule.copy(enabled = true)
.setNotify(true)
.setNotificationSound()
}
else -> safeRule
}
} else {
if (NOTIFICATION_OFF_INDEX == index) {
if (safeKind == RuleSetKey.UNDERRIDE || safeRule.ruleId == RuleIds.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS) {
safeRule.setNotify(false)
} else {
safeRule.copy(enabled = false)
}
} else {
val newRule = safeRule.copy(enabled = true)
.setNotify(true)
.setHighlight(safeKind != RuleSetKey.UNDERRIDE
&& safeRule.ruleId != RuleIds.RULE_ID_INVITE_ME
&& NOTIFICATION_NOISY_INDEX == index)
if (NOTIFICATION_NOISY_INDEX == index) {
newRule.setNotificationSound(
if (safeRule.ruleId == RuleIds.RULE_ID_CALL) {
Action.ACTION_OBJECT_VALUE_VALUE_RING
} else {
Action.ACTION_OBJECT_VALUE_VALUE_DEFAULT
}
)
} else {
newRule.removeNotificationSound()
}
}
}
} else {
safeRule
}
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
@ -170,14 +82,14 @@ class PushRulePreference : VectorPreference {
val radioGroup = holder.findViewById(R.id.bingPreferenceRadioGroup) as? RadioGroup
radioGroup?.setOnCheckedChangeListener(null)
when (ruleStatusIndex) {
NOTIFICATION_OFF_INDEX -> {
when (index) {
NotificationIndex.OFF -> {
radioGroup?.check(R.id.bingPreferenceRadioBingRuleOff)
}
NOTIFICATION_SILENT_INDEX -> {
NotificationIndex.SILENT -> {
radioGroup?.check(R.id.bingPreferenceRadioBingRuleSilent)
}
else -> {
NotificationIndex.NOISY -> {
radioGroup?.check(R.id.bingPreferenceRadioBingRuleNoisy)
}
}
@ -185,23 +97,15 @@ class PushRulePreference : VectorPreference {
radioGroup?.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.bingPreferenceRadioBingRuleOff -> {
onPreferenceChangeListener?.onPreferenceChange(this, NOTIFICATION_OFF_INDEX)
onPreferenceChangeListener?.onPreferenceChange(this, NotificationIndex.OFF)
}
R.id.bingPreferenceRadioBingRuleSilent -> {
onPreferenceChangeListener?.onPreferenceChange(this, NOTIFICATION_SILENT_INDEX)
onPreferenceChangeListener?.onPreferenceChange(this, NotificationIndex.SILENT)
}
R.id.bingPreferenceRadioBingRuleNoisy -> {
onPreferenceChangeListener?.onPreferenceChange(this, NOTIFICATION_NOISY_INDEX)
onPreferenceChangeListener?.onPreferenceChange(this, NotificationIndex.NOISY)
}
}
}
}
companion object {
// index in mRuleStatuses
private const val NOTIFICATION_OFF_INDEX = 0
private const val NOTIFICATION_SILENT_INDEX = 1
private const val NOTIFICATION_NOISY_INDEX = 2
}
}

View file

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

View file

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

View file

@ -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)

View file

@ -57,7 +57,8 @@ fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData {
size = size,
name = displayName,
duration = duration,
queryUri = contentUri
queryUri = contentUri,
waveform = waveform
)
}

View file

@ -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()
}

View file

@ -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()

View file

@ -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)

View file

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

View file

@ -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)?) {

View file

@ -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()
}
}

View file

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

View file

@ -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 {

View file

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

View file

@ -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)

View file

@ -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() }
}
}
/**

View file

@ -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()
}
}
}

View file

@ -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) {

View file

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

View file

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

View file

@ -27,6 +27,7 @@ import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityVectorSettingsBinding
import im.vector.app.features.settings.devices.VectorSettingsDevicesFragment
import im.vector.app.features.settings.notifications.VectorSettingsNotificationPreferenceFragment
import org.matrix.android.sdk.api.failure.GlobalError
import org.matrix.android.sdk.api.session.Session

View file

@ -0,0 +1,92 @@
/*
* 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.settings.notifications
import im.vector.app.core.preference.PushRulePreference
import org.matrix.android.sdk.api.pushrules.RuleIds
fun getStandardAction(ruleId: String, index: PushRulePreference.NotificationIndex): StandardActions? {
return when (ruleId) {
RuleIds.RULE_ID_CONTAIN_DISPLAY_NAME ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.Disabled
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.HighlightDefaultSound
}
RuleIds.RULE_ID_CONTAIN_USER_NAME ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.Disabled
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.HighlightDefaultSound
}
RuleIds.RULE_ID_ROOM_NOTIF ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.Disabled
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.Highlight
}
RuleIds.RULE_ID_ONE_TO_ONE_ROOM ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.DontNotify
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.NotifyDefaultSound
}
RuleIds.RULE_ID_ONE_TO_ONE_ENCRYPTED_ROOM ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.DontNotify
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.NotifyDefaultSound
}
RuleIds.RULE_ID_ALL_OTHER_MESSAGES_ROOMS ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.DontNotify
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.NotifyDefaultSound
}
RuleIds.RULE_ID_ENCRYPTED ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.DontNotify
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.NotifyDefaultSound
}
RuleIds.RULE_ID_INVITE_ME ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.Disabled
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.NotifyDefaultSound
}
RuleIds.RULE_ID_CALL ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.Disabled
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.NotifyRingSound
}
RuleIds.RULE_ID_SUPPRESS_BOTS_NOTIFICATIONS ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.DontNotify
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Disabled
PushRulePreference.NotificationIndex.NOISY -> StandardActions.NotifyDefaultSound
}
RuleIds.RULE_ID_TOMBSTONE ->
when (index) {
PushRulePreference.NotificationIndex.OFF -> StandardActions.Disabled
PushRulePreference.NotificationIndex.SILENT -> StandardActions.Notify
PushRulePreference.NotificationIndex.NOISY -> StandardActions.Highlight
}
else -> null
}
}

View file

@ -0,0 +1,31 @@
/*
* 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.settings.notifications
import org.matrix.android.sdk.api.pushrules.Action
sealed class StandardActions(
val actions: List<Action>?
) {
object Notify : StandardActions(actions = listOf(Action.Notify))
object NotifyDefaultSound : StandardActions(actions = listOf(Action.Notify, Action.Sound()))
object NotifyRingSound : StandardActions(actions = listOf(Action.Notify, Action.Sound(sound = Action.ACTION_OBJECT_VALUE_VALUE_RING)))
object Highlight : StandardActions(actions = listOf(Action.Notify, Action.Highlight(highlight = true)))
object HighlightDefaultSound : StandardActions(actions = listOf(Action.Notify, Action.Highlight(highlight = true), Action.Sound()))
object DontNotify : StandardActions(actions = listOf(Action.DoNotNotify))
object Disabled : StandardActions(actions = null)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2018 New Vector Ltd
* 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.
@ -13,17 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.settings
package im.vector.app.features.settings.notifications
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import im.vector.app.R
import im.vector.app.core.preference.PushRulePreference.NotificationIndex
import im.vector.app.core.preference.PushRulePreference
import im.vector.app.core.preference.VectorPreference
import im.vector.app.core.utils.toast
import im.vector.app.features.settings.VectorSettingsBaseFragment
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.pushrules.RuleIds
import org.matrix.android.sdk.api.pushrules.rest.PushRule
import org.matrix.android.sdk.api.pushrules.rest.PushRuleAndKind
import org.matrix.android.sdk.api.pushrules.toJson
import javax.inject.Inject
class VectorSettingsAdvancedNotificationPreferenceFragment @Inject constructor()
@ -45,24 +49,29 @@ class VectorSettingsAdvancedNotificationPreferenceFragment @Inject constructor()
preference.isVisible = false
} else {
preference.isVisible = true
preference.setPushRule(ruleAndKind)
val initialIndex = getNotificationIndexForRule(ruleAndKind.pushRule)
preference.setIndex(initialIndex)
preference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val newRule = preference.createNewRule(newValue as Int)
if (newRule != null) {
val newIndex = newValue as NotificationIndex
val standardAction = getStandardAction(ruleAndKind.pushRule.ruleId, newIndex)
if (standardAction != null) {
val enabled = standardAction != StandardActions.Disabled
val newActions = standardAction.actions
displayLoadingView()
lifecycleScope.launch {
val result = runCatching {
session.updatePushRuleActions(ruleAndKind.kind,
preference.ruleAndKind?.pushRule ?: ruleAndKind.pushRule,
newRule)
ruleAndKind.pushRule.ruleId,
enabled,
newActions)
}
if (!isAdded) {
return@launch
}
hideLoadingView()
result.onSuccess {
preference.setPushRule(ruleAndKind.copy(pushRule = newRule))
preference.setIndex(newIndex)
}
result.onFailure { failure ->
// Restore the previous value
@ -78,6 +87,28 @@ class VectorSettingsAdvancedNotificationPreferenceFragment @Inject constructor()
}
}
private fun getNotificationIndexForRule(rule: PushRule): NotificationIndex? {
return NotificationIndex.values().firstOrNull {
// Get the actions for the index
val standardAction = getStandardAction(rule.ruleId, it) ?: return@firstOrNull false
val indexActions = standardAction.actions ?: listOf()
// Check if the input rule matches a rule generated from the static rule definitions
val targetRule = rule.copy(enabled = standardAction != StandardActions.Disabled, actions = indexActions.toJson())
ruleMatches(rule, targetRule)
}
}
private fun ruleMatches(rule: PushRule, targetRule: PushRule): Boolean {
// Rules match if both are disabled, or if both are enabled and their highlight/sound/notify actions match up.
return (!rule.enabled && !targetRule.enabled)
|| (rule.enabled
&& targetRule.enabled
&& rule.getHighlight() == targetRule.getHighlight()
&& rule.getNotificationSound() == targetRule.getNotificationSound()
&& rule.shouldNotify() == targetRule.shouldNotify()
&& rule.shouldNotNotify() == targetRule.shouldNotNotify())
}
private fun refreshDisplay() {
listView?.adapter?.notifyDataSetChanged()
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2019 New Vector Ltd
* 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.
@ -14,7 +14,7 @@
* limitations under the License.
*/
package im.vector.app.features.settings
package im.vector.app.features.settings.notifications
import android.app.Activity
import android.content.Context
@ -37,6 +37,11 @@ import im.vector.app.core.pushers.PushersManager
import im.vector.app.core.utils.isIgnoringBatteryOptimizations
import im.vector.app.core.utils.requestDisablingBatteryOptimization
import im.vector.app.features.notifications.NotificationUtils
import im.vector.app.features.settings.BackgroundSyncMode
import im.vector.app.features.settings.BackgroundSyncModeChooserDialog
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.settings.VectorSettingsBaseFragment
import im.vector.app.features.settings.VectorSettingsFragmentInteractionListener
import im.vector.app.push.fcm.FcmHelper
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.tryOrNull

View file

@ -1,5 +1,5 @@
/*
* Copyright 2018 New Vector Ltd
* 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.
@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.settings
package im.vector.app.features.settings.notifications
import android.app.Activity
import android.content.BroadcastReceiver
@ -36,6 +36,7 @@ import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentSettingsNotificationsTroubleshootBinding
import im.vector.app.features.notifications.NotificationUtils
import im.vector.app.features.rageshake.BugReporter
import im.vector.app.features.settings.VectorSettingsFragmentInteractionListener
import im.vector.app.features.settings.troubleshoot.NotificationTroubleshootTestManager
import im.vector.app.features.settings.troubleshoot.TroubleshootTest
import im.vector.app.push.fcm.NotificationTroubleshootTestManagerFactory

View file

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

View file

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

View file

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

View file

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

View 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
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -639,7 +639,7 @@
<string name="room_resend_unsent_messages">Znovu odeslat neodeslané zprávy</string>
<string name="room_delete_unsent_messages">Smazat neodeslané zprávy</string>
<string name="room_message_file_not_found">Soubor nenalezen</string>
<string name="room_do_not_have_permission_to_post">Nemáte právo odesílat v této místnosti</string>
<string name="room_do_not_have_permission_to_post">Nemáte právo odesílat v této místnosti.</string>
<plurals name="room_new_messages_notification">
<item quantity="one">%d nová zpráva</item>
<item quantity="few">%d nové zprávy</item>
@ -1356,7 +1356,7 @@
<string name="deactivate_account_submit">Deaktivovat účet</string>
<string name="error_empty_field_enter_user_name">Zadejte, prosím, uživatelské jméno.</string>
<string name="error_empty_field_your_password">Prosím, zadejte své heslo.</string>
<string name="room_tombstone_versioned_description">Tato místnost byla nahrazena a není již aktivní</string>
<string name="room_tombstone_versioned_description">Tato místnost byla nahrazena a není již aktivní.</string>
<string name="room_tombstone_continuation_link">Konverzace pokračuje tady</string>
<string name="room_tombstone_continuation_description">Tato místnost je pokračováním jiné konverzace</string>
<string name="room_tombstone_predecessor_link">Po kliknutí zde uvidíte starší zprávy</string>
@ -2868,4 +2868,29 @@
<string name="create_space_alias_hint">Adresa prostoru</string>
<string name="space_settings_alias_subtitle">Prohlédnout a spravovat adresy tohoto prostoru.</string>
<string name="space_settings_alias_title">Adresy prostorů</string>
<string name="room_upgrade_to_recommended_version">Aktualizujte na doporučenou verzi místnosti</string>
<string name="room_using_unstable_room_version">Tato místnost používá místnost verze %s, kterou homeserver označil za nestabilní.</string>
<string name="upgrade_room_no_power_to_manage">K aktualizaci místnosti potřebujete oprávnění</string>
<string name="upgrade_room_update_parent_space">Automaticky aktualizovat mateřský prostor</string>
<string name="upgrade_room_auto_invite">Automaticky pozvat uživatele</string>
<string name="upgrade_public_room_from_to">Budete aktualizovat tuto místnost z %s na %s.</string>
<string name="upgrade_room_warning">Aktualizace místnosti je pokročilá akce a obvykle se doporučuje tehdy, je-li místnost nestabilní kvůli chybám, chybějícím funkcím nebo slabým místům v zabezpečení.
\nObvykle má vliv pouze na to, jak server místnost zpracovává.</string>
<string name="upgrade_private_room">Aktualizovat soukromou místnost</string>
<string name="upgrade_public_room">Aktualizovat veřejnou místnost</string>
<string name="upgrade">Aktualizace</string>
<string name="it_may_take_some_time">Buďte, prosím, trpěliví, může to chvíli trvat.</string>
<string name="joining_replacement_room">Vstupte do náhradní místnosti</string>
<string name="command_description_upgrade_room">Aktualizuje místnost na novou verzi</string>
<string name="settings_server_room_version_unstable">nestabilní</string>
<string name="settings_server_room_version_stable">stabilní</string>
<string name="settings_server_default_room_version">Výchozí verze</string>
<string name="settings_server_room_versions">Verze místností 👓</string>
<string name="verification_scan_self_emoji_subtitle">Raději ověřit porovnáním emoji</string>
<string name="verification_scan_with_this_device">Oskenovat tímto zařízením</string>
<string name="verification_scan_self_notice">Oskenujte kód svým dalším zařízením nebo přepněte a oskenujte tímto zařízením</string>
<string name="hs_client_url">URL API Homeserveru</string>
<string name="missing_permissions_title">Chybějící oprávnění</string>
<string name="denied_permission_camera">Pro provedení této akce udělte, prosím, oprávnění Fotoaparát v systémových nastaveních.</string>
<string name="denied_permission_generic">Některá z oprávnění potřebných k provedení akce chybí, prosím, udělte oprávnění v systémových nastaveních.</string>
</resources>

View file

@ -8,7 +8,7 @@
<string name="notice_room_join">%1$s hat den Raum betreten</string>
<string name="notice_room_leave">%1$s hat den Raum verlassen</string>
<string name="notice_room_reject">%1$s hat die Einladung abgelehnt</string>
<string name="notice_room_kick">%1$s hat %2$s gekickt</string>
<string name="notice_room_kick">%1$s hat %2$s entfernt</string>
<string name="notice_room_unban">%1$s hat den Bann von %2$s aufgehoben</string>
<string name="notice_room_ban">%1$s hat %2$s gebannt</string>
<string name="notice_room_withdraw">%1$s hat die Einladung für %2$s zurückgezogen</string>
@ -95,7 +95,7 @@
<string name="notice_room_join_with_reason">%1$s ist dem Raum beigetreten. Grund: %2$s</string>
<string name="notice_room_leave_with_reason">%1$s hat den Raum verlassen. Grund: %2$s</string>
<string name="notice_room_reject_with_reason">%1$s hat die Einladung abgelehnt. Grund: %2$s</string>
<string name="notice_room_kick_with_reason">%1$s hat %2$s gekickt. Grund: %3$s</string>
<string name="notice_room_kick_with_reason">%1$s hat %2$s entfernt. Grund: %3$s</string>
<string name="notice_room_unban_with_reason">%1$s hat Sperre von %2$s aufgehoben. Grund: %3$s</string>
<string name="notice_room_ban_with_reason">%1$s hat %2$s verbannt. Grund: %3$s</string>
<string name="notice_room_third_party_invite_with_reason">%1$s hat eine Einladung an %2$s gesandt um diesem Raum beizutreten. Grund: %3$s</string>
@ -952,7 +952,7 @@
<string name="filter_group_members">Filter Gruppen-Mitglieder</string>
<string name="filter_group_rooms">Filter Gruppen-Räume</string>
<string name="group_no_long_description">Der Community-Administrator hat keine lange Beschreibung für diese Community zur Verfügung gestellt.</string>
<string name="has_been_kicked">Du wurdest von %2$s aus %1$s gekickt</string>
<string name="has_been_kicked">Du wurdest von %2$s aus %1$s entfernt</string>
<string name="has_been_banned">Du wurdest von %2$s aus %1$s verbannt</string>
<string name="reason_colon">Grund: %1$s</string>
<string name="rejoin">Erneut beitreten</string>
@ -1075,7 +1075,7 @@
<string name="command_description_join_room">Tritt dem Raum mit angegebenen Alias bei</string>
<string name="command_description_part_room">Verlasse Raum</string>
<string name="command_description_topic">Raumthema ändern</string>
<string name="command_description_kick_user">Kickt Benutzer mit angegebener ID</string>
<string name="command_description_kick_user">Entfernt die Person angegebener ID</string>
<string name="command_description_nick">Ändert deinen Anzeigenamen</string>
<string name="command_description_markdown">(De-)Aktiviert Markdown</string>
<string name="command_description_clear_scalar_token">Um das Matrix-App-Management zu reparieren</string>
@ -2628,7 +2628,7 @@
<string name="login_social_continue_with">Mit %s weitermachen</string>
<string name="settings_show_emoji_keyboard_summary">Knopf zum Nachrichteneditor hinzufügen, der die Emoji-Tastatur öffnet</string>
<string name="settings_show_emoji_keyboard">Emoji-Tastatur anzeigen</string>
<string name="settings_chat_effects_description">Nutze /confetti Kommando oder sende Nachrichten, die ❄️ oder 🎉 enthalten</string>
<string name="settings_chat_effects_description">Nutze /confetti oder sende Nachrichten mit ❄️ oder 🎉</string>
<string name="settings_chat_effects_title">Chateffekte</string>
<string name="room_permissions_change_topic">Thema ändern</string>
<string name="room_permissions_upgrade_the_room">Raum aktualisieren</string>
@ -2877,4 +2877,11 @@
<string name="space_settings_alias_subtitle">Adressen des Spaces anzeigen und verwalten.</string>
<string name="create_space_alias_hint">Space-Adressen</string>
<string name="error_failed_to_join_room">Beim Versuch %s beizutreten, ist leider ein Fehler aufgetreten</string>
<string name="room_upgrade_to_recommended_version">Zur empfohlenen Raumversion upgraden</string>
<string name="joining_replacement_room">Ersatzraum betreten</string>
<string name="command_description_upgrade_room">Raum zu neuer Version upgraden</string>
<string name="settings_server_room_version_stable">stabil</string>
<string name="settings_server_room_version_unstable">instabil</string>
<string name="settings_server_room_versions">Raumversionen 👓</string>
<string name="missing_permissions_title">Fehlende Berechtigungen</string>
</resources>

View file

@ -533,7 +533,7 @@
<string name="e2e_re_request_encryption_key">Küsi oma muudest sessioonidest krüptimisvõtmed uuesti.</string>
<string name="e2e_re_request_encryption_key_sent">Võtmete jagamise päring on saadetud.</string>
<string name="e2e_re_request_encryption_key_dialog_title">Päring on saadetud</string>
<string name="template_e2e_re_request_encryption_key_dialog_content">Palun käivita ${app_name} mõnes muus seadmes, mis suudab neid sõnumeid dekrüptoda ja seega saata krüptovõtmeid siia sessiooni.</string>
<string name="template_e2e_re_request_encryption_key_dialog_content">Palun käivita ${app_name} mõnes muus seadmes, mis suudab neid sõnumeid dekrüptida ja seega saata krüptovõtmeid siia sessiooni.</string>
<string name="read_receipts_list">Lugemisteatiste loend</string>
<string name="groups_list">Gruppide loend</string>
<plurals name="membership_changes">
@ -1197,7 +1197,7 @@
<string name="room_resend_unsent_messages">Saada saatmata sõnumid uuesti</string>
<string name="room_delete_unsent_messages">Kustuta saatmata sõnumid</string>
<string name="room_message_file_not_found">Faili ei leidunud</string>
<string name="room_do_not_have_permission_to_post">Sul ei ole õigusi siia jututuppa kirjutamiseks</string>
<string name="room_do_not_have_permission_to_post">Sul ei ole õigusi siia jututuppa kirjutamiseks.</string>
<plurals name="room_new_messages_notification">
<item quantity="one">%d uus sõnum</item>
<item quantity="other">%d uut sõnumit</item>
@ -1608,7 +1608,7 @@
<string name="deactivate_account_submit">Deaktiveeri konto</string>
<string name="error_empty_field_enter_user_name">Palun sisesta kasutajanimi.</string>
<string name="error_empty_field_your_password">Palun sisesta oma salasõna.</string>
<string name="room_tombstone_versioned_description">See jututuba on asendatud teise jututoaga ning ei ole enam kasutusel</string>
<string name="room_tombstone_versioned_description">See jututuba on asendatud teise jututoaga ning ei ole enam kasutusel.</string>
<string name="room_tombstone_continuation_link">Vestlus jätkub siin</string>
<string name="room_tombstone_continuation_description">See jututuba on järg varasemale vestlusele</string>
<string name="room_tombstone_predecessor_link">Vanemate sõnumite nägemiseks klõpsi siin</string>
@ -2815,4 +2815,29 @@
<string name="create_space_alias_hint">Kogukonnakeskuse aadressid</string>
<string name="space_settings_alias_subtitle">Selle kogukonnakeskuse hallatud ja nähtavad aadressid.</string>
<string name="space_settings_alias_title">Kogukonnakeskuse aadressid</string>
<string name="settings_server_room_version_unstable">ebapüsiv</string>
<string name="settings_server_room_version_stable">stabiilne</string>
<string name="room_upgrade_to_recommended_version">Uuenda see jututoa versioon soovitatud versioonini</string>
<string name="room_using_unstable_room_version">Selle jututoa versioon on %s ning see koduserver on tema märkinud ebastabiilseks.</string>
<string name="upgrade_room_no_power_to_manage">Jututoa versiooni uuendamiseks on sul vaja õigusi</string>
<string name="upgrade_room_update_parent_space">Uuenda automaatselt ka kogukonnakeskust, kus jututuba osaleb</string>
<string name="upgrade_room_auto_invite">Kutsu automaatselt kasutajaid</string>
<string name="upgrade_public_room_from_to">Sa oled uuendamas jututoa versiooni: %s -&gt; %s.</string>
<string name="upgrade_room_warning">Jututoa versiooni uuendamine on erandlik tegevus ning soovitame seda vaid siis kui ta on vigade tõttu raskestikasutatav, tal puuduvad uued funktsionaalsused või temas leidub turvavigu.
\nTavaliselt versiooniuuendus mõjutab vaid seda viisi kuidas andmeid töödeldakse serveris.</string>
<string name="upgrade_private_room">Uuenda omavaheline jututuba</string>
<string name="upgrade_public_room">Uuenda avalik jututuba</string>
<string name="upgrade">Uuenda</string>
<string name="it_may_take_some_time">Palun oota rahulikult. Selleks võib kuluda natuke aega.</string>
<string name="joining_replacement_room">Liitu asenduseks mõeldud jututoaga</string>
<string name="command_description_upgrade_room">Uuendab jututoa uue versioonini</string>
<string name="settings_server_default_room_version">Vaikimisi versioon</string>
<string name="settings_server_room_versions">Jututubade versioonid 👓</string>
<string name="verification_scan_self_emoji_subtitle">Selle asemel verifitseeri võrreldes emoji\'sid</string>
<string name="verification_scan_with_this_device">Skaneeri selle seadmega</string>
<string name="verification_scan_self_notice">Skaneeri koodi oma teise seadmega või vaheta pooli ning skaneeri selle seadmega</string>
<string name="hs_client_url">Koduserveri API aadress</string>
<string name="missing_permissions_title">Õigused on puudu</string>
<string name="denied_permission_camera">Selle tegevuse jaoks palun luba seadistustes sellele rakendusele kaamera kasutamine.</string>
<string name="denied_permission_generic">Selle tegevuse jaoks puuduvad sul õigused. Palun jaga vajalikud õigused süsteemi seadistustest.</string>
</resources>

Some files were not shown because too many files have changed in this diff Show more