Merge branch 'develop' into hughns/msc3824-oidc-aware

This commit is contained in:
Hugh Nimmo-Smith 2023-01-18 17:05:36 +00:00
commit 1ac04b0070
61 changed files with 707 additions and 104 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)

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"
@ -48,7 +48,7 @@ plugins {
id "com.google.devtools.ksp" version "1.7.22-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.

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"

View File

@ -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,7 +27,7 @@ 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 sentry = "6.11.0"
def fragment = "1.5.5"
// 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
@ -60,7 +60,7 @@ ext.libs = [
'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 +86,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 +101,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

@ -3181,7 +3181,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>
@ -3195,6 +3196,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>
@ -3512,6 +3514,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

@ -62,7 +62,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()}\""

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

@ -36,6 +36,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'

View File

@ -132,7 +132,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

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

@ -498,6 +498,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
}
@ -529,8 +530,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
@ -54,7 +52,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 {
@ -99,20 +98,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

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

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,7 +67,7 @@
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" />

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