Merge branch 'develop' into feature/bca/rust_flavor

This commit is contained in:
valere 2023-01-12 14:51:03 +01:00
commit c0397875f0
72 changed files with 1201 additions and 152 deletions

View File

@ -11,7 +11,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
uses: danger/danger-js@11.2.0
uses: danger/danger-js@11.2.1
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:

View File

@ -66,7 +66,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
uses: danger/danger-js@11.2.0
uses: danger/danger-js@11.2.1
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:

View File

@ -1,3 +1,30 @@
Changes in Element v1.5.20 (2023-01-10)
=======================================
Features ✨
----------
- "[Rich text editor] Add list formatting buttons to the rich text editor" ([#7887](https://github.com/vector-im/element-android/issues/7887))
Bugfixes 🐛
----------
- ReplyTo are not updated if the original message is edited or deleted. ([#5546](https://github.com/vector-im/element-android/issues/5546))
- Observe ViewEvents only when resumed and ensure ViewEvents are not lost. ([#7724](https://github.com/vector-im/element-android/issues/7724))
- [Session manager] Missing info when a session does not support encryption ([#7853](https://github.com/vector-im/element-android/issues/7853))
- Reduce number of crypto database transactions when handling the sync response ([#7879](https://github.com/vector-im/element-android/issues/7879))
- [Voice Broadcast] Stop listening if we reach the last received chunk and there is no last sequence number ([#7899](https://github.com/vector-im/element-android/issues/7899))
- Handle network error on API `rooms/{roomId}/threads` ([#7913](https://github.com/vector-im/element-android/issues/7913))
In development 🚧
----------------
- [Poll] Render active polls list of a room
- [Poll] Render past polls list of a room ([#7864](https://github.com/vector-im/element-android/issues/7864))
Other changes
-------------
- fix: increase font size for messages ([#5717](https://github.com/vector-im/element-android/issues/5717))
- Add trim to username input on the app side and SDK side when sign-in ([#7111](https://github.com/vector-im/element-android/issues/7111))
Changes in Element v1.5.18 (2023-01-02)
=======================================

View File

@ -127,7 +127,8 @@ GEM
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
git (1.11.0)
git (1.13.0)
addressable (~> 2.8)
rchardet (~> 1.8)
google-apis-androidpublisher_v3 (0.25.0)
google-apis-core (>= 0.7, < 2.a)

5
SECURITY.md Normal file
View File

@ -0,0 +1,5 @@
# Reporting a Vulnerability
**If you've found a security vulnerability, please report it to security@matrix.org**
For more information on our security disclosure policy, visit https://www.matrix.org/security-disclosure-policy/

View File

@ -27,8 +27,8 @@ buildscript {
classpath 'com.google.firebase:firebase-appdistribution-gradle:3.1.1'
classpath 'com.google.gms:google-services:4.3.14'
classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.5.0.2730'
classpath 'com.google.android.gms:oss-licenses-plugin:0.10.5'
classpath "com.likethesalad.android:stem-plugin:2.2.3"
classpath 'com.google.android.gms:oss-licenses-plugin:0.10.6'
classpath "com.likethesalad.android:stem-plugin:2.3.0"
classpath 'org.owasp:dependency-check-gradle:7.4.4'
classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.7.20"
classpath "org.jetbrains.kotlinx:kotlinx-knit:0.4.0"
@ -45,10 +45,10 @@ plugins {
// Detekt
id "io.gitlab.arturbosch.detekt" version "1.22.0"
// Ksp
id "com.google.devtools.ksp" version "1.7.22-1.0.8"
id "com.google.devtools.ksp" version "1.8.0-1.0.8"
// Dependency Analysis
id 'com.autonomousapps.dependency-analysis' version "1.17.0"
id 'com.autonomousapps.dependency-analysis' version "1.18.0"
// Gradle doctor
id "com.osacky.doctor" version "0.8.1"
}

View File

@ -1 +0,0 @@
ReplyTo are not updated if the original message is edited or deleted.

View File

@ -1 +0,0 @@
Observe ViewEvents only when resumed and ensure ViewEvents are not lost.

1
changelog.d/7832.bugfix Normal file
View File

@ -0,0 +1 @@
[Voice Broadcast] Fix unexpected "live broadcast" in the room list

View File

@ -1 +0,0 @@
[Session manager] Missing info when a session does not support encryption

View File

@ -1,2 +0,0 @@
[Poll] Render active polls list of a room
[Poll] Render past polls list of a room

View File

@ -1 +0,0 @@
Reduce number of crypto database transactions when handling the sync response

View File

@ -1 +0,0 @@
"[Rich text editor] Add list formatting buttons to the rich text editor"

View File

@ -1 +0,0 @@
[Voice Broadcast] Stop listening if we reach the last received chunk and there is no last sequence number

1
changelog.d/7900.feature Normal file
View File

@ -0,0 +1 @@
Render ended polls

View File

@ -1 +0,0 @@
Handle network error on API `rooms/{roomId}/threads`

1
changelog.d/7930.feature Normal file
View File

@ -0,0 +1 @@
"[Rich text editor] Update list item bullet appearance"

1
changelog.d/7936.misc Normal file
View File

@ -0,0 +1 @@
Upgrade to Kotlin 1.8

View File

@ -80,12 +80,12 @@ task generateCoverageReport(type: JacocoReport) {
task unitTestsWithCoverage(type: GradleBuild) {
// the 7.1.3 android gradle plugin has a bug where enableTestCoverage generates invalid coverage
startParameter.projectProperties.coverage = [enableTestCoverage: false]
startParameter.projectProperties.coverage = "false"
tasks = ['testDebugUnitTest']
}
task instrumentationTestsWithCoverage(type: GradleBuild) {
startParameter.projectProperties.coverage = [enableTestCoverage: true]
startParameter.projectProperties.coverage = "true"
startParameter.projectProperties['android.testInstrumentationRunnerArguments.notPackage'] = 'im.vector.app.ui'
tasks = [':vector-app:connectedGplayKotlinCryptoDebugAndroidTest', ':vector:connectedKotlinCryptoDebugAndroidTest', 'matrix-sdk-android:connectedKotlinCryptoDebugAndroidTest']
}

View File

@ -8,7 +8,7 @@ ext.versions = [
def gradle = "7.3.1"
// Ref: https://kotlinlang.org/releases.html
def kotlin = "1.7.22"
def kotlin = "1.8.0"
def kotlinCoroutines = "1.6.4"
def dagger = "2.44.2"
def firebaseBom = "31.1.1"
@ -18,7 +18,7 @@ def markwon = "4.6.2"
def moshi = "1.14.0"
def lifecycle = "2.5.1"
def flowBinding = "1.2.0"
def flipper = "0.176.0"
def flipper = "0.176.1"
def epoxy = "5.0.0"
def mavericks = "3.0.1"
def glide = "4.14.2"
@ -27,12 +27,13 @@ def jjwt = "0.11.5"
// Temporary version to unblock #6929. Once 0.16.0 is released we should use it, and revert
// the whole commit which set version 0.16.0-SNAPSHOT
def vanniktechEmoji = "0.16.0-SNAPSHOT"
def sentry = "6.9.2"
def fragment = "1.5.5"
def sentry = "6.11.0"
// Use 1.6.0 alpha to fix issue with test
def fragment = "1.6.0-alpha04"
// Testing
def mockk = "1.12.3" // We need to use 1.12.3 to have mocking in androidTest until a new version is released: https://github.com/mockk/mockk/issues/819
def espresso = "3.4.0"
def androidxTest = "1.4.0"
def espresso = "3.5.1"
def androidxTest = "1.5.0"
def androidxOrchestrator = "1.4.2"
def paparazzi = "1.1.0"
@ -56,11 +57,12 @@ ext.libs = [
'exifinterface' : "androidx.exifinterface:exifinterface:1.3.5",
'fragmentKtx' : "androidx.fragment:fragment-ktx:$fragment",
'fragmentTesting' : "androidx.fragment:fragment-testing:$fragment",
'fragmentTestingManifest' : "androidx.fragment:fragment-testing-manifest:$fragment",
'constraintLayout' : "androidx.constraintlayout:constraintlayout:2.1.4",
'work' : "androidx.work:work-runtime-ktx:2.7.1",
'autoFill' : "androidx.autofill:autofill:1.1.0",
'preferenceKtx' : "androidx.preference:preference-ktx:1.2.0",
'junit' : "androidx.test.ext:junit:1.1.3",
'junit' : "androidx.test.ext:junit:1.1.5",
'lifecycleCommon' : "androidx.lifecycle:lifecycle-common:$lifecycle",
'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle",
'lifecycleProcess' : "androidx.lifecycle:lifecycle-process:$lifecycle",
@ -86,7 +88,7 @@ ext.libs = [
'appdistributionApi' : "com.google.firebase:firebase-appdistribution-api-ktx:$appDistribution",
'appdistribution' : "com.google.firebase:firebase-appdistribution:$appDistribution",
// Phone number https://github.com/google/libphonenumber
'phonenumber' : "com.googlecode.libphonenumber:libphonenumber:8.13.3"
'phonenumber' : "com.googlecode.libphonenumber:libphonenumber:8.13.4"
],
dagger : [
'dagger' : "com.google.dagger:dagger:$dagger",
@ -101,7 +103,7 @@ ext.libs = [
],
element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0",
'wysiwyg' : "io.element.android:wysiwyg:0.14.0"
'wysiwyg' : "io.element.android:wysiwyg:0.15.0"
],
squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi",

View File

@ -0,0 +1,2 @@
Main changes in this version: Mainly bugfixing!
Full changelog: https://github.com/vector-im/element-android/releases

View File

@ -3187,7 +3187,8 @@
<item quantity="other">Final result based on %1$d votes</item>
</plurals>
<string name="poll_end_action">End poll</string>
<string name="a11y_poll_winner_option">winner option</string>
<!-- TODO TO BE REMOVED -->
<string name="a11y_poll_winner_option" tools:ignore="UnusedResources">winner option</string>
<string name="end_poll_confirmation_title">End this poll?</string>
<string name="end_poll_confirmation_description">This will stop people from being able to vote and will display the final results of the poll.</string>
<string name="end_poll_confirmation_approve_button">End poll</string>
@ -3201,6 +3202,7 @@
<string name="open_poll_option_description">Voters see results as soon as they have voted</string>
<string name="closed_poll_option_title">Closed poll</string>
<string name="closed_poll_option_description">Results are only revealed when you end the poll</string>
<string name="ended_poll_indicator">Ended the poll.</string>
<string name="room_polls_active">Active polls</string>
<string name="room_polls_active_no_item">There are no active polls in this room</string>
<string name="room_polls_ended">Past polls</string>
@ -3518,6 +3520,9 @@
<string name="message_reply_to_sender_sent_video">sent a video.</string>
<string name="message_reply_to_sender_sent_sticker">sent a sticker.</string>
<string name="message_reply_to_sender_created_poll">created a poll.</string>
<string name="message_reply_to_sender_ended_poll">ended a poll.</string>
<string name="message_reply_to_poll_preview">Poll</string>
<string name="message_reply_to_ended_poll_preview">Ended poll</string>
<string name="settings_access_token">Access Token</string>
<string name="settings_access_token_summary">Your access token gives full access to your account. Do not share it with anyone.</string>

View File

@ -22,6 +22,7 @@
<item name="android:clipToPadding">false</item>
<item name="android:textSize">15sp</item>
<item name="android:textColor">?vctr_message_text_color</item>
<item name="lineHeight">20sp</item>
</style>
</resources>

View File

@ -63,7 +63,7 @@ android {
// that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true'
buildConfigField "String", "SDK_VERSION", "\"1.5.20\""
buildConfigField "String", "SDK_VERSION", "\"1.5.22\""
buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\""
buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\""
@ -82,7 +82,7 @@ android {
buildTypes {
debug {
if (project.hasProperty("coverage")) {
testCoverageEnabled = coverage.enableTestCoverage
testCoverageEnabled = coverage == "true"
}
// Set to true to log privacy or sensible data, such as token
buildConfigField "boolean", "LOG_PRIVATE_DATA", project.property("vector.debugPrivateData")

View File

@ -1,6 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.matrix.android.sdk">
xmlns:tools="http://schemas.android.com/tools">
<application>

View File

@ -248,7 +248,7 @@ data class Event(
if (isRedacted()) return "Message removed"
val text = getDecryptedValue() ?: run {
if (isPoll()) {
return getPollQuestion() ?: "created a poll."
return getTextSummaryForPoll()
}
return null
}
@ -261,13 +261,23 @@ data class Event(
isImageMessage() -> "sent an image."
isVideoMessage() -> "sent a video."
isSticker() -> "sent a sticker."
isPoll() -> getPollQuestion() ?: "created a poll."
isPoll() -> getTextSummaryForPoll()
isLiveLocation() -> "Live location."
isLocationMessage() -> "has shared their location."
else -> text
}
}
private fun getTextSummaryForPoll(): String? {
val pollQuestion = getPollQuestion()
return when {
pollQuestion != null -> pollQuestion
isPollStart() -> "created a poll."
isPollEnd() -> "ended a poll."
else -> null
}
}
private fun Event.isQuote(): Boolean {
if (isReplyRenderedInThread()) return false
return getDecryptedValue("formatted_body")?.contains("<blockquote>") ?: false

View File

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.model.message
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
/**
@ -25,5 +26,12 @@ import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultCon
*/
@JsonClass(generateAdapter = true)
data class MessageEndPollContent(
@Json(name = "m.relates_to") val relatesTo: RelationDefaultContent? = null
)
/**
* Local message type, not from server.
*/
@Transient
override val msgType: String = MessageType.MSGTYPE_POLL_END,
@Json(name = "body") override val body: String = "",
@Json(name = "m.new_content") override val newContent: Content? = null,
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null
) : MessageContent

View File

@ -38,6 +38,7 @@ object MessageType {
// Because poll events are not message events and they don't have msgtype field
const val MSGTYPE_POLL_START = "org.matrix.android.sdk.poll.start"
const val MSGTYPE_POLL_RESPONSE = "org.matrix.android.sdk.poll.response"
const val MSGTYPE_POLL_END = "org.matrix.android.sdk.poll.end"
const val MSGTYPE_CONFETTI = "nic.custom.confetti"
const val MSGTYPE_SNOWFALL = "io.element.effect.snowfall"

View File

@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoCo
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody
import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
@ -148,6 +149,7 @@ fun TimelineEvent.getLastMessageContent(): MessageContent? {
// so toModel<MessageContent> won't parse them correctly
// It's discriminated on event type instead. Maybe it shouldn't be MessageContent at all to avoid confusion?
in EventType.POLL_START.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessagePollContent>()
in EventType.POLL_END.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageEndPollContent>()
in EventType.STATE_ROOM_BEACON_INFO.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconInfoContent>()
in EventType.BEACON_LOCATION_DATA.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconLocationDataContent>()
else -> (getLastEditNewContent() ?: root.getClearContent()).toModel()

View File

@ -69,7 +69,7 @@ internal class DefaultLoginWizard(
)
} else {
PasswordLoginParams.userIdentifier(
user = login,
user = login.trim(),
password = password,
deviceDisplayName = initialDeviceName,
deviceId = deviceId

View File

@ -30,10 +30,4 @@ internal data class GetPushRulesResponse(
*/
@Json(name = "global")
val global: RuleSet,
/**
* Device specific rules, apply only to current device.
*/
@Json(name = "device")
val device: RuleSet? = null
)

View File

@ -42,7 +42,6 @@ internal class DefaultSavePushRulesTask @Inject constructor(@SessionDatabase pri
.findAll()
.forEach { it.deleteOnCascade() }
// Save only global rules for the moment
val globalRules = params.pushRules.global
val content = PushRulesEntity(RuleScope.GLOBAL).apply { kind = RuleSetKey.CONTENT }

View File

@ -359,9 +359,9 @@ adb -d install ${apkPath}
read -p "Please run the APK on your phone to check that the upgrade went well (no init sync, etc.). Press enter when it's done."
printf "\n================================================================================\n"
githubCreateReleaseLink="https://github.com/vector-im/element-android/releases/new?tag=v${version}&title=Element%%20Android%%20v${version}&body=${changelogUrlEncoded}"
githubCreateReleaseLink="https://github.com/vector-im/element-android/releases/new?tag=v${version}&title=Element%20Android%20v${version}&body=${changelogUrlEncoded}"
printf "Creating the release on gitHub.\n"
printf "Open this link: ${githubCreateReleaseLink}\n"
printf -- "Open this link: %s\n" ${githubCreateReleaseLink}
printf "Then\n"
printf " - click on the 'Generate releases notes' button\n"
printf " - Add the 4 signed APKs to the GitHub release. They are located at ${targetPath}\n"
@ -369,7 +369,7 @@ read -p ". Press enter when it's done. "
printf "\n================================================================================\n"
printf "Message for the Android internal room:\n\n"
message="@room Element Android ${version} is ready to be tested. You can get if from https://github.com/vector-im/element-android/releases/tag/v${version}. Please report any feedback here. Thanks!"
message="@room Element Android ${version} is ready to be tested. You can get it from https://github.com/vector-im/element-android/releases/tag/v${version}. Please report any feedback here. Thanks!"
printf "${message}\n\n"
if [[ -z "${elementBotToken}" ]]; then

View File

@ -37,7 +37,7 @@ ext.versionMinor = 5
// Note: even values are reserved for regular release, odd values for hotfix release.
// When creating a hotfix, you should decrease the value, since the current value
// is the value for the next regular release.
ext.versionPatch = 20
ext.versionPatch = 22
static def getGitTimestamp() {
def cmd = 'git show -s --format=%ct'
@ -251,7 +251,7 @@ android {
signingConfig signingConfigs.debug
if (project.hasProperty("coverage")) {
testCoverageEnabled = coverage.enableTestCoverage
testCoverageEnabled = coverage == "true"
}
}
@ -448,7 +448,7 @@ dependencies {
androidTestImplementation libs.mockk.mockkAndroid
androidTestUtil libs.androidx.orchestrator
androidTestImplementation libs.androidx.fragmentTesting
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.22"
debugImplementation libs.androidx.fragmentTesting
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.8.0"
debugImplementation libs.androidx.fragmentTestingManifest
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
}

View File

@ -69,7 +69,7 @@ android {
buildTypes {
debug {
if (project.hasProperty("coverage")) {
testCoverageEnabled = coverage.enableTestCoverage
testCoverageEnabled = coverage == "true"
}
}
}
@ -135,7 +135,7 @@ dependencies {
implementation libs.androidx.biometric
api "org.threeten:threetenbp:1.4.0:no-tzdb"
api "com.gabrielittner.threetenbp:lazythreetenbp:0.12.0"
api "com.gabrielittner.threetenbp:lazythreetenbp:0.13.0"
implementation libs.squareup.moshi
kapt libs.squareup.moshiKotlin
@ -333,6 +333,7 @@ dependencies {
}
androidTestImplementation libs.mockk.mockkAndroid
androidTestUtil libs.androidx.orchestrator
debugImplementation libs.androidx.fragmentTesting
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.22"
debugImplementation libs.androidx.fragmentTestingManifest
androidTestImplementation libs.androidx.fragmentTesting
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.8.0"
}

View File

@ -27,7 +27,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
fun TimelineEvent.canReact(): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values &&
return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values + EventType.POLL_END.values &&
root.sendState == SendState.SYNCED &&
!root.isRedacted()
}

View File

@ -16,16 +16,18 @@
package im.vector.app.core.utils
import im.vector.app.core.platform.VectorViewEvents
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import java.util.concurrent.CopyOnWriteArraySet
interface SharedEvents<out T> {
interface SharedEvents<out T : VectorViewEvents> {
fun stream(consumerId: String): Flow<T>
}
class EventQueue<T>(capacity: Int) : SharedEvents<T> {
class EventQueue<T : VectorViewEvents>(capacity: Int) : SharedEvents<T> {
private val innerQueue = MutableSharedFlow<OneTimeEvent<T>>(replay = capacity)
@ -33,7 +35,12 @@ class EventQueue<T>(capacity: Int) : SharedEvents<T> {
innerQueue.tryEmit(OneTimeEvent(event))
}
override fun stream(consumerId: String): Flow<T> = innerQueue.filterNotHandledBy(consumerId)
override fun stream(consumerId: String): Flow<T> = innerQueue
.onEach {
// Ensure that buffered Events will not be sent again to new subscribers.
innerQueue.resetReplayCache()
}
.filterNotHandledBy(consumerId)
}
/**
@ -42,7 +49,7 @@ class EventQueue<T>(capacity: Int) : SharedEvents<T> {
*
* Keeps track of who has already handled its content.
*/
private class OneTimeEvent<out T>(private val content: T) {
private class OneTimeEvent<out T : VectorViewEvents>(private val content: T) {
private val handlers = CopyOnWriteArraySet<String>()
@ -53,6 +60,6 @@ private class OneTimeEvent<out T>(private val content: T) {
fun getIfNotHandled(asker: String): T? = if (handlers.add(asker)) content else null
}
private fun <T> Flow<OneTimeEvent<T>>.filterNotHandledBy(consumerId: String): Flow<T> = transform { event ->
private fun <T : VectorViewEvents> Flow<OneTimeEvent<T>>.filterNotHandledBy(consumerId: String): Flow<T> = transform { event ->
event.getIfNotHandled(consumerId)?.let { emit(it) }
}

View File

@ -44,6 +44,7 @@ import org.commonmark.parser.Parser
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
@ -181,6 +182,7 @@ class PlainTextComposerLayout @JvmOverloads constructor(
is MessageAudioContent -> getAudioContentBodyText(messageContent)
is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion()
is MessageBeaconInfoContent -> resources.getString(R.string.live_location_description)
is MessageEndPollContent -> resources.getString(R.string.message_reply_to_ended_poll_preview)
else -> messageContent?.body.orEmpty()
}
var formattedBody: CharSequence? = null

View File

@ -25,8 +25,14 @@ import javax.inject.Inject
class CheckIfCanReplyEventUseCase @Inject constructor() {
fun execute(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean {
// Only EventType.MESSAGE, EventType.POLL_START and EventType.STATE_ROOM_BEACON_INFO event types are supported for the moment
if (event.root.getClearType() !in EventType.STATE_ROOM_BEACON_INFO.values + EventType.POLL_START.values + EventType.MESSAGE) return false
// Only EventType.MESSAGE, EventType.POLL_START, EventType.POLL_END and EventType.STATE_ROOM_BEACON_INFO event types are supported for the moment
if (event.root.getClearType() !in
EventType.STATE_ROOM_BEACON_INFO.values +
EventType.POLL_START.values +
EventType.POLL_END.values +
EventType.MESSAGE
) return false
if (!actionPermissions.canSendMessage) return false
return when (messageContent?.msgType) {
MessageType.MSGTYPE_TEXT,
@ -37,6 +43,7 @@ class CheckIfCanReplyEventUseCase @Inject constructor() {
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE,
MessageType.MSGTYPE_POLL_START,
MessageType.MSGTYPE_POLL_END,
MessageType.MSGTYPE_BEACON_INFO,
MessageType.MSGTYPE_LOCATION -> true
else -> false

View File

@ -499,6 +499,7 @@ class MessageActionsViewModel @AssistedInject constructor(
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE,
MessageType.MSGTYPE_POLL_START,
MessageType.MSGTYPE_POLL_END,
MessageType.MSGTYPE_STICKER_LOCAL -> event.root.threadDetails?.isRootThread ?: false
else -> false
}
@ -530,8 +531,8 @@ class MessageActionsViewModel @AssistedInject constructor(
}
private fun canViewReactions(event: TimelineEvent): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values) return false
// Only event of type EventType.MESSAGE, EventType.STICKER, EventType.POLL_START, EventType.POLL_END are supported for the moment
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values + EventType.POLL_END.values) return false
return event.annotations?.reactionsSummary?.isNotEmpty() ?: false
}

View File

@ -91,11 +91,13 @@ import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.events.model.isThread
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.getTimelineEvent
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody
import org.matrix.android.sdk.api.session.room.model.message.MessageEmoteContent
import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
@ -109,8 +111,10 @@ import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent
import org.matrix.android.sdk.api.session.room.timeline.getRelationContent
import org.matrix.android.sdk.api.settings.LightweightSettingsStorage
import org.matrix.android.sdk.api.util.MimeTypes
import timber.log.Timber
import javax.inject.Inject
class MessageItemFactory @Inject constructor(
@ -202,7 +206,8 @@ class MessageItemFactory @Inject constructor(
is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
is MessageAudioContent -> buildAudioContent(params, messageContent, informationData, highlight, attributes)
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes, isEnded = false)
is MessageEndPollContent -> buildEndedPollItem(event.getRelationContent()?.eventId, informationData, highlight, callback, attributes)
is MessageLocationContent -> buildLocationItem(messageContent, informationData, highlight, attributes)
is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(event, highlight, attributes)
is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, attributes)
@ -245,6 +250,7 @@ class MessageItemFactory @Inject constructor(
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
isEnded: Boolean,
): PollItem {
val pollViewState = pollItemViewStateFactory.create(pollContent, informationData)
@ -256,11 +262,35 @@ class MessageItemFactory @Inject constructor(
.votesStatus(pollViewState.votesStatus)
.optionViewStates(pollViewState.optionViewStates.orEmpty())
.edited(informationData.hasBeenEdited)
.ended(isEnded)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.callback(callback)
}
private fun buildEndedPollItem(
pollStartEventId: String?,
informationData: MessageInformationData,
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
): PollItem? {
pollStartEventId ?: return null.also {
Timber.e("### buildEndedPollItem. Cannot render poll end event because poll start event id is null")
}
val pollStartEvent = session.roomService().getRoom(roomId)?.getTimelineEvent(pollStartEventId)
val pollContent = pollStartEvent?.root?.getClearContent()?.toModel<MessagePollContent>() ?: return null
return buildPollItem(
pollContent,
informationData,
highlight,
callback,
attributes,
isEnded = true
)
}
private fun createPollQuestion(
informationData: MessageInformationData,
question: String,

View File

@ -102,6 +102,7 @@ class TimelineItemFactory @Inject constructor(
// Message itemsX
EventType.STICKER,
in EventType.POLL_START.values,
in EventType.POLL_END.values,
EventType.MESSAGE -> messageItemFactory.create(params)
EventType.REDACTION,
EventType.KEY_VERIFICATION_ACCEPT,
@ -114,8 +115,7 @@ class TimelineItemFactory @Inject constructor(
EventType.CALL_SELECT_ANSWER,
EventType.CALL_NEGOTIATE,
EventType.REACTION,
in EventType.POLL_RESPONSE.values,
in EventType.POLL_END.values -> noticeItemFactory.create(params)
in EventType.POLL_RESPONSE.values -> noticeItemFactory.create(params)
in EventType.BEACON_LOCATION_DATA.values -> {
if (event.root.isRedacted()) {
messageItemFactory.create(params)

View File

@ -17,11 +17,14 @@
package im.vector.app.features.home.room.detail.timeline.format
import android.content.Context
import im.vector.app.R
import im.vector.app.core.utils.TextUtils
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.isAudioMessage
import org.matrix.android.sdk.api.session.events.model.isFileMessage
import org.matrix.android.sdk.api.session.events.model.isImageMessage
import org.matrix.android.sdk.api.session.events.model.isPollEnd
import org.matrix.android.sdk.api.session.events.model.isPollStart
import org.matrix.android.sdk.api.session.events.model.isVideoMessage
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
@ -51,10 +54,16 @@ class EventDetailsFormatter @Inject constructor(
event.isVideoMessage() -> formatForVideoMessage(event)
event.isAudioMessage() -> formatForAudioMessage(event)
event.isFileMessage() -> formatForFileMessage(event)
event.isPollStart() -> formatPollMessage()
event.isPollEnd() -> formatPollEndMessage()
else -> null
}
}
private fun formatPollMessage() = context.getString(R.string.message_reply_to_poll_preview)
private fun formatPollEndMessage() = context.getString(R.string.message_reply_to_ended_poll_preview)
/**
* Example: "1024 x 720 - 670 kB".
*/

View File

@ -23,8 +23,6 @@ import im.vector.app.core.extensions.localDateTime
import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams
import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData
import im.vector.app.features.home.room.detail.timeline.item.ReferencesInfoData
import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayoutFactory
@ -55,7 +53,8 @@ class MessageInformationDataFactory @Inject constructor(
private val session: Session,
private val dateFormatter: VectorDateFormatter,
private val messageLayoutFactory: TimelineMessageLayoutFactory,
private val reactionsSummaryFactory: ReactionsSummaryFactory
private val reactionsSummaryFactory: ReactionsSummaryFactory,
private val pollResponseDataFactory: PollResponseDataFactory,
) {
fun create(params: TimelineItemFactoryParams): MessageInformationData {
@ -102,20 +101,7 @@ class MessageInformationDataFactory @Inject constructor(
memberName = event.senderInfo.disambiguatedDisplayName,
messageLayout = messageLayout,
reactionsSummary = reactionsSummaryFactory.create(event),
pollResponseAggregatedSummary = event.annotations?.pollResponseSummary?.let {
PollResponseData(
myVote = it.aggregatedContent?.myVote,
isClosed = it.closedTime != null,
votes = it.aggregatedContent?.votesSummary?.mapValues { votesSummary ->
PollVoteSummaryData(
total = votesSummary.value.total,
percentage = votesSummary.value.percentage
)
},
winnerVoteCount = it.aggregatedContent?.winnerVoteCount ?: 0,
totalVotes = it.aggregatedContent?.totalVotes ?: 0
)
},
pollResponseAggregatedSummary = pollResponseDataFactory.create(event),
hasBeenEdited = event.hasBeenEdited(),
hasPendingEdits = event.annotations?.editSummary?.localEchos?.any() ?: false,
referencesInfoData = event.annotations?.referencesAggregatedSummary?.let { referencesAggregatedSummary ->

View File

@ -0,0 +1,71 @@
/*
* Copyright (c) 2023 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 im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData
import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.events.model.isPollEnd
import org.matrix.android.sdk.api.session.room.getTimelineEvent
import org.matrix.android.sdk.api.session.room.model.PollResponseAggregatedSummary
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import timber.log.Timber
import javax.inject.Inject
class PollResponseDataFactory @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
) {
fun create(event: TimelineEvent): PollResponseData? {
val pollResponseSummary = getPollResponseSummary(event)
return pollResponseSummary?.let {
PollResponseData(
myVote = it.aggregatedContent?.myVote,
isClosed = it.closedTime != null,
votes = it.aggregatedContent?.votesSummary?.mapValues { votesSummary ->
PollVoteSummaryData(
total = votesSummary.value.total,
percentage = votesSummary.value.percentage
)
},
winnerVoteCount = it.aggregatedContent?.winnerVoteCount ?: 0,
totalVotes = it.aggregatedContent?.totalVotes ?: 0
)
}
}
private fun getPollResponseSummary(event: TimelineEvent): PollResponseAggregatedSummary? {
return if (event.root.isPollEnd()) {
val pollStartEventId = event.root.getRelationContent()?.eventId
if (pollStartEventId.isNullOrEmpty()) {
Timber.e("### Cannot render poll end event because poll start event id is null")
null
} else {
activeSessionHolder
.getSafeActiveSession()
?.roomService()
?.getRoom(event.roomId)
?.getTimelineEvent(pollStartEventId)
?.annotations
?.pollResponseSummary
}
} else {
event.annotations?.pollResponseSummary
}
}
}

View File

@ -55,6 +55,7 @@ object TimelineDisplayableEvents {
VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
) +
EventType.POLL_START.values +
EventType.POLL_END.values +
EventType.STATE_ROOM_BEACON_INFO.values +
EventType.BEACON_LOCATION_DATA.values
}

View File

@ -85,7 +85,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
if (useBigFont) {
holder.messageView.textSize = 44F
} else {
holder.messageView.textSize = 14F
holder.messageView.textSize = 15.5F
}
if (searchForPills) {
message?.charSequence?.findPillsAndProcess(coroutineScope) {

View File

@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.timeline.item
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.children
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
@ -50,6 +51,9 @@ abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
@EpoxyAttribute
lateinit var optionViewStates: List<PollOptionViewState>
@EpoxyAttribute
var ended: Boolean = false
override fun getViewStubId() = STUB_ID
override fun bind(holder: Holder) {
@ -75,6 +79,8 @@ abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
it.setOnClickListener { onPollItemClick(optionViewState) }
}
}
holder.endedPollTextView.isVisible = ended
}
private fun onPollItemClick(optionViewState: PollOptionViewState) {
@ -89,6 +95,7 @@ abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
val questionTextView by bind<TextView>(R.id.questionTextView)
val optionsContainer by bind<LinearLayout>(R.id.optionsContainer)
val votesStatusTextView by bind<TextView>(R.id.optionsVotesStatusTextView)
val endedPollTextView by bind<TextView>(R.id.endedPollTextView)
}
companion object {

View File

@ -25,6 +25,7 @@ import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.extensions.setAttributeTintedImageResource
import im.vector.app.databinding.ItemPollOptionBinding
import im.vector.app.features.themes.ThemeUtils
class PollOptionView @JvmOverloads constructor(
context: Context,
@ -53,35 +54,40 @@ class PollOptionView @JvmOverloads constructor(
private fun renderPollSending() {
views.optionCheckImageView.isVisible = false
views.optionWinnerImageView.isVisible = false
views.optionVoteCountTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
hideVotes()
renderVoteSelection(false)
}
private fun renderPollEnded(state: PollOptionViewState.PollEnded) {
views.optionCheckImageView.isVisible = false
views.optionWinnerImageView.isVisible = state.isWinner
val drawableStart = if (state.isWinner) R.drawable.ic_poll_winner else 0
views.optionVoteCountTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableStart, 0, 0, 0)
views.optionVoteCountTextView.setTextColor(
if (state.isWinner) ThemeUtils.getColor(context, R.attr.colorPrimary)
else ThemeUtils.getColor(context, R.attr.vctr_content_secondary)
)
showVotes(state.voteCount, state.votePercentage)
renderVoteSelection(state.isWinner)
}
private fun renderPollReady() {
views.optionCheckImageView.isVisible = true
views.optionWinnerImageView.isVisible = false
views.optionVoteCountTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
hideVotes()
renderVoteSelection(false)
}
private fun renderPollVoted(state: PollOptionViewState.PollVoted) {
views.optionCheckImageView.isVisible = true
views.optionWinnerImageView.isVisible = false
views.optionVoteCountTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
showVotes(state.voteCount, state.votePercentage)
renderVoteSelection(state.isSelected)
}
private fun renderPollUndisclosed(state: PollOptionViewState.PollUndisclosed) {
views.optionCheckImageView.isVisible = true
views.optionWinnerImageView.isVisible = false
views.optionVoteCountTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
hideVotes()
renderVoteSelection(state.isSelected)
}

View File

@ -25,6 +25,8 @@ import org.matrix.android.sdk.api.session.events.model.isFileMessage
import org.matrix.android.sdk.api.session.events.model.isImageMessage
import org.matrix.android.sdk.api.session.events.model.isLiveLocation
import org.matrix.android.sdk.api.session.events.model.isPoll
import org.matrix.android.sdk.api.session.events.model.isPollEnd
import org.matrix.android.sdk.api.session.events.model.isPollStart
import org.matrix.android.sdk.api.session.events.model.isSticker
import org.matrix.android.sdk.api.session.events.model.isVideoMessage
import org.matrix.android.sdk.api.session.events.model.isVoiceMessage
@ -93,10 +95,15 @@ class ProcessBodyOfReplyToEventUseCase @Inject constructor(
)
}
repliedToEvent.isPoll() -> {
val fallbackText = when {
repliedToEvent.isPollStart() -> stringProvider.getString(R.string.message_reply_to_sender_created_poll)
repliedToEvent.isPollEnd() -> stringProvider.getString(R.string.message_reply_to_sender_ended_poll)
else -> ""
}
matrixFormattedBody.replaceRange(
afterBreakingLineIndex,
endOfBlockQuoteIndex,
repliedToEvent.getPollQuestion() ?: stringProvider.getString(R.string.message_reply_to_sender_created_poll)
repliedToEvent.getPollQuestion() ?: fallbackText
)
}
repliedToEvent.isLiveLocation() -> {

View File

@ -50,6 +50,7 @@ class TimelineMessageLayoutFactory @Inject constructor(
EventType.STICKER,
) +
EventType.POLL_START.values +
EventType.POLL_END.values +
EventType.STATE_ROOM_BEACON_INFO.values
// Can't be rendered in bubbles, so get back to default layout

View File

@ -22,41 +22,33 @@ import com.airbnb.mvrx.Loading
import im.vector.app.R
import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.RoomListDisplayMode
import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
import im.vector.app.features.home.room.list.usecase.GetLatestPreviewableEventUseCase
import im.vector.app.features.home.room.typing.TypingHelper
import im.vector.app.features.voicebroadcast.isLive
import im.vector.app.features.voicebroadcast.isVoiceBroadcast
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.getTimelineEvent
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
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.SpaceChildInfo
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject
class RoomSummaryItemFactory @Inject constructor(
private val sessionHolder: ActiveSessionHolder,
private val displayableEventFormatter: DisplayableEventFormatter,
private val dateFormatter: VectorDateFormatter,
private val stringProvider: StringProvider,
private val typingHelper: TypingHelper,
private val avatarRenderer: AvatarRenderer,
private val errorFormatter: ErrorFormatter,
private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase,
private val getLatestPreviewableEventUseCase: GetLatestPreviewableEventUseCase,
) {
fun create(
@ -142,7 +134,7 @@ class RoomSummaryItemFactory @Inject constructor(
val showSelected = selectedRoomIds.contains(roomSummary.roomId)
var latestFormattedEvent: CharSequence = ""
var latestEventTime = ""
val latestEvent = roomSummary.getVectorLatestPreviewableEvent()
val latestEvent = getLatestPreviewableEventUseCase.execute(roomSummary.roomId)
if (latestEvent != null) {
latestFormattedEvent = displayableEventFormatter.format(latestEvent, roomSummary.isDirect, roomSummary.isDirect.not())
latestEventTime = dateFormatter.format(latestEvent.root.originServerTs, DateFormatKind.ROOM_LIST)
@ -150,7 +142,8 @@ class RoomSummaryItemFactory @Inject constructor(
val typingMessage = typingHelper.getTypingMessage(roomSummary.typingUsers)
// Skip typing while there is a live voice broadcast
.takeUnless { latestEvent?.root?.asVoiceBroadcastEvent()?.isLive.orFalse() }.orEmpty()
.takeUnless { latestEvent?.root?.asVoiceBroadcastEvent()?.isLive.orFalse() }
.orEmpty()
return if (subtitle.isBlank() && displayMode == RoomListDisplayMode.FILTERED) {
createCenteredRoomSummaryItem(roomSummary, displayMode, showSelected, unreadCount, onClick, onLongClick)
@ -240,14 +233,4 @@ class RoomSummaryItemFactory @Inject constructor(
else -> stringProvider.getQuantityString(R.plurals.search_space_multiple_parents, size - 1, directParentNames[0], size - 1)
}
}
private fun RoomSummary.getVectorLatestPreviewableEvent(): TimelineEvent? {
val room = sessionHolder.getSafeActiveSession()?.getRoom(roomId) ?: return latestPreviewableEvent
val liveVoiceBroadcastTimelineEvent = getRoomLiveVoiceBroadcastsUseCase.execute(roomId).lastOrNull()
?.root?.eventId?.let { room.getTimelineEvent(it) }
return latestPreviewableEvent?.takeIf { it.root.getClearType() == EventType.CALL_INVITE }
?: liveVoiceBroadcastTimelineEvent
?: latestPreviewableEvent
?.takeUnless { it.root.asMessageAudioEvent()?.isVoiceBroadcast().orFalse() } // Skip voice messages related to voice broadcast
}
}

View File

@ -0,0 +1,72 @@
/*
* Copyright (c) 2023 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.list.usecase
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.voicebroadcast.isLive
import im.vector.app.features.voicebroadcast.isVoiceBroadcast
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase
import im.vector.app.features.voicebroadcast.voiceBroadcastId
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.session.room.getTimelineEvent
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject
class GetLatestPreviewableEventUseCase @Inject constructor(
private val sessionHolder: ActiveSessionHolder,
private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase,
) {
fun execute(roomId: String): TimelineEvent? {
val room = sessionHolder.getSafeActiveSession()?.getRoom(roomId) ?: return null
val roomSummary = room.roomSummary() ?: return null
return getCallEvent(roomSummary)
?: getLiveVoiceBroadcastEvent(room)
?: getDefaultLatestEvent(room, roomSummary)
}
private fun getCallEvent(roomSummary: RoomSummary): TimelineEvent? {
return roomSummary.latestPreviewableEvent
?.takeIf { it.root.getClearType() == EventType.CALL_INVITE }
}
private fun getLiveVoiceBroadcastEvent(room: Room): TimelineEvent? {
return getRoomLiveVoiceBroadcastsUseCase.execute(room.roomId)
.lastOrNull()
?.voiceBroadcastId
?.let { room.getTimelineEvent(it) }
}
private fun getDefaultLatestEvent(room: Room, roomSummary: RoomSummary): TimelineEvent? {
val latestPreviewableEvent = roomSummary.latestPreviewableEvent
// If the default latest event is a live voice broadcast (paused or resumed), rely to the started event
val liveVoiceBroadcastEventId = latestPreviewableEvent?.root?.asVoiceBroadcastEvent()?.takeIf { it.isLive }?.voiceBroadcastId
if (liveVoiceBroadcastEventId != null) {
return room.getTimelineEvent(liveVoiceBroadcastEventId)
}
return latestPreviewableEvent
?.takeUnless { it.root.asMessageAudioEvent()?.isVoiceBroadcast().orFalse() } // Skip voice messages related to voice broadcast
}
}

View File

@ -685,7 +685,7 @@ class LoginViewModel @AssistedInject constructor(
currentJob = viewModelScope.launch {
try {
safeLoginWizard.login(
action.username,
action.username.trim(),
action.password,
action.initialDeviceName
)

View File

@ -19,14 +19,20 @@ package im.vector.app.features.voicebroadcast.usecase
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.isLive
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.voiceBroadcastId
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.getRoom
import javax.inject.Inject
/**
* Get the list of live (not ended) voice broadcast events in the given room.
*/
class GetRoomLiveVoiceBroadcastsUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
private val getVoiceBroadcastStateEventUseCase: GetVoiceBroadcastStateEventUseCase,
) {
fun execute(roomId: String): List<VoiceBroadcastEvent> {
@ -37,7 +43,8 @@ class GetRoomLiveVoiceBroadcastsUseCase @Inject constructor(
setOf(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO),
QueryStringValue.IsNotEmpty
)
.mapNotNull { it.asVoiceBroadcastEvent() }
.mapNotNull { stateEvent -> stateEvent.asVoiceBroadcastEvent()?.voiceBroadcastId }
.mapNotNull { voiceBroadcastId -> getVoiceBroadcastStateEventUseCase.execute(VoiceBroadcast(voiceBroadcastId, roomId)) }
.filter { it.isLive }
}
}

View File

@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transformWhile
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.util.Optional
@ -44,6 +43,7 @@ import javax.inject.Inject
class GetVoiceBroadcastStateEventLiveUseCase @Inject constructor(
private val session: Session,
private val getVoiceBroadcastStateEventUseCase: GetVoiceBroadcastStateEventUseCase,
) {
fun execute(voiceBroadcast: VoiceBroadcast): Flow<Optional<VoiceBroadcastEvent>> {
@ -93,7 +93,7 @@ class GetVoiceBroadcastStateEventLiveUseCase @Inject constructor(
* Get a flow of the most recent related event.
*/
private fun getMostRecentRelatedEventFlow(room: Room, voiceBroadcast: VoiceBroadcast): Flow<Optional<VoiceBroadcastEvent>> {
val mostRecentEvent = getMostRecentRelatedEvent(room, voiceBroadcast).toOptional()
val mostRecentEvent = getVoiceBroadcastStateEventUseCase.execute(voiceBroadcast).toOptional()
return if (mostRecentEvent.hasValue()) {
val stateKey = mostRecentEvent.get().root.stateKey.orEmpty()
// observe incoming voice broadcast state events
@ -141,15 +141,6 @@ class GetVoiceBroadcastStateEventLiveUseCase @Inject constructor(
}
}
/**
* Get the most recent event related to the given voice broadcast.
*/
private fun getMostRecentRelatedEvent(room: Room, voiceBroadcast: VoiceBroadcast): VoiceBroadcastEvent? {
return room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId)
.mapNotNull { timelineEvent -> timelineEvent.root.asVoiceBroadcastEvent()?.takeUnless { it.root.isRedacted() } }
.maxByOrNull { it.root.originServerTs ?: 0 }
}
/**
* Get a flow of the given voice broadcast event changes.
*/

View File

@ -0,0 +1,62 @@
/*
* Copyright (c) 2022 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.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.voiceBroadcastId
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.session.room.getTimelineEvent
import timber.log.Timber
import javax.inject.Inject
class GetVoiceBroadcastStateEventUseCase @Inject constructor(
private val session: Session,
) {
fun execute(voiceBroadcast: VoiceBroadcast): VoiceBroadcastEvent? {
val room = session.getRoom(voiceBroadcast.roomId) ?: error("Unknown roomId: ${voiceBroadcast.roomId}")
return getMostRecentRelatedEvent(room, voiceBroadcast)
.also { event ->
Timber.d(
"## VoiceBroadcast | " +
"voiceBroadcastId=${event?.voiceBroadcastId}, " +
"state=${event?.content?.voiceBroadcastState}"
)
}
}
/**
* Get the most recent event related to the given voice broadcast.
*/
private fun getMostRecentRelatedEvent(room: Room, voiceBroadcast: VoiceBroadcast): VoiceBroadcastEvent? {
val startedEvent = room.getTimelineEvent(voiceBroadcast.voiceBroadcastId)
return if (startedEvent?.root?.isRedacted().orTrue()) {
null
} else {
room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId)
.mapNotNull { timelineEvent -> timelineEvent.root.asVoiceBroadcastEvent() }
.filterNot { it.root.isRedacted() }
.maxByOrNull { it.root.originServerTs ?: 0 }
}
}
}

View File

@ -124,6 +124,8 @@
app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toBottomOf="@id/composerModeBarrier"
app:bulletRadius="4sp"
app:bulletGap="8sp"
tools:text="@tools:sample/lorem/random" />
<com.google.android.material.textfield.TextInputEditText

View File

@ -36,34 +36,23 @@
android:layout_marginStart="12dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="12dp"
app:layout_constraintEnd_toEndOf="@id/optionWinnerImageView"
app:layout_constraintEnd_toStartOf="@id/optionVoteCountTextView"
app:layout_constraintStart_toEndOf="@id/optionCheckImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="@sample/poll.json/data/answer" />
<ImageView
android:id="@+id/optionWinnerImageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/a11y_poll_winner_option"
android:src="@drawable/ic_poll_winner"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<TextView
android:id="@+id/optionVoteCountTextView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginEnd="10dp"
android:drawablePadding="6dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/optionVoteProgress"
app:layout_constraintBottom_toBottomOf="@id/optionNameTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/optionVoteProgress"
app:layout_constraintTop_toTopOf="@id/optionNameTextView"
tools:drawableStartCompat="@drawable/ic_poll_winner"
tools:text="@sample/poll.json/data/votes"
tools:visibility="visible" />
@ -78,9 +67,9 @@
android:layout_marginBottom="8dp"
android:progressDrawable="@drawable/poll_option_progressbar_checked"
app:layout_constraintBottom_toBottomOf="@id/optionBorderImageView"
app:layout_constraintEnd_toStartOf="@id/optionVoteCountTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/optionNameTextView"
tools:progress="60" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -2,9 +2,21 @@
<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:minWidth="@dimen/chat_bubble_fixed_size"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:minWidth="@dimen/chat_bubble_fixed_size">
<TextView
android:id="@+id/endedPollTextView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/ended_poll_indicator"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<TextView
android:id="@+id/questionTextView"
@ -13,11 +25,10 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toBottomOf="@id/endedPollTextView"
tools:text="@sample/poll.json/question" />
<LinearLayout

View File

@ -0,0 +1,108 @@
/*
* Copyright (c) 2022 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
import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.features.home.room.list.home.invites.InvitesAction
import im.vector.app.features.home.room.list.home.invites.InvitesViewEvents
import im.vector.app.features.home.room.list.home.invites.InvitesViewModel
import im.vector.app.features.home.room.list.home.invites.InvitesViewState
import im.vector.app.test.fakes.FakeDrawableProvider
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fixtures.RoomSummaryFixture
import im.vector.app.test.test
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.Membership
class InvitesViewModelTest {
@get:Rule
val mavericksTestRule = MavericksTestRule()
private val fakeSession = FakeSession()
private val fakeStringProvider = FakeStringProvider()
private val fakeDrawableProvider = FakeDrawableProvider()
private var initialState = InvitesViewState()
private lateinit var viewModel: InvitesViewModel
private val anInvite = RoomSummaryFixture.aRoomSummary("invite")
@Before
fun setUp() {
mockkStatic("org.matrix.android.sdk.flow.FlowSessionKt")
every {
fakeSession.fakeRoomService.getPagedRoomSummariesLive(
queryParams = match {
it.memberships == listOf(Membership.INVITE)
},
pagedListConfig = any(),
sortOrder = any()
)
} returns mockk()
viewModelWith(initialState)
}
@Test
fun `when invite accepted then membership map is updated and open event posted`() = runTest {
val test = viewModel.test()
viewModel.handle(InvitesAction.AcceptInvitation(anInvite))
test.assertEvents(
InvitesViewEvents.OpenRoom(
roomSummary = anInvite,
shouldCloseInviteView = false,
isInviteAlreadySelected = true
)
).finish()
}
@Test
fun `when invite rejected then membership map is updated and open event posted`() = runTest {
coEvery { fakeSession.roomService().leaveRoom(any(), any()) } returns Unit
viewModel.handle(InvitesAction.RejectInvitation(anInvite))
coVerify {
fakeSession.roomService().leaveRoom(anInvite.roomId)
}
}
private fun viewModelWith(state: InvitesViewState) {
InvitesViewModel(
state,
session = fakeSession,
stringProvider = fakeStringProvider.instance,
drawableProvider = fakeDrawableProvider.instance,
).also {
viewModel = it
initialState = state
}
}
}

View File

@ -0,0 +1,184 @@
/*
* Copyright (c) 2022 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
import android.widget.ImageView
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.R
import im.vector.app.core.platform.StateView
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.list.home.HomeRoomListAction
import im.vector.app.features.home.room.list.home.HomeRoomListViewModel
import im.vector.app.features.home.room.list.home.HomeRoomListViewState
import im.vector.app.features.home.room.list.home.header.HomeRoomFilter
import im.vector.app.test.fakes.FakeAnalyticsTracker
import im.vector.app.test.fakes.FakeDrawableProvider
import im.vector.app.test.fakes.FakeHomeLayoutPreferencesStore
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeSpaceStateHandler
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fixtures.RoomSummaryFixture.aRoomSummary
import im.vector.app.test.test
import io.mockk.every
import io.mockk.mockkStatic
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.query.SpaceFilter
import org.matrix.android.sdk.api.session.getUserOrDefault
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.flow.FlowSession
class RoomsListViewModelTest {
@get:Rule
val mavericksTestRule = MavericksTestRule()
@get:Rule
var rule = InstantTaskExecutorRule()
private val fakeSession = FakeSession()
private val fakeAnalyticsTracker = FakeAnalyticsTracker()
private val fakeStringProvider = FakeStringProvider()
private val fakeDrawableProvider = FakeDrawableProvider()
private val fakeSpaceStateHandler = FakeSpaceStateHandler()
private val fakeHomeLayoutPreferencesStore = FakeHomeLayoutPreferencesStore()
private var initialState = HomeRoomListViewState()
private lateinit var viewModel: HomeRoomListViewModel
private lateinit var fakeFLowSession: FlowSession
@Before
fun setUp() {
mockkStatic("org.matrix.android.sdk.flow.FlowSessionKt")
fakeFLowSession = fakeSession.givenFlowSession()
every { fakeSpaceStateHandler.getSelectedSpaceFlow() } returns flowOf(Optional.empty())
every { fakeSpaceStateHandler.getCurrentSpace() } returns null
every { fakeFLowSession.liveRoomSummaries(any(), any()) } returns flowOf(emptyList())
val roomA = aRoomSummary("room_a")
val roomB = aRoomSummary("room_b")
val roomC = aRoomSummary("room_c")
val allRooms = listOf(roomA, roomB, roomC)
every {
fakeFLowSession.liveRoomSummaries(
match {
it.roomCategoryFilter == null &&
it.roomTagQueryFilter == null &&
it.memberships == listOf(Membership.JOIN) &&
it.spaceFilter is SpaceFilter.NoFilter
}, any()
)
} returns flowOf(allRooms)
viewModelWith(initialState)
}
@Test
fun `when recents are enabled then updates state`() = runTest {
val fakeFLowSession = fakeSession.givenFlowSession()
every { fakeFLowSession.liveRoomSummaries(any()) } returns flowOf(emptyList())
val test = viewModel.test()
val roomA = aRoomSummary("room_a")
val roomB = aRoomSummary("room_b")
val roomC = aRoomSummary("room_c")
val recentRooms = listOf(roomA, roomB, roomC)
every { fakeFLowSession.liveBreadcrumbs(any()) } returns flowOf(recentRooms)
fakeHomeLayoutPreferencesStore.givenRecentsEnabled(true)
val userName = fakeSession.getUserOrDefault(fakeSession.myUserId).toMatrixItem().getBestName()
val allEmptyState = StateView.State.Empty(
title = fakeStringProvider.instance.getString(R.string.home_empty_no_rooms_title, userName),
message = fakeStringProvider.instance.getString(R.string.home_empty_no_rooms_message),
image = fakeDrawableProvider.instance.getDrawable(R.drawable.ill_empty_all_chats),
isBigImage = true
)
test.assertLatestState(
initialState.copy(emptyState = allEmptyState, headersData = initialState.headersData.copy(recents = recentRooms))
)
}
@Test
fun `when filter tabs are enabled then updates state`() = runTest {
val test = viewModel.test()
fakeHomeLayoutPreferencesStore.givenFiltersEnabled(true)
val filtersData = mutableListOf(
HomeRoomFilter.ALL,
HomeRoomFilter.UNREADS
)
val userName = fakeSession.getUserOrDefault(fakeSession.myUserId).toMatrixItem().getBestName()
val allEmptyState = StateView.State.Empty(
title = fakeStringProvider.instance.getString(R.string.home_empty_no_rooms_title, userName),
message = fakeStringProvider.instance.getString(R.string.home_empty_no_rooms_message),
image = fakeDrawableProvider.instance.getDrawable(R.drawable.ill_empty_all_chats),
isBigImage = true
)
test.assertLatestState(
initialState.copy(emptyState = allEmptyState, headersData = initialState.headersData.copy(filtersList = filtersData))
)
}
@Test
fun `when filter tab is selected then updates state`() = runTest {
val test = viewModel.test()
val aFilter = HomeRoomFilter.UNREADS
viewModel.handle(HomeRoomListAction.ChangeRoomFilter(filter = aFilter))
val unreadsEmptyState = StateView.State.Empty(
title = fakeStringProvider.instance.getString(R.string.home_empty_no_unreads_title),
message = fakeStringProvider.instance.getString(R.string.home_empty_no_unreads_message),
image = fakeDrawableProvider.instance.getDrawable(R.drawable.ill_empty_unreads),
isBigImage = true,
imageScaleType = ImageView.ScaleType.CENTER_INSIDE
)
test.assertLatestState(
initialState.copy(emptyState = unreadsEmptyState, headersData = initialState.headersData.copy(currentFilter = aFilter))
)
}
private fun viewModelWith(state: HomeRoomListViewState) {
HomeRoomListViewModel(
state,
session = fakeSession,
spaceStateHandler = fakeSpaceStateHandler,
preferencesStore = fakeHomeLayoutPreferencesStore.instance,
stringProvider = fakeStringProvider.instance,
drawableProvider = fakeDrawableProvider.instance,
analyticsTracker = fakeAnalyticsTracker
).also {
viewModel = it
initialState = state
}
}
}

View File

@ -43,7 +43,7 @@ class CheckIfCanReplyEventUseCaseTest {
@Test
fun `given reply is allowed for the event type when use case is executed then result is true`() {
val eventTypes = EventType.STATE_ROOM_BEACON_INFO.values + EventType.POLL_START.values + EventType.MESSAGE
val eventTypes = EventType.STATE_ROOM_BEACON_INFO.values + EventType.POLL_START.values + EventType.POLL_END.values + EventType.MESSAGE
eventTypes.forEach { eventType ->
val event = givenAnEvent(eventType)
@ -78,6 +78,7 @@ class CheckIfCanReplyEventUseCaseTest {
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE,
MessageType.MSGTYPE_POLL_START,
MessageType.MSGTYPE_POLL_END,
MessageType.MSGTYPE_BEACON_INFO,
MessageType.MSGTYPE_LOCATION
)

View File

@ -29,6 +29,7 @@ import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.getPollQuestion
import org.matrix.android.sdk.api.session.events.model.isAudioMessage
import org.matrix.android.sdk.api.session.events.model.isFileMessage
@ -158,6 +159,7 @@ class ProcessBodyOfReplyToEventUseCaseTest {
// Given
givenTypeOfRepliedEvent(isPollMessage = true)
givenNewContentForId(R.string.message_reply_to_sender_created_poll)
every { fakeRepliedEvent.getClearType() } returns EventType.POLL_START.unstable
every { fakeRepliedEvent.getPollQuestion() } returns null
executeAndAssertResult()
@ -168,11 +170,23 @@ class ProcessBodyOfReplyToEventUseCaseTest {
// Given
givenTypeOfRepliedEvent(isPollMessage = true)
givenNewContentForId(R.string.message_reply_to_sender_created_poll)
every { fakeRepliedEvent.getClearType() } returns EventType.POLL_START.unstable
every { fakeRepliedEvent.getPollQuestion() } returns A_NEW_CONTENT
executeAndAssertResult()
}
@Test
fun `given a replied event of type poll end message when process the formatted body then content is replaced by correct string`() {
// Given
givenTypeOfRepliedEvent(isPollMessage = true)
givenNewContentForId(R.string.message_reply_to_sender_ended_poll)
every { fakeRepliedEvent.getClearType() } returns EventType.POLL_END.unstable
every { fakeRepliedEvent.getPollQuestion() } returns null
executeAndAssertResult()
}
@Test
fun `given a replied event of type live location message when process the formatted body then content is replaced by correct string`() {
// Given

View File

@ -0,0 +1,196 @@
/*
* Copyright (c) 2023 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.list.usecase
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants.VOICE_BROADCAST_CHUNK_KEY
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeRoom
import io.mockk.every
import io.mockk.mockk
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeNull
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val A_ROOM_ID = "a-room-id"
internal class GetLatestPreviewableEventUseCaseTest {
private val fakeRoom = FakeRoom()
private val fakeSessionHolder = FakeActiveSessionHolder()
private val fakeRoomSummary = mockk<RoomSummary>()
private val fakeGetRoomLiveVoiceBroadcastsUseCase = mockk<GetRoomLiveVoiceBroadcastsUseCase>()
private val getLatestPreviewableEventUseCase = GetLatestPreviewableEventUseCase(
fakeSessionHolder.instance,
fakeGetRoomLiveVoiceBroadcastsUseCase,
)
@Before
fun setup() {
every { fakeSessionHolder.instance.getSafeActiveSession()?.getRoom(A_ROOM_ID) } returns fakeRoom
every { fakeRoom.roomSummary() } returns fakeRoomSummary
every { fakeRoom.roomId } returns A_ROOM_ID
every { fakeRoom.timelineService().getTimelineEvent(any()) } answers {
mockk(relaxed = true) {
every { eventId } returns firstArg()
}
}
}
@Test
fun `given the latest event is a call invite and there is a live broadcast, when execute, returns the call event`() {
// Given
val aLatestPreviewableEvent = mockk<TimelineEvent> {
every { root.type } returns EventType.MESSAGE
every { root.getClearType() } returns EventType.CALL_INVITE
}
every { fakeRoomSummary.latestPreviewableEvent } returns aLatestPreviewableEvent
every { fakeGetRoomLiveVoiceBroadcastsUseCase.execute(A_ROOM_ID) } returns listOf(
givenAVoiceBroadcastEvent("id1", VoiceBroadcastState.STARTED, "id1"),
givenAVoiceBroadcastEvent("id2", VoiceBroadcastState.RESUMED, "id1"),
).mapNotNull { it.asVoiceBroadcastEvent() }
// When
val result = getLatestPreviewableEventUseCase.execute(A_ROOM_ID)
// Then
result shouldBe aLatestPreviewableEvent
}
@Test
fun `given the latest event is not a call invite and there is a live broadcast, when execute, returns the latest broadcast event`() {
// Given
val aLatestPreviewableEvent = mockk<TimelineEvent> {
every { root.type } returns EventType.MESSAGE
every { root.getClearType() } returns EventType.MESSAGE
}
every { fakeRoomSummary.latestPreviewableEvent } returns aLatestPreviewableEvent
every { fakeGetRoomLiveVoiceBroadcastsUseCase.execute(A_ROOM_ID) } returns listOf(
givenAVoiceBroadcastEvent("id1", VoiceBroadcastState.STARTED, "vb_id1"),
givenAVoiceBroadcastEvent("id2", VoiceBroadcastState.RESUMED, "vb_id2"),
).mapNotNull { it.asVoiceBroadcastEvent() }
// When
val result = getLatestPreviewableEventUseCase.execute(A_ROOM_ID)
// Then
result?.eventId shouldBeEqualTo "vb_id2"
}
@Test
fun `given there is no live broadcast, when execute, returns the latest event`() {
// Given
val aLatestPreviewableEvent = mockk<TimelineEvent> {
every { root.type } returns EventType.MESSAGE
every { root.getClearType() } returns EventType.MESSAGE
}
every { fakeRoomSummary.latestPreviewableEvent } returns aLatestPreviewableEvent
every { fakeGetRoomLiveVoiceBroadcastsUseCase.execute(A_ROOM_ID) } returns emptyList()
// When
val result = getLatestPreviewableEventUseCase.execute(A_ROOM_ID)
// Then
result shouldBe aLatestPreviewableEvent
}
@Test
fun `given there is no live broadcast and the latest event is a vb message, when execute, returns null`() {
// Given
val aLatestPreviewableEvent = mockk<TimelineEvent> {
every { root.type } returns EventType.MESSAGE
every { root.getClearType() } returns EventType.MESSAGE
every { root.getClearContent() } returns mapOf(
MessageContent.MSG_TYPE_JSON_KEY to "m.audio",
VOICE_BROADCAST_CHUNK_KEY to "1",
"body" to "",
)
}
every { fakeRoomSummary.latestPreviewableEvent } returns aLatestPreviewableEvent
every { fakeGetRoomLiveVoiceBroadcastsUseCase.execute(A_ROOM_ID) } returns emptyList()
// When
val result = getLatestPreviewableEventUseCase.execute(A_ROOM_ID)
// Then
result.shouldBeNull()
}
@Test
fun `given the latest event is an ended vb, when execute, returns the stopped event`() {
// Given
val aLatestPreviewableEvent = mockk<TimelineEvent> {
every { eventId } returns "id1"
every { root } returns givenAVoiceBroadcastEvent("id1", VoiceBroadcastState.STOPPED, "vb_id1")
}
every { fakeRoomSummary.latestPreviewableEvent } returns aLatestPreviewableEvent
every { fakeGetRoomLiveVoiceBroadcastsUseCase.execute(A_ROOM_ID) } returns emptyList()
// When
val result = getLatestPreviewableEventUseCase.execute(A_ROOM_ID)
// Then
result?.eventId shouldBeEqualTo "id1"
}
@Test
fun `given the latest event is a resumed vb, when execute, returns the started event`() {
// Given
val aLatestPreviewableEvent = mockk<TimelineEvent> {
every { eventId } returns "id1"
every { root } returns givenAVoiceBroadcastEvent("id1", VoiceBroadcastState.RESUMED, "vb_id1")
}
every { fakeRoomSummary.latestPreviewableEvent } returns aLatestPreviewableEvent
every { fakeGetRoomLiveVoiceBroadcastsUseCase.execute(A_ROOM_ID) } returns emptyList()
// When
val result = getLatestPreviewableEventUseCase.execute(A_ROOM_ID)
// Then
result?.eventId shouldBeEqualTo "vb_id1"
}
private fun givenAVoiceBroadcastEvent(
eventId: String,
state: VoiceBroadcastState,
voiceBroadcastId: String,
): Event = mockk {
every { this@mockk.eventId } returns eventId
every { getClearType() } returns VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO
every { type } returns VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO
every { content } returns mapOf(
"state" to state.value,
"m.relates_to" to mapOf(
"rel_type" to RelationType.REFERENCE,
"event_id" to voiceBroadcastId
)
)
}
}

View File

@ -0,0 +1,125 @@
/*
* Copyright (c) 2023 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.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.test.fakes.FakeSession
import io.mockk.every
import io.mockk.mockk
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeNull
import org.amshove.kluent.shouldNotBeNull
import org.junit.Test
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val A_ROOM_ID = "A_ROOM_ID"
private const val A_VOICE_BROADCAST_ID = "A_VOICE_BROADCAST_ID"
internal class GetVoiceBroadcastStateEventUseCaseTest {
private val fakeSession = FakeSession()
private val getVoiceBroadcastStateEventUseCase = GetVoiceBroadcastStateEventUseCase(fakeSession)
@Test
fun `given there is no event related to the given vb, when execute, then return null`() {
// Given
val aVoiceBroadcast = VoiceBroadcast(A_VOICE_BROADCAST_ID, A_ROOM_ID)
every { fakeSession.getRoom(A_ROOM_ID)?.timelineService()?.getTimelineEvent(A_VOICE_BROADCAST_ID) } returns null
every { fakeSession.getRoom(A_ROOM_ID)?.timelineService()?.getTimelineEventsRelatedTo(any(), any()) } returns emptyList()
// When
val result = getVoiceBroadcastStateEventUseCase.execute(aVoiceBroadcast)
// Then
result.shouldBeNull()
}
@Test
fun `given there are several related events related to the given vb, when execute, then return the most recent one`() {
// Given
val aVoiceBroadcast = VoiceBroadcast(A_VOICE_BROADCAST_ID, A_ROOM_ID)
val aListOfTimelineEvents = listOf(
givenAVoiceBroadcastEvent(eventId = A_VOICE_BROADCAST_ID, state = VoiceBroadcastState.STARTED, isRedacted = false, timestamp = 1L),
givenAVoiceBroadcastEvent(eventId = "event_id_3", state = VoiceBroadcastState.STOPPED, isRedacted = false, timestamp = 3L),
givenAVoiceBroadcastEvent(eventId = "event_id_2", state = VoiceBroadcastState.PAUSED, isRedacted = false, timestamp = 2L),
)
every { fakeSession.getRoom(A_ROOM_ID)?.timelineService()?.getTimelineEventsRelatedTo(any(), any()) } returns aListOfTimelineEvents
// When
val result = getVoiceBroadcastStateEventUseCase.execute(aVoiceBroadcast)
// Then
result.shouldNotBeNull()
result.root.eventId shouldBeEqualTo "event_id_3"
}
@Test
fun `given there are several related events related to the given vb, when execute, then return the most recent one which is not redacted`() {
// Given
val aVoiceBroadcast = VoiceBroadcast(A_VOICE_BROADCAST_ID, A_ROOM_ID)
val aListOfTimelineEvents = listOf(
givenAVoiceBroadcastEvent(eventId = A_VOICE_BROADCAST_ID, state = VoiceBroadcastState.STARTED, isRedacted = false, timestamp = 1L),
givenAVoiceBroadcastEvent(eventId = "event_id_2", state = VoiceBroadcastState.STOPPED, isRedacted = true, timestamp = 2L),
)
every { fakeSession.getRoom(A_ROOM_ID)?.timelineService()?.getTimelineEventsRelatedTo(any(), any()) } returns aListOfTimelineEvents
// When
val result = getVoiceBroadcastStateEventUseCase.execute(aVoiceBroadcast)
// Then
result.shouldNotBeNull()
result.root.eventId shouldBeEqualTo A_VOICE_BROADCAST_ID
}
@Test
fun `given a not ended voice broadcast with a redacted start event, when execute, then return null`() {
// Given
val aVoiceBroadcast = VoiceBroadcast(A_VOICE_BROADCAST_ID, A_ROOM_ID)
val aListOfTimelineEvents = listOf(
givenAVoiceBroadcastEvent(eventId = A_VOICE_BROADCAST_ID, state = VoiceBroadcastState.STARTED, isRedacted = true, timestamp = 1L),
givenAVoiceBroadcastEvent(eventId = "event_id_2", state = VoiceBroadcastState.PAUSED, isRedacted = false, timestamp = 2L),
givenAVoiceBroadcastEvent(eventId = "event_id_3", state = VoiceBroadcastState.RESUMED, isRedacted = false, timestamp = 3L),
)
every { fakeSession.getRoom(A_ROOM_ID)?.timelineService()?.getTimelineEventsRelatedTo(any(), any()) } returns aListOfTimelineEvents
// When
val result = getVoiceBroadcastStateEventUseCase.execute(aVoiceBroadcast)
// Then
result.shouldBeNull()
}
private fun givenAVoiceBroadcastEvent(
eventId: String,
state: VoiceBroadcastState,
isRedacted: Boolean,
timestamp: Long,
): TimelineEvent {
val timelineEvent = mockk<TimelineEvent> {
every { root.eventId } returns eventId
every { root.type } returns VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO
every { root.content } returns mapOf("state" to state.value)
every { root.isRedacted() } returns isRedacted
every { root.originServerTs } returns timestamp
}
every { fakeSession.getRoom(A_ROOM_ID)?.timelineService()?.getTimelineEvent(eventId) } returns timelineEvent
return timelineEvent
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.test.fakes
import im.vector.app.core.resources.DrawableProvider
import io.mockk.every
import io.mockk.mockk
class FakeDrawableProvider {
val instance = mockk<DrawableProvider>()
init {
every { instance.getDrawable(any()) } returns mockk()
every { instance.getDrawable(any(), any()) } returns mockk()
}
}

View File

@ -0,0 +1,43 @@
/*
* Copyright (c) 2022 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.test.fakes
import im.vector.app.features.home.room.list.home.HomeLayoutPreferencesStore
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeHomeLayoutPreferencesStore {
private val _areRecentsEnabledFlow = MutableSharedFlow<Boolean>()
private val _areFiltersEnabledFlow = MutableSharedFlow<Boolean>()
private val _isAZOrderingEnabledFlow = MutableSharedFlow<Boolean>()
val instance = mockk<HomeLayoutPreferencesStore>(relaxed = true) {
every { areRecentsEnabledFlow } returns _areRecentsEnabledFlow
every { areFiltersEnabledFlow } returns _areFiltersEnabledFlow
every { isAZOrderingEnabledFlow } returns _isAZOrderingEnabledFlow
}
suspend fun givenRecentsEnabled(enabled: Boolean) {
_areRecentsEnabledFlow.emit(enabled)
}
suspend fun givenFiltersEnabled(enabled: Boolean) {
_areFiltersEnabledFlow.emit(enabled)
}
}

View File

@ -30,4 +30,8 @@ class FakeRoomService(
fun getRoomSummaryReturns(roomSummary: RoomSummary?) {
every { getRoomSummary(any()) } returns roomSummary
}
fun set(roomSummary: RoomSummary?) {
every { getRoomSummary(any()) } returns roomSummary
}
}

View File

@ -42,6 +42,7 @@ class FakeSession(
val fakeSharedSecretStorageService: FakeSharedSecretStorageService = FakeSharedSecretStorageService(),
val fakeRoomService: FakeRoomService = FakeRoomService(),
val fakePushersService: FakePushersService = FakePushersService(),
val fakeUserService: FakeUserService = FakeUserService(),
private val fakeEventService: FakeEventService = FakeEventService(),
val fakeSessionAccountDataService: FakeSessionAccountDataService = FakeSessionAccountDataService()
) : Session by mockk(relaxed = true) {
@ -62,6 +63,7 @@ class FakeSession(
override fun eventService() = fakeEventService
override fun pushersService() = fakePushersService
override fun accountDataService() = fakeSessionAccountDataService
override fun userService() = fakeUserService
fun givenVectorStore(vectorSessionStore: VectorSessionStore) {
coEvery {
@ -92,8 +94,10 @@ class FakeSession(
/**
* Do not forget to call mockkStatic("org.matrix.android.sdk.flow.FlowSessionKt") in the setup method of the tests.
*/
@SuppressWarnings("all")
fun givenFlowSession(): FlowSession {
val fakeFlowSession = mockk<FlowSession>()
every { flow() } returns fakeFlowSession
return fakeFlowSession
}

View File

@ -17,6 +17,7 @@
package im.vector.app.test.fakes
import im.vector.app.core.resources.StringProvider
import io.mockk.InternalPlatformDsl.toStr
import io.mockk.every
import io.mockk.mockk
@ -27,6 +28,9 @@ class FakeStringProvider {
every { instance.getString(any()) } answers {
"test-${args[0]}"
}
every { instance.getString(any(), any()) } answers {
"test-${args[0]}-${args[1].toStr()}"
}
every { instance.getQuantityString(any(), any(), any()) } answers {
"test-${args[0]}-${args[1]}"

View File

@ -0,0 +1,32 @@
/*
* Copyright (c) 2022 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.test.fakes
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import org.matrix.android.sdk.api.session.user.UserService
import org.matrix.android.sdk.api.session.user.model.User
class FakeUserService : UserService by mockk() {
private val userIdSlot = slot<String>()
init {
every { getUser(capture(userIdSlot)) } answers { User(userId = userIdSlot.captured) }
}
}