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

This commit is contained in:
Hugh Nimmo-Smith 2023-01-18 17:08:16 +00:00
commit 3333d86776
103 changed files with 2693 additions and 374 deletions

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

@ -45,7 +45,7 @@ plugins {
// Detekt // Detekt
id "io.gitlab.arturbosch.detekt" version "1.22.0" id "io.gitlab.arturbosch.detekt" version "1.22.0"
// Ksp // 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 // Dependency Analysis
id 'com.autonomousapps.dependency-analysis' version "1.18.0" id 'com.autonomousapps.dependency-analysis' version "1.18.0"

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

@ -0,0 +1 @@
Fix can't get out of a verification dialog

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

@ -0,0 +1 @@
[Poll] Warning message on decryption failure of some events

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

@ -0,0 +1 @@
Handle exceptions when listening a voice broadcast

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

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

1
changelog.d/7845.wip Normal file
View File

@ -0,0 +1 @@
[Voice Broadcast] Only display a notification on the first voice chunk

1
changelog.d/7864.wip Normal file
View File

@ -0,0 +1 @@
[Poll] History list: Load more UI mechanism

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

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

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

@ -0,0 +1 @@
Fix rendering of edited polls

View File

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

View File

@ -8,7 +8,7 @@ ext.versions = [
def gradle = "7.3.1" def gradle = "7.3.1"
// Ref: https://kotlinlang.org/releases.html // Ref: https://kotlinlang.org/releases.html
def kotlin = "1.7.22" def kotlin = "1.8.0"
def kotlinCoroutines = "1.6.4" def kotlinCoroutines = "1.6.4"
def dagger = "2.44.2" def dagger = "2.44.2"
def firebaseBom = "31.1.1" def firebaseBom = "31.1.1"
@ -18,7 +18,7 @@ def markwon = "4.6.2"
def moshi = "1.14.0" def moshi = "1.14.0"
def lifecycle = "2.5.1" def lifecycle = "2.5.1"
def flowBinding = "1.2.0" def flowBinding = "1.2.0"
def flipper = "0.176.1" def flipper = "0.177.0"
def epoxy = "5.0.0" def epoxy = "5.0.0"
def mavericks = "3.0.1" def mavericks = "3.0.1"
def glide = "4.14.2" def glide = "4.14.2"
@ -28,11 +28,12 @@ def jjwt = "0.11.5"
// the whole commit which set version 0.16.0-SNAPSHOT // the whole commit which set version 0.16.0-SNAPSHOT
def vanniktechEmoji = "0.16.0-SNAPSHOT" def vanniktechEmoji = "0.16.0-SNAPSHOT"
def sentry = "6.11.0" def sentry = "6.11.0"
def fragment = "1.5.5" // Use 1.6.0 alpha to fix issue with test
def fragment = "1.6.0-alpha04"
// Testing // 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 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 espresso = "3.5.1"
def androidxTest = "1.4.0" def androidxTest = "1.5.0"
def androidxOrchestrator = "1.4.2" def androidxOrchestrator = "1.4.2"
def paparazzi = "1.1.0" def paparazzi = "1.1.0"
@ -49,13 +50,14 @@ ext.libs = [
], ],
androidx : [ androidx : [
'activity' : "androidx.activity:activity-ktx:1.6.1", 'activity' : "androidx.activity:activity-ktx:1.6.1",
'appCompat' : "androidx.appcompat:appcompat:1.5.1", 'appCompat' : "androidx.appcompat:appcompat:1.6.0",
'biometric' : "androidx.biometric:biometric:1.1.0", 'biometric' : "androidx.biometric:biometric:1.1.0",
'core' : "androidx.core:core-ktx:1.9.0", 'core' : "androidx.core:core-ktx:1.9.0",
'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1", 'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1",
'exifinterface' : "androidx.exifinterface:exifinterface:1.3.5", 'exifinterface' : "androidx.exifinterface:exifinterface:1.3.5",
'fragmentKtx' : "androidx.fragment:fragment-ktx:$fragment", 'fragmentKtx' : "androidx.fragment:fragment-ktx:$fragment",
'fragmentTesting' : "androidx.fragment:fragment-testing:$fragment", 'fragmentTesting' : "androidx.fragment:fragment-testing:$fragment",
'fragmentTestingManifest' : "androidx.fragment:fragment-testing-manifest:$fragment",
'constraintLayout' : "androidx.constraintlayout:constraintlayout:2.1.4", 'constraintLayout' : "androidx.constraintlayout:constraintlayout:2.1.4",
'work' : "androidx.work:work-runtime-ktx:2.7.1", 'work' : "androidx.work:work-runtime-ktx:2.7.1",
'autoFill' : "androidx.autofill:autofill:1.1.0", 'autoFill' : "androidx.autofill:autofill:1.1.0",
@ -101,7 +103,7 @@ ext.libs = [
], ],
element : [ element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0", 'opusencoder' : "io.element.android:opusencoder:1.1.0",
'wysiwyg' : "io.element.android:wysiwyg:0.15.0" 'wysiwyg' : "io.element.android:wysiwyg:0.18.0"
], ],
squareup : [ squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi", 'moshi' : "com.squareup.moshi:moshi:$moshi",

View File

@ -2860,7 +2860,7 @@
<string name="device_manager_sessions_sign_in_with_qr_code_title">Přihlásit se pomocí QR kódu</string> <string name="device_manager_sessions_sign_in_with_qr_code_title">Přihlásit se pomocí QR kódu</string>
<string name="login_scan_qr_code">Naskenovat QR kód</string> <string name="login_scan_qr_code">Naskenovat QR kód</string>
<string name="labs_enable_voice_broadcast_summary">Možnost nahrávat a odesílat hlasové vysílání na časové ose místnosti.</string> <string name="labs_enable_voice_broadcast_summary">Možnost nahrávat a odesílat hlasové vysílání na časové ose místnosti.</string>
<string name="labs_enable_voice_broadcast_title">Povolit hlasové vysílání (v aktivním vývoji)</string> <string name="labs_enable_voice_broadcast_title">Povolit hlasové vysílání</string>
<string name="qr_code_login_header_failed_homeserver_is_not_supported_description">Domovský server nepodporuje přihlášení pomocí QR kódu.</string> <string name="qr_code_login_header_failed_homeserver_is_not_supported_description">Domovský server nepodporuje přihlášení pomocí QR kódu.</string>
<string name="qr_code_login_header_failed_user_cancelled_description">Přihlášení bylo na druhém zařízení zrušeno.</string> <string name="qr_code_login_header_failed_user_cancelled_description">Přihlášení bylo na druhém zařízení zrušeno.</string>
<string name="qr_code_login_header_failed_invalid_qr_code_description">Tento QR kód je neplatný.</string> <string name="qr_code_login_header_failed_invalid_qr_code_description">Tento QR kód je neplatný.</string>
@ -2946,4 +2946,13 @@
<string name="set_link_link">Odkaz</string> <string name="set_link_link">Odkaz</string>
<string name="set_link_text">Text</string> <string name="set_link_text">Text</string>
<string name="rich_text_editor_link">Nastavit odkaz</string> <string name="rich_text_editor_link">Nastavit odkaz</string>
<string name="settings_access_token_summary">Přístupový token umožňuje plný přístup k účtu. Nikomu ho nesdělujte.</string>
<string name="settings_access_token">Přístupový token</string>
<string name="rich_text_editor_bullet_list">Přepnout na odrážky</string>
<string name="rich_text_editor_numbered_list">Přepnout na číslovaný seznam</string>
<string name="room_polls_ended_no_item">V této místnosti nejsou žádné předchozí hlasování</string>
<string name="room_polls_ended">Předchozí hlasování</string>
<string name="room_polls_active_no_item">V této místnosti nejsou žádné aktivní hlasování</string>
<string name="room_polls_active">Aktivní hlasování</string>
<string name="room_profile_section_more_polls">Historie hlasování</string>
</resources> </resources>

View File

@ -1123,9 +1123,7 @@
<string name="settings_select_country">Elektu landon</string> <string name="settings_select_country">Elektu landon</string>
<string name="settings_emails_and_phone_numbers_summary">Administri retpoŝtadresojn kaj telefonnumerojn ligitajn al via konto de Matrix</string> <string name="settings_emails_and_phone_numbers_summary">Administri retpoŝtadresojn kaj telefonnumerojn ligitajn al via konto de Matrix</string>
<string name="settings_emails_and_phone_numbers_title">Retpoŝtadresoj kaj telefonnumeroj</string> <string name="settings_emails_and_phone_numbers_title">Retpoŝtadresoj kaj telefonnumeroj</string>
<string name="settings_unignore_user">Ĉu montri ĉiujn mesaĝojn de %s\? <string name="settings_unignore_user">Ĉu montri ĉiujn mesaĝojn de %s\?</string>
\n
\nSciu ke tiu ĉi ago reekigos la aplikaĵon, kaj tio povas daŭri iom da tempo.</string>
<string name="settings_password_updated">Via pasvorto ĝisdatiĝis</string> <string name="settings_password_updated">Via pasvorto ĝisdatiĝis</string>
<string name="settings_fail_to_update_password_invalid_current_password">La pasvorto ne validas</string> <string name="settings_fail_to_update_password_invalid_current_password">La pasvorto ne validas</string>
<string name="settings_fail_to_update_password">Malsukcesis ĝisdatigi pasvorton</string> <string name="settings_fail_to_update_password">Malsukcesis ĝisdatigi pasvorton</string>
@ -2201,4 +2199,5 @@
<string name="call_ringing">Sonorante…</string> <string name="call_ringing">Sonorante…</string>
<string name="spaces">Aroj</string> <string name="spaces">Aroj</string>
<string name="initial_sync_request_reason_unignored_users">- Iom uzantoj reatentita</string> <string name="initial_sync_request_reason_unignored_users">- Iom uzantoj reatentita</string>
<string name="settings_mentions_at_room">\@room</string>
</resources> </resources>

View File

@ -2890,4 +2890,13 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze
<string name="set_link_link">Hivatkozás</string> <string name="set_link_link">Hivatkozás</string>
<string name="set_link_text">Szöveg</string> <string name="set_link_text">Szöveg</string>
<string name="rich_text_editor_link">Hivatkozás beállítása</string> <string name="rich_text_editor_link">Hivatkozás beállítása</string>
<string name="settings_access_token_summary">A hozzáférési kulcs teljes elérést biztosít a fiókhoz. Soha ne ossza meg mással.</string>
<string name="settings_access_token">Elérési kulcs</string>
<string name="rich_text_editor_bullet_list">Lista ki-,bekapcsolása</string>
<string name="rich_text_editor_numbered_list">Számozott lista ki-,bekapcsolása</string>
<string name="room_polls_ended_no_item">Nincsenek régi szavazások ebben a szobában</string>
<string name="room_polls_ended">Régi szavazások</string>
<string name="room_polls_active_no_item">Nincsenek aktív szavazások ebben a szobában</string>
<string name="room_polls_active">Aktív szavazások</string>
<string name="room_profile_section_more_polls">Szavazás alakulása</string>
</resources> </resources>

View File

@ -794,7 +794,7 @@
<string name="thread_list_modal_my_threads_subtitle">Shows all threads youve participated in</string> <string name="thread_list_modal_my_threads_subtitle">Shows all threads youve participated in</string>
<string name="thread_list_empty_title">Keep discussions organized with threads</string> <string name="thread_list_empty_title">Keep discussions organized with threads</string>
<string name="thread_list_empty_subtitle">Threads help keep your conversations on-topic and easy to track.</string> <string name="thread_list_empty_subtitle">Threads help keep your conversations on-topic and easy to track.</string>
<string name="thread_list_not_available">You\'re homeserver does not support listing threads yet.</string> <string name="thread_list_not_available">Your homeserver does not support listing threads yet.</string>
<!-- Parameter %s will be replaced by the value of string reply_in_thread --> <!-- Parameter %s will be replaced by the value of string reply_in_thread -->
<string name="thread_list_empty_notice">Tip: Long tap a message and use “%s”.</string> <string name="thread_list_empty_notice">Tip: Long tap a message and use “%s”.</string>
<string name="search_thread_from_a_thread">From a Thread</string> <string name="search_thread_from_a_thread">From a Thread</string>
@ -2298,6 +2298,7 @@
<string name="sent_verification_conclusion">Verification Conclusion</string> <string name="sent_verification_conclusion">Verification Conclusion</string>
<string name="sent_location">Shared their location</string> <string name="sent_location">Shared their location</string>
<string name="sent_live_location">Shared their live location</string> <string name="sent_live_location">Shared their live location</string>
<string name="started_a_voice_broadcast">Started a voice broadcast</string>
<string name="verification_request_waiting">Waiting…</string> <string name="verification_request_waiting">Waiting…</string>
<string name="verification_request_other_cancelled">%s canceled</string> <string name="verification_request_other_cancelled">%s canceled</string>
@ -3124,6 +3125,7 @@
<string name="error_voice_broadcast_permission_denied_message">You dont have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.</string> <string name="error_voice_broadcast_permission_denied_message">You dont have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.</string>
<string name="error_voice_broadcast_blocked_by_someone_else_message">Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.</string> <string name="error_voice_broadcast_blocked_by_someone_else_message">Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.</string>
<string name="error_voice_broadcast_already_in_progress_message">You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.</string> <string name="error_voice_broadcast_already_in_progress_message">You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.</string>
<string name="error_voice_broadcast_unable_to_play">Unable to play this voice broadcast.</string>
<!-- Examples of usage: 6h 15min 30sec left / 15min 30sec left / 30sec left --> <!-- Examples of usage: 6h 15min 30sec left / 15min 30sec left / 30sec left -->
<string name="voice_broadcast_recording_time_left">%1$s left</string> <string name="voice_broadcast_recording_time_left">%1$s left</string>
<string name="stop_voice_broadcast_dialog_title">Stop live broadcasting?</string> <string name="stop_voice_broadcast_dialog_title">Stop live broadcasting?</string>
@ -3197,10 +3199,22 @@
<string name="closed_poll_option_title">Closed poll</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="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="ended_poll_indicator">Ended the poll.</string>
<string name="unable_to_decrypt_some_events_in_poll">Due to decryption errors, some votes may not be counted</string>
<string name="room_polls_active">Active polls</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_active_no_item">There are no active polls in this room</string>
<plurals name="room_polls_active_no_item_for_loaded_period">
<item quantity="one">"There are no active polls for the past day.\nLoad more polls to view polls for previous days."</item>
<item quantity="other">"There are no active polls for the past %1$d days.\nLoad more polls to view polls for previous days."</item>
</plurals>
<string name="room_polls_ended">Past polls</string> <string name="room_polls_ended">Past polls</string>
<string name="room_polls_ended_no_item">There are no past polls in this room</string> <string name="room_polls_ended_no_item">There are no past polls in this room</string>
<plurals name="room_polls_ended_no_item_for_loaded_period">
<item quantity="one">"There are no past polls for the past day.\nLoad more polls to view polls for previous days."</item>
<item quantity="other">"There are no past polls for the past %1$d days.\nLoad more polls to view polls for previous days."</item>
</plurals>
<string name="room_polls_wait_for_display">Displaying polls</string>
<string name="room_polls_load_more">Load more polls</string>
<string name="room_polls_loading_error">Error fetching polls.</string>
<!-- Location --> <!-- Location -->
<string name="location_activity_title_static_sharing">Share location</string> <string name="location_activity_title_static_sharing">Share location</string>

View File

@ -81,7 +81,7 @@ android {
buildTypes { buildTypes {
debug { debug {
if (project.hasProperty("coverage")) { if (project.hasProperty("coverage")) {
testCoverageEnabled = coverage.enableTestCoverage testCoverageEnabled = coverage == "true"
} }
// Set to true to log privacy or sensible data, such as token // Set to true to log privacy or sensible data, such as token
buildConfigField "boolean", "LOG_PRIVATE_DATA", project.property("vector.debugPrivateData") buildConfigField "boolean", "LOG_PRIVATE_DATA", project.property("vector.debugPrivateData")

View File

@ -23,5 +23,7 @@ data class PollResponseAggregatedSummary(
val nbOptions: Int = 0, val nbOptions: Int = 0,
// The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk)
val sourceEvents: List<String>, val sourceEvents: List<String>,
val localEchos: List<String> val localEchos: List<String>,
// list of related event ids which are encrypted due to decryption failure
val encryptedRelatedEventIds: List<String>,
) )

View File

@ -148,8 +148,8 @@ fun TimelineEvent.getLastMessageContent(): MessageContent? {
// Polls/Beacon are not message contents like others as there is no msgtype subtype to discriminate moshi parsing // Polls/Beacon are not message contents like others as there is no msgtype subtype to discriminate moshi parsing
// so toModel<MessageContent> won't parse them correctly // 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? // 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_START.values -> (getLastPollEditNewContent() ?: root.getClearContent()).toModel<MessagePollContent>()
in EventType.POLL_END.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageEndPollContent>() in EventType.POLL_END.values -> (getLastPollEditNewContent() ?: root.getClearContent()).toModel<MessageEndPollContent>()
in EventType.STATE_ROOM_BEACON_INFO.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconInfoContent>() in EventType.STATE_ROOM_BEACON_INFO.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconInfoContent>()
in EventType.BEACON_LOCATION_DATA.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconLocationDataContent>() in EventType.BEACON_LOCATION_DATA.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconLocationDataContent>()
else -> (getLastEditNewContent() ?: root.getClearContent()).toModel() else -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
@ -160,6 +160,10 @@ fun TimelineEvent.getLastEditNewContent(): Content? {
return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel<MessageContent>()?.newContent return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel<MessageContent>()?.newContent
} }
private fun TimelineEvent.getLastPollEditNewContent(): Content? {
return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel<MessagePollContent>()?.newContent
}
/** /**
* Returns true if it's a reply. * Returns true if it's a reply.
*/ */

View File

@ -59,7 +59,7 @@ internal class EventDecryptor @Inject constructor(
private val sendToDeviceTask: SendToDeviceTask, private val sendToDeviceTask: SendToDeviceTask,
private val deviceListManager: DeviceListManager, private val deviceListManager: DeviceListManager,
private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction, private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction,
private val cryptoStore: IMXCryptoStore private val cryptoStore: IMXCryptoStore,
) { ) {
/** /**

View File

@ -17,11 +17,16 @@
package org.matrix.android.sdk.internal.database package org.matrix.android.sdk.internal.database
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import io.realm.RealmConfiguration import io.realm.RealmConfiguration
import io.realm.RealmResults import io.realm.RealmResults
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
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.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertEntity import org.matrix.android.sdk.internal.database.model.EventInsertEntity
@ -34,7 +39,7 @@ import javax.inject.Inject
internal class EventInsertLiveObserver @Inject constructor( internal class EventInsertLiveObserver @Inject constructor(
@SessionDatabase realmConfiguration: RealmConfiguration, @SessionDatabase realmConfiguration: RealmConfiguration,
private val processors: Set<@JvmSuppressWildcards EventInsertLiveProcessor> private val processors: Set<@JvmSuppressWildcards EventInsertLiveProcessor>,
) : ) :
RealmLiveEntityObserver<EventInsertEntity>(realmConfiguration) { RealmLiveEntityObserver<EventInsertEntity>(realmConfiguration) {
@ -50,11 +55,11 @@ internal class EventInsertLiveObserver @Inject constructor(
if (!results.isLoaded || results.isEmpty()) { if (!results.isLoaded || results.isEmpty()) {
return@withLock return@withLock
} }
val idsToDeleteAfterProcess = ArrayList<String>() val eventsToProcess = ArrayList<EventInsertEntity>(results.size)
val filteredEvents = ArrayList<EventInsertEntity>(results.size) val eventsToIgnore = ArrayList<EventInsertEntity>(results.size)
Timber.v("EventInsertEntity updated with ${results.size} results in db") Timber.v("EventInsertEntity updated with ${results.size} results in db")
results.forEach { results.forEach {
if (shouldProcess(it)) {
// don't use copy from realm over there // don't use copy from realm over there
val copiedEvent = EventInsertEntity( val copiedEvent = EventInsertEntity(
eventId = it.eventId, eventId = it.eventId,
@ -62,36 +67,78 @@ internal class EventInsertLiveObserver @Inject constructor(
).apply { ).apply {
insertType = it.insertType insertType = it.insertType
} }
filteredEvents.add(copiedEvent)
if (shouldProcess(it)) {
eventsToProcess.add(copiedEvent)
} else {
eventsToIgnore.add(copiedEvent)
} }
idsToDeleteAfterProcess.add(it.eventId)
} }
awaitTransaction(realmConfiguration) { realm -> awaitTransaction(realmConfiguration) { realm ->
Timber.v("##Transaction: There are ${filteredEvents.size} events to process ") Timber.v("##Transaction: There are ${eventsToProcess.size} events to process")
filteredEvents.forEach { eventInsert ->
val idsToDeleteAfterProcess = ArrayList<String>()
val idsOfEncryptedEvents = ArrayList<String>()
val getAndTriageEvent: (EventInsertEntity) -> Event? = { eventInsert ->
val eventId = eventInsert.eventId val eventId = eventInsert.eventId
val event = EventEntity.where(realm, eventId).findFirst() val event = getEvent(realm, eventId)
if (event == null) { if (event?.getClearType() == EventType.ENCRYPTED) {
Timber.v("Event $eventId not found") idsOfEncryptedEvents.add(eventId)
} else {
idsToDeleteAfterProcess.add(eventId)
}
event
}
eventsToProcess.forEach { eventInsert ->
val eventId = eventInsert.eventId
val event = getAndTriageEvent(eventInsert)
if (event != null && canProcessEvent(event)) {
processors.filter {
it.shouldProcess(eventId, event.getClearType(), eventInsert.insertType)
}.forEach {
it.process(realm, event)
}
} else {
Timber.v("Cannot process event with id $eventId")
return@forEach return@forEach
} }
val domainEvent = event.asDomain()
processors.filter {
it.shouldProcess(eventId, domainEvent.getClearType(), eventInsert.insertType)
}.forEach {
it.process(realm, domainEvent)
}
} }
eventsToIgnore.forEach { getAndTriageEvent(it) }
realm.where(EventInsertEntity::class.java) realm.where(EventInsertEntity::class.java)
.`in`(EventInsertEntityFields.EVENT_ID, idsToDeleteAfterProcess.toTypedArray()) .`in`(EventInsertEntityFields.EVENT_ID, idsToDeleteAfterProcess.toTypedArray())
.findAll() .findAll()
.deleteAllFromRealm() .deleteAllFromRealm()
// make the encrypted events not processable: they will be processed again after decryption
realm.where(EventInsertEntity::class.java)
.`in`(EventInsertEntityFields.EVENT_ID, idsOfEncryptedEvents.toTypedArray())
.findAll()
.forEach { it.canBeProcessed = false }
} }
processors.forEach { it.onPostProcess() } processors.forEach { it.onPostProcess() }
} }
} }
} }
private fun getEvent(realm: Realm, eventId: String): Event? {
val event = EventEntity.where(realm, eventId).findFirst()
if (event == null) {
Timber.v("Event $eventId not found")
}
return event?.asDomain()
}
private fun canProcessEvent(event: Event): Boolean {
// event should be either not encrypted or if encrypted it should contain relatesTo content
return event.getClearType() != EventType.ENCRYPTED ||
event.content.toModel<EncryptedEventContent>()?.relatesTo != null
}
private fun shouldProcess(eventInsertEntity: EventInsertEntity): Boolean { private fun shouldProcess(eventInsertEntity: EventInsertEntity): Boolean {
return processors.any { return processors.any {
it.shouldProcess(eventInsertEntity.eventId, eventInsertEntity.eventType, eventInsertEntity.insertType) it.shouldProcess(eventInsertEntity.eventId, eventInsertEntity.eventType, eventInsertEntity.insertType)

View File

@ -30,7 +30,8 @@ internal object PollResponseAggregatedSummaryEntityMapper {
closedTime = entity.closedTime, closedTime = entity.closedTime,
localEchos = entity.sourceLocalEchoEvents.toList(), localEchos = entity.sourceLocalEchoEvents.toList(),
sourceEvents = entity.sourceEvents.toList(), sourceEvents = entity.sourceEvents.toList(),
nbOptions = entity.nbOptions nbOptions = entity.nbOptions,
encryptedRelatedEventIds = entity.encryptedRelatedEventIds.toList(),
) )
} }
@ -40,7 +41,8 @@ internal object PollResponseAggregatedSummaryEntityMapper {
nbOptions = model.nbOptions, nbOptions = model.nbOptions,
closedTime = model.closedTime, closedTime = model.closedTime,
sourceEvents = RealmList<String>().apply { addAll(model.sourceEvents) }, sourceEvents = RealmList<String>().apply { addAll(model.sourceEvents) },
sourceLocalEchoEvents = RealmList<String>().apply { addAll(model.localEchos) } sourceLocalEchoEvents = RealmList<String>().apply { addAll(model.localEchos) },
encryptedRelatedEventIds = RealmList<String>().apply { addAll(model.encryptedRelatedEventIds) },
) )
} }
} }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C. * Copyright (c) 2023 The Matrix.org Foundation C.I.C.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -17,15 +17,16 @@
package org.matrix.android.sdk.internal.database.migration package org.matrix.android.sdk.internal.database.migration
import io.realm.DynamicRealm import io.realm.DynamicRealm
import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntityFields
import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities
import org.matrix.android.sdk.internal.util.database.RealmMigrator import org.matrix.android.sdk.internal.util.database.RealmMigrator
/**
* Adding a new field in poll summary to keep track of non decrypted related events.
*/
internal class MigrateSessionTo048(realm: DynamicRealm) : RealmMigrator(realm, 48) { internal class MigrateSessionTo048(realm: DynamicRealm) : RealmMigrator(realm, 48) {
override fun doMigrate(realm: DynamicRealm) { override fun doMigrate(realm: DynamicRealm) {
realm.schema.get("HomeServerCapabilitiesEntity") realm.schema.get("PollResponseAggregatedSummaryEntity")
?.addField(HomeServerCapabilitiesEntityFields.EXTERNAL_ACCOUNT_MANAGEMENT_URL, String::class.java) ?.addRealmListField(PollResponseAggregatedSummaryEntityFields.ENCRYPTED_RELATED_EVENT_IDS.`$`, String::class.java)
?.forceRefreshOfHomeServerCapabilities()
} }
} }

View File

@ -27,7 +27,7 @@ internal open class EventInsertEntity(
var eventType: String = "", var eventType: String = "",
/** /**
* This flag will be used to filter EventInsertEntity in EventInsertLiveObserver. * This flag will be used to filter EventInsertEntity in EventInsertLiveObserver.
* Currently it's set to false when the event content is encrypted. * Currently it's set to false after an event with encrypted content has been processed.
*/ */
var canBeProcessed: Boolean = true var canBeProcessed: Boolean = true
) : RealmObject() { ) : RealmObject() {

View File

@ -33,7 +33,9 @@ internal open class PollResponseAggregatedSummaryEntity(
// The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk)
var sourceEvents: RealmList<String> = RealmList(), var sourceEvents: RealmList<String> = RealmList(),
var sourceLocalEchoEvents: RealmList<String> = RealmList() var sourceLocalEchoEvents: RealmList<String> = RealmList(),
// list of related event ids which are encrypted due to decryption failure
var encryptedRelatedEventIds: RealmList<String> = RealmList(),
) : RealmObject() { ) : RealmObject() {
companion object companion object

View File

@ -72,7 +72,7 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit
SpaceParentSummaryEntity::class, SpaceParentSummaryEntity::class,
UserPresenceEntity::class, UserPresenceEntity::class,
ThreadSummaryEntity::class, ThreadSummaryEntity::class,
ThreadListPageEntity::class ThreadListPageEntity::class,
] ]
) )
internal class SessionRealmModule internal class SessionRealmModule

View File

@ -20,7 +20,6 @@ import io.realm.Realm
import io.realm.RealmList import io.realm.RealmList
import io.realm.RealmQuery import io.realm.RealmQuery
import io.realm.kotlin.where import io.realm.kotlin.where
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventEntityFields import org.matrix.android.sdk.internal.database.model.EventEntityFields
import org.matrix.android.sdk.internal.database.model.EventInsertEntity import org.matrix.android.sdk.internal.database.model.EventInsertEntity
@ -32,10 +31,9 @@ internal fun EventEntity.copyToRealmOrIgnore(realm: Realm, insertType: EventInse
.equalTo(EventEntityFields.ROOM_ID, roomId) .equalTo(EventEntityFields.ROOM_ID, roomId)
.findFirst() .findFirst()
return if (eventEntity == null) { return if (eventEntity == null) {
val canBeProcessed = type != EventType.ENCRYPTED || decryptionResultJson != null val insertEntity = EventInsertEntity(eventId = eventId, eventType = type, canBeProcessed = true)
val insertEntity = EventInsertEntity(eventId = eventId, eventType = type, canBeProcessed = canBeProcessed).apply { insertEntity.insertType = insertType
this.insertType = insertType
}
realm.insert(insertEntity) realm.insert(insertEntity)
// copy this event entity and return it // copy this event entity and return it
realm.copyToRealm(this) realm.copyToRealm(this)

View File

@ -16,13 +16,16 @@
package org.matrix.android.sdk.internal.session.room package org.matrix.android.sdk.internal.session.room
import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.events.model.Event 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.LocalEcho import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -101,7 +104,7 @@ internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCrypto
if (originalDecrypted.type != replaceDecrypted.type) { if (originalDecrypted.type != replaceDecrypted.type) {
return EditValidity.Invalid("replacement and original events must have the same type") return EditValidity.Invalid("replacement and original events must have the same type")
} }
if (replaceDecrypted.clearContent.toModel<MessageContent>()?.newContent == null) { if (!hasNewContent(replaceDecrypted.type, replaceDecrypted.clearContent)) {
return EditValidity.Invalid("replacement event must have an m.new_content property") return EditValidity.Invalid("replacement event must have an m.new_content property")
} }
} else { } else {
@ -116,11 +119,18 @@ internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCrypto
if (originalEvent.type != replaceEvent.type) { if (originalEvent.type != replaceEvent.type) {
return EditValidity.Invalid("replacement and original events must have the same type") return EditValidity.Invalid("replacement and original events must have the same type")
} }
if (replaceEvent.content.toModel<MessageContent>()?.newContent == null) { if (!hasNewContent(replaceEvent.type, replaceEvent.content)) {
return EditValidity.Invalid("replacement event must have an m.new_content property") return EditValidity.Invalid("replacement event must have an m.new_content property")
} }
} }
return EditValidity.Valid return EditValidity.Valid
} }
private fun hasNewContent(eventType: String?, content: Content?): Boolean {
return when (eventType) {
in EventType.POLL_START.values -> content.toModel<MessagePollContent>()?.newContent != null
else -> content.toModel<MessageContent>()?.newContent != null
}
}
} }

View File

@ -61,6 +61,7 @@ import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor
import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor
import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollAggregationProcessor import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollAggregationProcessor
import org.matrix.android.sdk.internal.session.room.aggregation.utd.EncryptedReferenceAggregationProcessor
import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource
import org.matrix.android.sdk.internal.util.time.Clock import org.matrix.android.sdk.internal.util.time.Clock
import timber.log.Timber import timber.log.Timber
@ -73,6 +74,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val liveLocationAggregationProcessor: LiveLocationAggregationProcessor, private val liveLocationAggregationProcessor: LiveLocationAggregationProcessor,
private val pollAggregationProcessor: PollAggregationProcessor, private val pollAggregationProcessor: PollAggregationProcessor,
private val encryptedReferenceAggregationProcessor: EncryptedReferenceAggregationProcessor,
private val editValidator: EventEditValidator, private val editValidator: EventEditValidator,
private val clock: Clock, private val clock: Clock,
) : EventInsertLiveProcessor { ) : EventInsertLiveProcessor {
@ -140,6 +142,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}") Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}")
handleReaction(realm, event, roomId, isLocalEcho) handleReaction(realm, event, roomId, isLocalEcho)
} }
EventType.ENCRYPTED -> {
val encryptedEventContent = event.content.toModel<EncryptedEventContent>()
processEncryptedContent(
encryptedEventContent = encryptedEventContent,
realm = realm,
event = event,
roomId = roomId,
isLocalEcho = isLocalEcho,
)
}
EventType.MESSAGE -> { EventType.MESSAGE -> {
if (event.unsignedData?.relations?.annotations != null) { if (event.unsignedData?.relations?.annotations != null) {
Timber.v("###REACTION Aggregation in room $roomId for event ${event.eventId}") Timber.v("###REACTION Aggregation in room $roomId for event ${event.eventId}")
@ -170,32 +182,6 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
} }
} }
} }
// As for now Live event processors are not receiving UTD events.
// They will get an update if the event is decrypted later
EventType.ENCRYPTED -> {
// Relation type is in clear, it might be possible to do some things?
// Notice that if the event is decrypted later, process be called again
val encryptedEventContent = event.content.toModel<EncryptedEventContent>()
when (encryptedEventContent?.relatesTo?.type) {
RelationType.REPLACE -> {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
// A replace!
handleReplace(realm, event, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId)
}
RelationType.RESPONSE -> {
// can we / should we do we something for UTD response??
Timber.w("## UTD response in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
RelationType.REFERENCE -> {
// can we / should we do we something for UTD reference??
Timber.w("## UTD reference in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
RelationType.ANNOTATION -> {
// can we / should we do we something for UTD annotation??
Timber.w("## UTD annotation in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
}
}
EventType.REDACTION -> { EventType.REDACTION -> {
val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() }
?: return ?: return
@ -250,6 +236,36 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
} }
} }
private fun processEncryptedContent(
encryptedEventContent: EncryptedEventContent?,
realm: Realm,
event: Event,
roomId: String,
isLocalEcho: Boolean,
) {
when (encryptedEventContent?.relatesTo?.type) {
RelationType.REPLACE -> {
Timber.w("## UTD replace in room $roomId for event ${event.eventId}")
}
RelationType.RESPONSE -> {
Timber.w("## UTD response in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
RelationType.REFERENCE -> {
Timber.w("## UTD reference in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
encryptedReferenceAggregationProcessor.handle(
realm = realm,
event = event,
isLocalEcho = isLocalEcho,
relatedEventId = encryptedEventContent.relatesTo.eventId,
)
}
RelationType.ANNOTATION -> {
Timber.w("## UTD annotation in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
else -> Unit
}
}
// OPT OUT serer aggregation until API mature enough // OPT OUT serer aggregation until API mature enough
private val SHOULD_HANDLE_SERVER_AGREGGATION = false // should be true to work with e2e private val SHOULD_HANDLE_SERVER_AGREGGATION = false // should be true to work with e2e

View File

@ -155,6 +155,8 @@ internal class DefaultPollAggregationProcessor @Inject constructor(
) )
aggregatedPollSummaryEntity.aggregatedContent = ContentMapper.map(newSumModel.toContent()) aggregatedPollSummaryEntity.aggregatedContent = ContentMapper.map(newSumModel.toContent())
event.eventId?.let { removeEncryptedRelatedEventIdIfNeeded(aggregatedPollSummaryEntity, it) }
return true return true
} }
@ -180,6 +182,8 @@ internal class DefaultPollAggregationProcessor @Inject constructor(
aggregatedPollSummaryEntity.sourceEvents.add(event.eventId) aggregatedPollSummaryEntity.sourceEvents.add(event.eventId)
} }
event.eventId?.let { removeEncryptedRelatedEventIdIfNeeded(aggregatedPollSummaryEntity, it) }
if (!isLocalEcho) { if (!isLocalEcho) {
ensurePollIsFullyAggregated(roomId, pollEventId) ensurePollIsFullyAggregated(roomId, pollEventId)
} }
@ -226,4 +230,10 @@ internal class DefaultPollAggregationProcessor @Inject constructor(
fetchPollResponseEventsTask.execute(params) fetchPollResponseEventsTask.execute(params)
} }
} }
private fun removeEncryptedRelatedEventIdIfNeeded(aggregatedPollSummaryEntity: PollResponseAggregatedSummaryEntity, eventId: String) {
if (aggregatedPollSummaryEntity.encryptedRelatedEventIds.contains(eventId)) {
aggregatedPollSummaryEntity.encryptedRelatedEventIds.remove(eventId)
}
}
} }

View File

@ -21,7 +21,7 @@ import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
interface PollAggregationProcessor { internal interface PollAggregationProcessor {
/** /**
* Poll start events don't need to be processed by the aggregator. * Poll start events don't need to be processed by the aggregator.
* This function will only handle if the poll is edited and will update the poll summary entity. * This function will only handle if the poll is edited and will update the poll summary entity.

View File

@ -0,0 +1,59 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room.aggregation.utd
import io.realm.Realm
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntityFields
import javax.inject.Inject
internal class EncryptedReferenceAggregationProcessor @Inject constructor() {
fun handle(
realm: Realm,
event: Event,
isLocalEcho: Boolean,
relatedEventId: String?
): Boolean {
return if (isLocalEcho || relatedEventId.isNullOrEmpty()) {
false
} else {
handlePollReference(realm = realm, event = event, relatedEventId = relatedEventId)
true
}
}
private fun handlePollReference(
realm: Realm,
event: Event,
relatedEventId: String
) {
event.eventId?.let { eventId ->
val existingRelatedPoll = getPollSummaryWithEventId(realm, relatedEventId)
if (eventId !in existingRelatedPoll?.encryptedRelatedEventIds.orEmpty()) {
existingRelatedPoll?.encryptedRelatedEventIds?.add(eventId)
}
}
}
private fun getPollSummaryWithEventId(realm: Realm, eventId: String): PollResponseAggregatedSummaryEntity? {
return realm.where(PollResponseAggregatedSummaryEntity::class.java)
.containsValue(PollResponseAggregatedSummaryEntityFields.SOURCE_EVENTS.`$`, eventId)
.findFirst()
}
}

View File

@ -0,0 +1,132 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room
import io.mockk.every
import io.mockk.mockk
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.events.model.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntityFields
import org.matrix.android.sdk.test.fakes.FakeClock
import org.matrix.android.sdk.test.fakes.FakeRealm
import org.matrix.android.sdk.test.fakes.FakeStateEventDataSource
import org.matrix.android.sdk.test.fakes.givenEqualTo
import org.matrix.android.sdk.test.fakes.givenFindFirst
import org.matrix.android.sdk.test.fakes.internal.FakeEventEditValidator
import org.matrix.android.sdk.test.fakes.internal.FakeLiveLocationAggregationProcessor
import org.matrix.android.sdk.test.fakes.internal.FakePollAggregationProcessor
import org.matrix.android.sdk.test.fakes.internal.FakeSessionManager
import org.matrix.android.sdk.test.fakes.internal.session.room.aggregation.utd.FakeEncryptedReferenceAggregationProcessor
private const val A_ROOM_ID = "room-id"
private const val AN_EVENT_ID = "event-id"
internal class EventRelationsAggregationProcessorTest {
private val fakeStateEventDataSource = FakeStateEventDataSource()
private val fakeSessionManager = FakeSessionManager()
private val fakeLiveLocationAggregationProcessor = FakeLiveLocationAggregationProcessor()
private val fakePollAggregationProcessor = FakePollAggregationProcessor()
private val fakeEncryptedReferenceAggregationProcessor = FakeEncryptedReferenceAggregationProcessor()
private val fakeEventEditValidator = FakeEventEditValidator()
private val fakeClock = FakeClock()
private val fakeRealm = FakeRealm()
private val encryptedEventRelationsAggregationProcessor = EventRelationsAggregationProcessor(
userId = "userId",
stateEventDataSource = fakeStateEventDataSource.instance,
sessionId = "sessionId",
sessionManager = fakeSessionManager.instance,
liveLocationAggregationProcessor = fakeLiveLocationAggregationProcessor.instance,
pollAggregationProcessor = fakePollAggregationProcessor.instance,
encryptedReferenceAggregationProcessor = fakeEncryptedReferenceAggregationProcessor.instance,
editValidator = fakeEventEditValidator.instance,
clock = fakeClock,
)
@Test
fun `given an encrypted reference event when process then reference is processed`() {
// Given
val anEvent = givenAnEvent(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
eventType = EventType.ENCRYPTED,
)
val relatedEventId = "related-event-id"
val encryptedEventContent = givenEncryptedEventContent(
relationType = RelationType.REFERENCE,
relatedEventId = relatedEventId,
)
every { anEvent.content } returns encryptedEventContent.toContent()
val resultOfReferenceProcess = false
fakeEncryptedReferenceAggregationProcessor.givenHandleReturns(resultOfReferenceProcess)
givenEventAnnotationsSummary(roomId = A_ROOM_ID, eventId = AN_EVENT_ID, annotationsSummary = null)
// When
encryptedEventRelationsAggregationProcessor.process(
realm = fakeRealm.instance,
event = anEvent,
)
// Then
fakeEncryptedReferenceAggregationProcessor.verifyHandle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = false,
relatedEventId = relatedEventId,
)
}
private fun givenAnEvent(
eventId: String,
roomId: String?,
eventType: String,
): Event {
return mockk<Event>().also {
every { it.eventId } returns eventId
every { it.roomId } returns roomId
every { it.getClearType() } returns eventType
}
}
private fun givenEncryptedEventContent(relationType: String, relatedEventId: String): EncryptedEventContent {
val relationContent = RelationDefaultContent(
eventId = relatedEventId,
type = relationType,
)
return EncryptedEventContent(
relatesTo = relationContent,
)
}
private fun givenEventAnnotationsSummary(
roomId: String,
eventId: String,
annotationsSummary: EventAnnotationsSummaryEntity?
) {
fakeRealm.givenWhere<EventAnnotationsSummaryEntity>()
.givenEqualTo(EventAnnotationsSummaryEntityFields.ROOM_ID, roomId)
.givenEqualTo(EventAnnotationsSummaryEntityFields.EVENT_ID, eventId)
.givenFindFirst(annotationsSummary)
}
}

View File

@ -25,6 +25,8 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeTrue import org.amshove.kluent.shouldBeTrue
import org.amshove.kluent.shouldContain
import org.amshove.kluent.shouldNotContain
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
@ -105,6 +107,24 @@ class DefaultPollAggregationProcessorTest {
pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT).shouldBeTrue() pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT).shouldBeTrue()
} }
@Test
fun `given a poll response event with a reference, when processing, then event id is removed from encrypted events list`() {
// Given
val anotherEventId = "other-event-id"
val pollResponseAggregatedSummaryEntity = PollResponseAggregatedSummaryEntity(
encryptedRelatedEventIds = RealmList(AN_EVENT_ID, anotherEventId)
)
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns pollResponseAggregatedSummaryEntity
// When
val result = pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT)
// Then
result.shouldBeTrue()
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldNotContain(AN_EVENT_ID)
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldContain(anotherEventId)
}
@Test @Test
fun `given a poll response event after poll is closed, when processing, then is ignored and returns false`() { fun `given a poll response event after poll is closed, when processing, then is ignored and returns false`() {
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity().apply { every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity().apply {
@ -132,12 +152,33 @@ class DefaultPollAggregationProcessorTest {
// Given // Given
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity()
every { fakeTaskExecutor.instance.executorScope } returns this every { fakeTaskExecutor.instance.executorScope } returns this
// When
val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, true) val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, true)
// When
val result = pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT)
// Then // Then
pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT).shouldBeTrue() result.shouldBeTrue()
}
@Test
fun `given a poll end event, when processing, then event id is removed from encrypted events list`() = runTest {
// Given
val anotherEventId = "other-event-id"
val pollResponseAggregatedSummaryEntity = PollResponseAggregatedSummaryEntity(
encryptedRelatedEventIds = RealmList(AN_EVENT_ID, anotherEventId)
)
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns pollResponseAggregatedSummaryEntity
every { fakeTaskExecutor.instance.executorScope } returns this
val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, true)
// When
val result = pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT)
// Then
result.shouldBeTrue()
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldNotContain(AN_EVENT_ID)
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldContain(anotherEventId)
} }
@Test @Test
@ -145,12 +186,13 @@ class DefaultPollAggregationProcessorTest {
// Given // Given
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity()
every { fakeTaskExecutor.instance.executorScope } returns this every { fakeTaskExecutor.instance.executorScope } returns this
// When
val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, false) val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, false)
// When
val result = pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT)
// Then // Then
pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT).shouldBeTrue() result.shouldBeTrue()
} }
@Test @Test

View File

@ -0,0 +1,138 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room.aggregation.utd
import io.mockk.every
import io.mockk.mockk
import io.realm.RealmList
import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeTrue
import org.amshove.kluent.shouldContain
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntityFields
import org.matrix.android.sdk.test.fakes.FakeRealm
import org.matrix.android.sdk.test.fakes.givenContainsValue
import org.matrix.android.sdk.test.fakes.givenFindFirst
internal class EncryptedReferenceAggregationProcessorTest {
private val fakeRealm = FakeRealm()
private val encryptedReferenceAggregationProcessor = EncryptedReferenceAggregationProcessor()
@Test
fun `given local echo when process then result is false`() {
// Given
val anEvent = mockk<Event>()
val isLocalEcho = true
val relatedEventId = "event-id"
// When
val result = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = relatedEventId,
)
// Then
result.shouldBeFalse()
}
@Test
fun `given invalid event id when process then result is false`() {
// Given
val anEvent = mockk<Event>()
val isLocalEcho = false
// When
val result1 = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = null,
)
val result2 = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = "",
)
// Then
result1.shouldBeFalse()
result2.shouldBeFalse()
}
@Test
fun `given related event id of an existing poll when process then result is true and event id is stored in poll summary`() {
// Given
val anEventId = "event-id"
val anEvent = givenAnEvent(anEventId)
val isLocalEcho = false
val relatedEventId = "related-event-id"
val pollResponseAggregatedSummaryEntity = PollResponseAggregatedSummaryEntity(
encryptedRelatedEventIds = RealmList(),
)
fakeRealm.givenWhere<PollResponseAggregatedSummaryEntity>()
.givenContainsValue(PollResponseAggregatedSummaryEntityFields.SOURCE_EVENTS.`$`, relatedEventId)
.givenFindFirst(pollResponseAggregatedSummaryEntity)
// When
val result = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = relatedEventId,
)
// Then
result.shouldBeTrue()
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldContain(anEventId)
}
@Test
fun `given related event id but no existing related poll when process then result is true and event id is not stored`() {
// Given
val anEventId = "event-id"
val anEvent = givenAnEvent(anEventId)
val isLocalEcho = false
val relatedEventId = "related-event-id"
fakeRealm.givenWhere<PollResponseAggregatedSummaryEntity>()
.givenContainsValue(PollResponseAggregatedSummaryEntityFields.SOURCE_EVENTS.`$`, relatedEventId)
.givenFindFirst(null)
// When
val result = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = relatedEventId,
)
// Then
result.shouldBeTrue()
}
private fun givenAnEvent(eventId: String): Event {
return mockk<Event>().also {
every { it.eventId } returns eventId
}
}
}

View File

@ -117,6 +117,14 @@ inline fun <reified T : RealmModel> RealmQuery<T>.givenIn(
return this return this
} }
inline fun <reified T : RealmModel> RealmQuery<T>.givenContainsValue(
fieldName: String,
value: String,
): RealmQuery<T> {
every { containsValue(fieldName, value) } returns this
return this
}
/** /**
* Should be called on a mocked RealmObject and not on a real RealmObject so that the underlying final method is mocked. * Should be called on a mocked RealmObject and not on a real RealmObject so that the underlying final method is mocked.
*/ */

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.test.fakes.internal
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.room.EventEditValidator
internal class FakeEventEditValidator {
val instance: EventEditValidator = mockk()
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.test.fakes.internal
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor
internal class FakeLiveLocationAggregationProcessor {
val instance: LiveLocationAggregationProcessor = mockk()
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.test.fakes.internal
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollAggregationProcessor
internal class FakePollAggregationProcessor {
val instance: PollAggregationProcessor = mockk()
}

View File

@ -0,0 +1,42 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.test.fakes.internal.session.room.aggregation.utd
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.realm.Realm
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.internal.session.room.aggregation.utd.EncryptedReferenceAggregationProcessor
internal class FakeEncryptedReferenceAggregationProcessor {
val instance: EncryptedReferenceAggregationProcessor = mockk()
fun givenHandleReturns(result: Boolean) {
every { instance.handle(any(), any(), any(), any()) } returns result
}
fun verifyHandle(
realm: Realm,
event: Event,
isLocalEcho: Boolean,
relatedEventId: String?,
) {
verify { instance.handle(realm, event, isLocalEcho, relatedEventId) }
}
}

View File

@ -232,7 +232,7 @@ android {
resValue "color", "launcher_background", "#0DBD8B" resValue "color", "launcher_background", "#0DBD8B"
if (project.hasProperty("coverage")) { if (project.hasProperty("coverage")) {
testCoverageEnabled = coverage.enableTestCoverage testCoverageEnabled = coverage == "true"
} }
} }
@ -403,8 +403,8 @@ dependencies {
androidTestImplementation libs.mockk.mockkAndroid androidTestImplementation libs.mockk.mockkAndroid
androidTestUtil libs.androidx.orchestrator androidTestUtil libs.androidx.orchestrator
androidTestImplementation libs.androidx.fragmentTesting androidTestImplementation libs.androidx.fragmentTesting
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.22" androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.8.0"
debugImplementation libs.androidx.fragmentTesting debugImplementation libs.androidx.fragmentTestingManifest
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
} }

View File

@ -69,7 +69,7 @@ android {
buildTypes { buildTypes {
debug { debug {
if (project.hasProperty("coverage")) { if (project.hasProperty("coverage")) {
testCoverageEnabled = coverage.enableTestCoverage testCoverageEnabled = coverage == "true"
} }
} }
} }
@ -330,6 +330,7 @@ dependencies {
} }
androidTestImplementation libs.mockk.mockkAndroid androidTestImplementation libs.mockk.mockkAndroid
androidTestUtil libs.androidx.orchestrator androidTestUtil libs.androidx.orchestrator
debugImplementation libs.androidx.fragmentTesting debugImplementation libs.androidx.fragmentTestingManifest
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.22" androidTestImplementation libs.androidx.fragmentTesting
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.8.0"
} }

View File

@ -20,6 +20,7 @@ import android.content.ActivityNotFoundException
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.call.dialpad.DialPadLookup
import im.vector.app.features.roomprofile.polls.RoomPollsLoadingError
import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voice.VoiceFailure
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure.RecordingError import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure.RecordingError
@ -138,6 +139,7 @@ class DefaultErrorFormatter @Inject constructor(
stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id) stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id)
is VoiceFailure -> voiceMessageError(throwable) is VoiceFailure -> voiceMessageError(throwable)
is VoiceBroadcastFailure -> voiceBroadcastMessageError(throwable) is VoiceBroadcastFailure -> voiceBroadcastMessageError(throwable)
is RoomPollsLoadingError -> stringProvider.getString(R.string.room_polls_loading_error)
is ActivityNotFoundException -> is ActivityNotFoundException ->
stringProvider.getString(R.string.error_no_external_application_found) stringProvider.getString(R.string.error_no_external_application_found)
else -> throwable.localizedMessage else -> throwable.localizedMessage
@ -157,6 +159,8 @@ class DefaultErrorFormatter @Inject constructor(
RecordingError.BlockedBySomeoneElse -> stringProvider.getString(R.string.error_voice_broadcast_blocked_by_someone_else_message) RecordingError.BlockedBySomeoneElse -> stringProvider.getString(R.string.error_voice_broadcast_blocked_by_someone_else_message)
RecordingError.NoPermission -> stringProvider.getString(R.string.error_voice_broadcast_permission_denied_message) RecordingError.NoPermission -> stringProvider.getString(R.string.error_voice_broadcast_permission_denied_message)
RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message) RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message)
is VoiceBroadcastFailure.ListeningError.UnableToPlay,
is VoiceBroadcastFailure.ListeningError.DownloadError -> stringProvider.getString(R.string.error_voice_broadcast_unable_to_play)
} }
} }

View File

@ -17,6 +17,7 @@ package im.vector.app.features.crypto.verification
import android.app.Activity import android.app.Activity
import android.app.Dialog import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.KeyEvent import android.view.KeyEvent
@ -84,10 +85,6 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetV
return BottomSheetVerificationBinding.inflate(inflater, container, false) return BottomSheetVerificationBinding.inflate(inflater, container, false)
} }
init {
isCancelable = false
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -210,6 +207,8 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetV
return@withState return@withState
} }
isCancelable = state.isVerificationRequired.not()
// Did the request result in a SAS transaction? // Did the request result in a SAS transaction?
if (state.sasTransactionState != null) { if (state.sasTransactionState != null) {
when (state.sasTransactionState) { when (state.sasTransactionState) {
@ -396,6 +395,11 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetV
const val WAITING_SELF_VERIF_TAG: String = "WAITING_SELF_VERIF_TAG" const val WAITING_SELF_VERIF_TAG: String = "WAITING_SELF_VERIF_TAG"
} }
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
viewModel.confirmCancel()
}
} }
// fun View.getParentCoordinatorLayout(): CoordinatorLayout? { // fun View.getParentCoordinatorLayout(): CoordinatorLayout? {

View File

@ -138,7 +138,7 @@ class MessageComposerViewModel @AssistedInject constructor(
} }
private fun handleOnTextChanged(action: MessageComposerAction.OnTextChanged) { private fun handleOnTextChanged(action: MessageComposerAction.OnTextChanged) {
val needsSendButtonVisibilityUpdate = currentComposerText.isEmpty() != action.text.isEmpty() val needsSendButtonVisibilityUpdate = currentComposerText.isBlank() != action.text.isBlank()
currentComposerText = SpannableString(action.text) currentComposerText = SpannableString(action.text)
if (needsSendButtonVisibilityUpdate) { if (needsSendButtonVisibilityUpdate) {
updateIsSendButtonVisibility(true) updateIsSendButtonVisibility(true)

View File

@ -229,6 +229,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
voiceMessageViews.renderPlaying(state) voiceMessageViews.renderPlaying(state)
} }
is AudioMessagePlaybackTracker.Listener.State.Paused, is AudioMessagePlaybackTracker.Listener.State.Paused,
is AudioMessagePlaybackTracker.Listener.State.Error,
is AudioMessagePlaybackTracker.Listener.State.Idle -> { is AudioMessagePlaybackTracker.Listener.State.Idle -> {
voiceMessageViews.renderIdle() voiceMessageViews.renderIdle()
} }

View File

@ -216,8 +216,8 @@ class MessageActionsViewModel @AssistedInject constructor(
noticeEventFormatter.format(timelineEvent, room?.roomSummary()?.isDirect.orFalse()) noticeEventFormatter.format(timelineEvent, room?.roomSummary()?.isDirect.orFalse())
} }
in EventType.POLL_START.values -> { in EventType.POLL_START.values -> {
timelineEvent.root.getClearContent().toModel<MessagePollContent>(catchError = true) (timelineEvent.getVectorLastMessageContent() as? MessagePollContent)?.getBestPollCreationInfo()?.question?.getBestQuestion()
?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "" ?: stringProvider.getString(R.string.message_reply_to_poll_preview)
} }
else -> null else -> null
} }

View File

@ -83,9 +83,14 @@ class PollItemViewStateFactory @Inject constructor(
totalVotes: Int, totalVotes: Int,
winnerVoteCount: Int?, winnerVoteCount: Int?,
): PollViewState { ): PollViewState {
val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes)
}
return PollViewState( return PollViewState(
question = question, question = question,
votesStatus = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes), votesStatus = totalVotesText,
canVote = false, canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollCreationInfo?.answers?.map { answer ->
val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "") val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "")
@ -126,9 +131,14 @@ class PollItemViewStateFactory @Inject constructor(
pollResponseSummary: PollResponseData?, pollResponseSummary: PollResponseData?,
totalVotes: Int totalVotes: Int
): PollViewState { ): PollViewState {
val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes)
}
return PollViewState( return PollViewState(
question = question, question = question,
votesStatus = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes), votesStatus = totalVotesText,
canVote = true, canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollCreationInfo?.answers?.map { answer ->
val isMyVote = pollResponseSummary?.myVote == answer.id val isMyVote = pollResponseSummary?.myVote == answer.id
@ -144,7 +154,11 @@ class PollItemViewStateFactory @Inject constructor(
) )
} }
private fun createReadyPollViewState(question: String, pollCreationInfo: PollCreationInfo?, totalVotes: Int): PollViewState { private fun createReadyPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
totalVotes: Int
): PollViewState {
val totalVotesText = if (totalVotes == 0) { val totalVotesText = if (totalVotes == 0) {
stringProvider.getString(R.string.poll_no_votes_cast) stringProvider.getString(R.string.poll_no_votes_cast)
} else { } else {

View File

@ -15,9 +15,9 @@
*/ */
package im.vector.app.features.home.room.detail.timeline.factory package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.DrawableProvider
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup
@ -36,7 +36,6 @@ import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.getUserOrDefault
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject import javax.inject.Inject
@ -45,6 +44,7 @@ class VoiceBroadcastItemFactory @Inject constructor(
private val avatarSizeProvider: AvatarSizeProvider, private val avatarSizeProvider: AvatarSizeProvider,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
private val drawableProvider: DrawableProvider, private val drawableProvider: DrawableProvider,
private val errorFormatter: ErrorFormatter,
private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
private val voiceBroadcastPlayer: VoiceBroadcastPlayer, private val voiceBroadcastPlayer: VoiceBroadcastPlayer,
private val playbackTracker: AudioMessagePlaybackTracker, private val playbackTracker: AudioMessagePlaybackTracker,
@ -75,13 +75,14 @@ class VoiceBroadcastItemFactory @Inject constructor(
voiceBroadcast = voiceBroadcast, voiceBroadcast = voiceBroadcast,
voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState, voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState,
duration = voiceBroadcastEventsGroup.getDuration(), duration = voiceBroadcastEventsGroup.getDuration(),
recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName().orEmpty(), recorderName = params.event.senderInfo.disambiguatedDisplayName,
recorder = voiceBroadcastRecorder, recorder = voiceBroadcastRecorder,
player = voiceBroadcastPlayer, player = voiceBroadcastPlayer,
playbackTracker = playbackTracker, playbackTracker = playbackTracker,
roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(), roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(),
colorProvider = colorProvider, colorProvider = colorProvider,
drawableProvider = drawableProvider, drawableProvider = drawableProvider,
errorFormatter = errorFormatter,
) )
return if (isRecording) { return if (isRecording) {

View File

@ -27,6 +27,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.isLive 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.model.asVoiceBroadcastEvent
import me.gujun.android.span.image import me.gujun.android.span.image
import me.gujun.android.span.span import me.gujun.android.span.span
@ -39,6 +40,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getTextDisplayableContent import org.matrix.android.sdk.api.session.room.timeline.getTextDisplayableContent
@ -86,11 +88,17 @@ class DisplayableEventFormatter @Inject constructor(
simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor) simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor)
} }
MessageType.MSGTYPE_AUDIO -> { MessageType.MSGTYPE_AUDIO -> {
if ((messageContent as? MessageAudioContent)?.voiceMessageIndicator != null) { when {
simpleFormat(senderName, stringProvider.getString(R.string.sent_a_voice_message), appendAuthor) (messageContent as? MessageAudioContent)?.voiceMessageIndicator == null -> {
} else {
simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor) simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor)
} }
timelineEvent.root.asMessageAudioEvent().isVoiceBroadcast() -> {
simpleFormat(senderName, stringProvider.getString(R.string.started_a_voice_broadcast), appendAuthor)
}
else -> {
simpleFormat(senderName, stringProvider.getString(R.string.sent_a_voice_message), appendAuthor)
}
}
} }
MessageType.MSGTYPE_VIDEO -> { MessageType.MSGTYPE_VIDEO -> {
simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor) simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor)
@ -130,7 +138,7 @@ class DisplayableEventFormatter @Inject constructor(
span { } span { }
} }
in EventType.POLL_START.values -> { in EventType.POLL_START.values -> {
timelineEvent.root.getClearContent().toModel<MessagePollContent>(catchError = true)?.getBestPollCreationInfo()?.question?.getBestQuestion() (timelineEvent.getVectorLastMessageContent() as? MessagePollContent)?.getBestPollCreationInfo()?.question?.getBestQuestion()
?: stringProvider.getString(R.string.sent_a_poll) ?: stringProvider.getString(R.string.sent_a_poll)
} }
in EventType.POLL_RESPONSE.values -> { in EventType.POLL_RESPONSE.values -> {

View File

@ -50,8 +50,11 @@ class AudioMessagePlaybackTracker @Inject constructor() {
listeners.remove(id) listeners.remove(id)
} }
fun pauseAllPlaybacks() { fun unregisterListeners() {
listeners.keys.forEach(::pausePlayback) listeners.forEach {
it.value.onUpdate(Listener.State.Idle)
}
listeners.clear()
} }
/** /**
@ -84,6 +87,10 @@ class AudioMessagePlaybackTracker @Inject constructor() {
} }
} }
fun pauseAllPlaybacks() {
listeners.keys.forEach(::pausePlayback)
}
fun pausePlayback(id: String) { fun pausePlayback(id: String) {
val state = getPlaybackState(id) val state = getPlaybackState(id)
if (state is Listener.State.Playing) { if (state is Listener.State.Playing) {
@ -94,8 +101,15 @@ class AudioMessagePlaybackTracker @Inject constructor() {
} }
fun stopPlayback(id: String) { fun stopPlayback(id: String) {
val state = getPlaybackState(id)
if (state !is Listener.State.Error) {
setState(id, Listener.State.Idle) setState(id, Listener.State.Idle)
} }
}
fun onError(id: String, error: Throwable) {
setState(id, Listener.State.Error(error))
}
fun updatePlayingAtPlaybackTime(id: String, time: Int, percentage: Float) { fun updatePlayingAtPlaybackTime(id: String, time: Int, percentage: Float) {
setState(id, Listener.State.Playing(time, percentage)) setState(id, Listener.State.Playing(time, percentage))
@ -116,6 +130,7 @@ class AudioMessagePlaybackTracker @Inject constructor() {
is Listener.State.Playing -> state.playbackTime is Listener.State.Playing -> state.playbackTime
is Listener.State.Paused -> state.playbackTime is Listener.State.Paused -> state.playbackTime
is Listener.State.Recording, is Listener.State.Recording,
is Listener.State.Error,
Listener.State.Idle, Listener.State.Idle,
null -> null null -> null
} }
@ -126,18 +141,12 @@ class AudioMessagePlaybackTracker @Inject constructor() {
is Listener.State.Playing -> state.percentage is Listener.State.Playing -> state.percentage
is Listener.State.Paused -> state.percentage is Listener.State.Paused -> state.percentage
is Listener.State.Recording, is Listener.State.Recording,
is Listener.State.Error,
Listener.State.Idle, Listener.State.Idle,
null -> null null -> null
} }
} }
fun unregisterListeners() {
listeners.forEach {
it.value.onUpdate(Listener.State.Idle)
}
listeners.clear()
}
companion object { companion object {
const val RECORDING_ID = "RECORDING_ID" const val RECORDING_ID = "RECORDING_ID"
} }
@ -148,6 +157,7 @@ class AudioMessagePlaybackTracker @Inject constructor() {
sealed class State { sealed class State {
object Idle : State() object Idle : State()
data class Error(val failure: Throwable) : State()
data class Playing(val playbackTime: Int, val percentage: Float) : State() data class Playing(val playbackTime: Int, val percentage: Float) : State()
data class Paused(val playbackTime: Int, val percentage: Float) : State() data class Paused(val playbackTime: Int, val percentage: Float) : State()
data class Recording(val amplitudeList: List<Int>) : State() data class Recording(val amplitudeList: List<Int>) : State()

View File

@ -44,7 +44,8 @@ class PollResponseDataFactory @Inject constructor(
) )
}, },
winnerVoteCount = it.aggregatedContent?.winnerVoteCount ?: 0, winnerVoteCount = it.aggregatedContent?.winnerVoteCount ?: 0,
totalVotes = it.aggregatedContent?.totalVotes ?: 0 totalVotes = it.aggregatedContent?.totalVotes ?: 0,
hasEncryptedRelatedEvents = it.encryptedRelatedEventIds.isNotEmpty(),
) )
} }
} }

View File

@ -22,6 +22,7 @@ import androidx.annotation.IdRes
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.tintBackground import im.vector.app.core.extensions.tintBackground
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.DrawableProvider
@ -48,6 +49,7 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol
protected val colorProvider get() = voiceBroadcastAttributes.colorProvider protected val colorProvider get() = voiceBroadcastAttributes.colorProvider
protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider
protected val avatarRenderer get() = attributes.avatarRenderer protected val avatarRenderer get() = attributes.avatarRenderer
protected val errorFormatter get() = voiceBroadcastAttributes.errorFormatter
protected val callback get() = attributes.callback protected val callback get() = attributes.callback
override fun isCacheable(): Boolean = false override fun isCacheable(): Boolean = false
@ -107,5 +109,6 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol
val roomItem: MatrixItem?, val roomItem: MatrixItem?,
val colorProvider: ColorProvider, val colorProvider: ColorProvider,
val drawableProvider: DrawableProvider, val drawableProvider: DrawableProvider,
val errorFormatter: ErrorFormatter,
) )
} }

View File

@ -142,6 +142,7 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
private fun renderStateBasedOnAudioPlayback(holder: Holder) { private fun renderStateBasedOnAudioPlayback(holder: Holder) {
audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state ->
when (state) { when (state) {
is AudioMessagePlaybackTracker.Listener.State.Error,
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state)

View File

@ -90,7 +90,8 @@ data class PollResponseData(
val votes: Map<String, PollVoteSummaryData>?, val votes: Map<String, PollVoteSummaryData>?,
val totalVotes: Int = 0, val totalVotes: Int = 0,
val winnerVoteCount: Int = 0, val winnerVoteCount: Int = 0,
val isClosed: Boolean = false val isClosed: Boolean = false,
val hasEncryptedRelatedEvents: Boolean = false,
) : Parcelable { ) : Parcelable {
fun getVoteSummaryOfAnOption(optionId: String) = votes?.get(optionId) fun getVoteSummaryOfAnOption(optionId: String) = votes?.get(optionId)

View File

@ -20,11 +20,13 @@ import android.text.format.DateUtils
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.Group
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.onClick import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer
@ -54,6 +56,16 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
} }
} }
player.addListener(voiceBroadcast, playerListener) player.addListener(voiceBroadcast, playerListener)
playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState ->
renderBackwardForwardButtons(holder, playbackState)
renderPlaybackError(holder, playbackState)
renderLiveIndicator(holder)
if (!isUserSeeking) {
holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0
}
}
bindSeekBar(holder) bindSeekBar(holder)
bindButtons(holder) bindButtons(holder)
} }
@ -63,10 +75,11 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
playPauseButton.setOnClickListener { playPauseButton.setOnClickListener {
if (player.currentVoiceBroadcast == voiceBroadcast) { if (player.currentVoiceBroadcast == voiceBroadcast) {
when (player.playingState) { when (player.playingState) {
VoiceBroadcastPlayer.State.PLAYING, VoiceBroadcastPlayer.State.Playing,
VoiceBroadcastPlayer.State.BUFFERING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) VoiceBroadcastPlayer.State.Buffering -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause)
VoiceBroadcastPlayer.State.PAUSED, VoiceBroadcastPlayer.State.Paused,
VoiceBroadcastPlayer.State.IDLE -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) is VoiceBroadcastPlayer.State.Error,
VoiceBroadcastPlayer.State.Idle -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
} }
} else { } else {
callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
@ -100,17 +113,18 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) { private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) {
with(holder) { with(holder) {
bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING bufferingView.isVisible = state == VoiceBroadcastPlayer.State.Buffering
voiceBroadcastMetadata.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING voiceBroadcastMetadata.isVisible = state != VoiceBroadcastPlayer.State.Buffering
when (state) { when (state) {
VoiceBroadcastPlayer.State.PLAYING, VoiceBroadcastPlayer.State.Playing,
VoiceBroadcastPlayer.State.BUFFERING -> { VoiceBroadcastPlayer.State.Buffering -> {
playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) playPauseButton.setImageResource(R.drawable.ic_play_pause_pause)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast)
} }
VoiceBroadcastPlayer.State.IDLE, is VoiceBroadcastPlayer.State.Error,
VoiceBroadcastPlayer.State.PAUSED -> { VoiceBroadcastPlayer.State.Idle,
VoiceBroadcastPlayer.State.Paused -> {
playPauseButton.setImageResource(R.drawable.ic_play_pause_play) playPauseButton.setImageResource(R.drawable.ic_play_pause_play)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast)
} }
@ -120,6 +134,18 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
} }
} }
private fun renderPlaybackError(holder: Holder, playbackState: State) {
with(holder) {
if (playbackState is State.Error) {
controlsGroup.isVisible = false
errorView.setTextOrHide(errorFormatter.toHumanReadable(playbackState.failure))
} else {
errorView.isVisible = false
controlsGroup.isVisible = true
}
}
}
private fun bindSeekBar(holder: Holder) { private fun bindSeekBar(holder: Holder) {
with(holder) { with(holder) {
remainingTimeView.text = formatRemainingTime(duration) remainingTimeView.text = formatRemainingTime(duration)
@ -141,13 +167,6 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
} }
}) })
} }
playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState ->
renderBackwardForwardButtons(holder, playbackState)
renderLiveIndicator(holder)
if (!isUserSeeking) {
holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0
}
}
} }
private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) { private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) {
@ -187,6 +206,8 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
val broadcasterNameMetadata by bind<VoiceBroadcastMetadataView>(R.id.broadcasterNameMetadata) val broadcasterNameMetadata by bind<VoiceBroadcastMetadataView>(R.id.broadcasterNameMetadata)
val voiceBroadcastMetadata by bind<VoiceBroadcastMetadataView>(R.id.voiceBroadcastMetadata) val voiceBroadcastMetadata by bind<VoiceBroadcastMetadataView>(R.id.voiceBroadcastMetadata)
val listenersCountMetadata by bind<VoiceBroadcastMetadataView>(R.id.listenersCountMetadata) val listenersCountMetadata by bind<VoiceBroadcastMetadataView>(R.id.listenersCountMetadata)
val errorView by bind<TextView>(R.id.errorView)
val controlsGroup by bind<Group>(R.id.controlsGroup)
} }
companion object { companion object {

View File

@ -124,6 +124,7 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state ->
when (state) { when (state) {
is AudioMessagePlaybackTracker.Listener.State.Error,
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed) is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed) is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed) is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed)

View File

@ -22,41 +22,33 @@ import com.airbnb.mvrx.Loading
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter 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.epoxy.VectorEpoxyModel
import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.RoomListDisplayMode 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.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.home.room.typing.TypingHelper
import im.vector.app.features.voicebroadcast.isLive 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.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.extensions.orFalse 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.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.Membership 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.RoomSummary
import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo 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 org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject import javax.inject.Inject
class RoomSummaryItemFactory @Inject constructor( class RoomSummaryItemFactory @Inject constructor(
private val sessionHolder: ActiveSessionHolder,
private val displayableEventFormatter: DisplayableEventFormatter, private val displayableEventFormatter: DisplayableEventFormatter,
private val dateFormatter: VectorDateFormatter, private val dateFormatter: VectorDateFormatter,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val typingHelper: TypingHelper, private val typingHelper: TypingHelper,
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
private val errorFormatter: ErrorFormatter, private val errorFormatter: ErrorFormatter,
private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase, private val getLatestPreviewableEventUseCase: GetLatestPreviewableEventUseCase,
) { ) {
fun create( fun create(
@ -142,7 +134,7 @@ class RoomSummaryItemFactory @Inject constructor(
val showSelected = selectedRoomIds.contains(roomSummary.roomId) val showSelected = selectedRoomIds.contains(roomSummary.roomId)
var latestFormattedEvent: CharSequence = "" var latestFormattedEvent: CharSequence = ""
var latestEventTime = "" var latestEventTime = ""
val latestEvent = roomSummary.getVectorLatestPreviewableEvent() val latestEvent = getLatestPreviewableEventUseCase.execute(roomSummary.roomId)
if (latestEvent != null) { if (latestEvent != null) {
latestFormattedEvent = displayableEventFormatter.format(latestEvent, roomSummary.isDirect, roomSummary.isDirect.not()) latestFormattedEvent = displayableEventFormatter.format(latestEvent, roomSummary.isDirect, roomSummary.isDirect.not())
latestEventTime = dateFormatter.format(latestEvent.root.originServerTs, DateFormatKind.ROOM_LIST) latestEventTime = dateFormatter.format(latestEvent.root.originServerTs, DateFormatKind.ROOM_LIST)
@ -150,7 +142,8 @@ class RoomSummaryItemFactory @Inject constructor(
val typingMessage = typingHelper.getTypingMessage(roomSummary.typingUsers) val typingMessage = typingHelper.getTypingMessage(roomSummary.typingUsers)
// Skip typing while there is a live voice broadcast // 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) { return if (subtitle.isBlank() && displayMode == RoomListDisplayMode.FILTERED) {
createCenteredRoomSummaryItem(roomSummary, displayMode, showSelected, unreadCount, onClick, onLongClick) 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) 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

@ -0,0 +1,59 @@
/*
* Copyright 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.notifications
import im.vector.app.ActiveSessionDataSource
import im.vector.app.features.voicebroadcast.isVoiceBroadcast
import im.vector.app.features.voicebroadcast.sequence
import org.matrix.android.sdk.api.session.events.model.isVoiceMessage
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.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject
class FilteredEventDetector @Inject constructor(
private val activeSessionDataSource: ActiveSessionDataSource
) {
/**
* Returns true if the given event should be ignored.
* Used to skip notifications if a non expected message is received.
*/
fun shouldBeIgnored(notifiableEvent: NotifiableEvent): Boolean {
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
if (notifiableEvent is NotifiableMessageEvent) {
val room = session.getRoom(notifiableEvent.roomId) ?: return false
val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false
return timelineEvent.shouldBeIgnored()
}
return false
}
/**
* Whether the timeline event should be ignored.
*/
private fun TimelineEvent.shouldBeIgnored(): Boolean {
if (root.isVoiceMessage()) {
val audioEvent = root.asMessageAudioEvent()
// if the event is a voice message related to a voice broadcast, only show the event on the first chunk.
return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1
}
return false
}
}

View File

@ -47,6 +47,7 @@ class NotificationDrawerManager @Inject constructor(
private val notifiableEventProcessor: NotifiableEventProcessor, private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer, private val notificationRenderer: NotificationRenderer,
private val notificationEventPersistence: NotificationEventPersistence, private val notificationEventPersistence: NotificationEventPersistence,
private val filteredEventDetector: FilteredEventDetector,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) { ) {
@ -100,6 +101,11 @@ class NotificationDrawerManager @Inject constructor(
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}") Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
} }
if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) {
Timber.d("onNotifiableEventReceived(): ignore the event")
return
}
add(notifiableEvent) add(notifiableEvent)
} }

View File

@ -76,6 +76,8 @@ class RoomProfileActivity :
return ActivitySimpleBinding.inflate(layoutInflater) return ActivitySimpleBinding.inflate(layoutInflater)
} }
override fun getCoordinatorLayout() = views.coordinatorLayout
override fun initUiAndData() { override fun initUiAndData() {
sharedActionViewModel = viewModelProvider.get(RoomProfileSharedActionViewModel::class.java) sharedActionViewModel = viewModelProvider.get(RoomProfileSharedActionViewModel::class.java)
roomProfileArgs = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG) ?: return roomProfileArgs = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG) ?: return

View File

@ -18,4 +18,6 @@ package im.vector.app.features.roomprofile.polls
import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.platform.VectorViewModelAction
sealed interface RoomPollsAction : VectorViewModelAction sealed interface RoomPollsAction : VectorViewModelAction {
object LoadMorePolls : RoomPollsAction
}

View File

@ -0,0 +1,19 @@
/*
* 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.roomprofile.polls
class RoomPollsLoadingError : Throwable()

View File

@ -18,4 +18,6 @@ package im.vector.app.features.roomprofile.polls
import im.vector.app.core.platform.VectorViewEvents import im.vector.app.core.platform.VectorViewEvents
sealed class RoomPollsViewEvent : VectorViewEvents sealed class RoomPollsViewEvent : VectorViewEvents {
object LoadingError : RoomPollsViewEvent()
}

View File

@ -23,12 +23,20 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase
import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class RoomPollsViewModel @AssistedInject constructor( class RoomPollsViewModel @AssistedInject constructor(
@Assisted initialState: RoomPollsViewState, @Assisted initialState: RoomPollsViewState,
private val getPollsUseCase: GetPollsUseCase, private val getPollsUseCase: GetPollsUseCase,
private val getLoadedPollsStatusUseCase: GetLoadedPollsStatusUseCase,
private val loadMorePollsUseCase: LoadMorePollsUseCase,
private val syncPollsUseCase: SyncPollsUseCase,
) : VectorViewModel<RoomPollsViewState, RoomPollsAction, RoomPollsViewEvent>(initialState) { ) : VectorViewModel<RoomPollsViewState, RoomPollsAction, RoomPollsViewEvent>(initialState) {
@AssistedFactory @AssistedFactory
@ -39,16 +47,63 @@ class RoomPollsViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<RoomPollsViewModel, RoomPollsViewState> by hiltMavericksViewModelFactory() companion object : MavericksViewModelFactory<RoomPollsViewModel, RoomPollsViewState> by hiltMavericksViewModelFactory()
init { init {
observePolls() val roomId = initialState.roomId
updateLoadedPollStatus(roomId)
syncPolls(roomId)
observePolls(roomId)
} }
private fun observePolls() { private fun updateLoadedPollStatus(roomId: String) {
getPollsUseCase.execute() val loadedPollsStatus = getLoadedPollsStatusUseCase.execute(roomId)
setState {
copy(
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays
)
}
}
private fun syncPolls(roomId: String) {
viewModelScope.launch {
setState { copy(isSyncing = true) }
val result = runCatching {
syncPollsUseCase.execute(roomId)
}
if (result.isFailure) {
_viewEvents.post(RoomPollsViewEvent.LoadingError)
}
setState { copy(isSyncing = false) }
}
}
private fun observePolls(roomId: String) {
getPollsUseCase.execute(roomId)
.onEach { setState { copy(polls = it) } } .onEach { setState { copy(polls = it) } }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
override fun handle(action: RoomPollsAction) { override fun handle(action: RoomPollsAction) {
// do nothing for now when (action) {
RoomPollsAction.LoadMorePolls -> handleLoadMore()
}
}
private fun handleLoadMore() = withState { viewState ->
viewModelScope.launch {
setState { copy(isLoadingMore = true) }
val result = runCatching {
val status = loadMorePollsUseCase.execute(viewState.roomId)
setState {
copy(
canLoadMore = status.canLoadMore,
nbLoadedDays = status.nbLoadedDays,
)
}
}
if (result.isFailure) {
_viewEvents.post(RoomPollsViewEvent.LoadingError)
}
setState { copy(isLoadingMore = false) }
}
} }
} }

View File

@ -18,11 +18,19 @@ package im.vector.app.features.roomprofile.polls
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import im.vector.app.features.roomprofile.RoomProfileArgs import im.vector.app.features.roomprofile.RoomProfileArgs
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
data class RoomPollsViewState( data class RoomPollsViewState(
val roomId: String, val roomId: String,
val polls: List<PollSummary> = emptyList(), val polls: List<PollSummary> = emptyList(),
val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = true,
val nbLoadedDays: Int = 0,
val isSyncing: Boolean = false,
) : MavericksState { ) : MavericksState {
constructor(roomProfileArgs: RoomProfileArgs) : this(roomId = roomProfileArgs.roomId) constructor(roomProfileArgs: RoomProfileArgs) : this(roomId = roomProfileArgs.roomId)
fun hasNoPolls() = polls.isEmpty()
fun hasNoPollsAndCanLoadMore() = !isSyncing && hasNoPolls() && canLoadMore
} }

View File

@ -19,13 +19,17 @@ package im.vector.app.features.roomprofile.polls.active
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.roomprofile.polls.RoomPollsType import im.vector.app.features.roomprofile.polls.RoomPollsType
import im.vector.app.features.roomprofile.polls.list.RoomPollsListFragment import im.vector.app.features.roomprofile.polls.list.ui.RoomPollsListFragment
@AndroidEntryPoint @AndroidEntryPoint
class RoomActivePollsFragment : RoomPollsListFragment() { class RoomActivePollsFragment : RoomPollsListFragment() {
override fun getEmptyListTitle(): String { override fun getEmptyListTitle(canLoadMore: Boolean, nbLoadedDays: Int): String {
return getString(R.string.room_polls_active_no_item) return if (canLoadMore) {
stringProvider.getQuantityString(R.plurals.room_polls_active_no_item_for_loaded_period, nbLoadedDays, nbLoadedDays)
} else {
getString(R.string.room_polls_active_no_item)
}
} }
override fun getRoomPollsType(): RoomPollsType { override fun getRoomPollsType(): RoomPollsType {

View File

@ -19,13 +19,17 @@ package im.vector.app.features.roomprofile.polls.ended
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.roomprofile.polls.RoomPollsType import im.vector.app.features.roomprofile.polls.RoomPollsType
import im.vector.app.features.roomprofile.polls.list.RoomPollsListFragment import im.vector.app.features.roomprofile.polls.list.ui.RoomPollsListFragment
@AndroidEntryPoint @AndroidEntryPoint
class RoomEndedPollsFragment : RoomPollsListFragment() { class RoomEndedPollsFragment : RoomPollsListFragment() {
override fun getEmptyListTitle(): String { override fun getEmptyListTitle(canLoadMore: Boolean, nbLoadedDays: Int): String {
return getString(R.string.room_polls_ended_no_item) return if (canLoadMore) {
stringProvider.getQuantityString(R.plurals.room_polls_ended_no_item_for_loaded_period, nbLoadedDays, nbLoadedDays)
} else {
getString(R.string.room_polls_ended_no_item)
}
} }
override fun getRoomPollsType(): RoomPollsType { override fun getRoomPollsType(): RoomPollsType {

View File

@ -1,90 +0,0 @@
/*
* 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.roomprofile.polls.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentRoomPollsListBinding
import im.vector.app.features.roomprofile.polls.PollSummary
import im.vector.app.features.roomprofile.polls.RoomPollsType
import im.vector.app.features.roomprofile.polls.RoomPollsViewModel
import timber.log.Timber
import javax.inject.Inject
abstract class RoomPollsListFragment :
VectorBaseFragment<FragmentRoomPollsListBinding>(),
RoomPollsController.Listener {
@Inject
lateinit var roomPollsController: RoomPollsController
private val viewModel: RoomPollsViewModel by parentFragmentViewModel(RoomPollsViewModel::class)
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomPollsListBinding {
return FragmentRoomPollsListBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupList()
}
abstract fun getEmptyListTitle(): String
abstract fun getRoomPollsType(): RoomPollsType
private fun setupList() {
roomPollsController.listener = this
views.roomPollsList.configureWith(roomPollsController)
views.roomPollsEmptyTitle.text = getEmptyListTitle()
}
override fun onDestroyView() {
cleanUpList()
super.onDestroyView()
}
private fun cleanUpList() {
views.roomPollsList.cleanup()
roomPollsController.listener = null
}
override fun invalidate() = withState(viewModel) { viewState ->
when (getRoomPollsType()) {
RoomPollsType.ACTIVE -> renderList(viewState.polls.filterIsInstance(PollSummary.ActivePoll::class.java))
RoomPollsType.ENDED -> renderList(viewState.polls.filterIsInstance(PollSummary.EndedPoll::class.java))
}
}
private fun renderList(polls: List<PollSummary>) {
roomPollsController.setData(polls)
views.roomPollsEmptyTitle.isVisible = polls.isEmpty()
}
override fun onPollClicked(pollId: String) {
// TODO navigate to details
Timber.d("poll with id $pollId clicked")
}
}

View File

@ -0,0 +1,22 @@
/*
* 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.roomprofile.polls.list.data
data class LoadedPollsStatus(
val canLoadMore: Boolean,
val nbLoadedDays: Int,
)

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,23 +14,60 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.features.roomprofile.polls package im.vector.app.features.roomprofile.polls.list.data
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.asSharedFlow
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
class GetPollsUseCase @Inject constructor() { @Singleton
class RoomPollDataSource @Inject constructor() {
fun execute(): Flow<List<PollSummary>> { private val pollsFlow = MutableSharedFlow<List<PollSummary>>(replay = 1)
// TODO unmock and add unit tests private val polls = mutableListOf<PollSummary>()
return flowOf(getActivePolls() + getEndedPolls()) private var fakeLoadCounter = 0
.map { it.sortedByDescending { poll -> poll.creationTimestamp } }
// TODO
// unmock using SDK service + add unit tests
// after unmock, expose domain layer model (entity) and do the mapping to PollSummary in the UI layer
fun getPolls(roomId: String): Flow<List<PollSummary>> {
Timber.d("roomId=$roomId")
return pollsFlow.asSharedFlow()
} }
private fun getActivePolls(): List<PollSummary.ActivePoll> { fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
Timber.d("roomId=$roomId")
return LoadedPollsStatus(
canLoadMore = canLoadMore(),
nbLoadedDays = fakeLoadCounter * 30,
)
}
private fun canLoadMore(): Boolean {
return fakeLoadCounter < 2
}
suspend fun loadMorePolls(roomId: String): LoadedPollsStatus {
// TODO
// unmock using SDK service + add unit tests
delay(3000)
fakeLoadCounter++
when (fakeLoadCounter) {
1 -> polls.addAll(getActivePollsPart1() + getEndedPollsPart1())
2 -> polls.addAll(getActivePollsPart2() + getEndedPollsPart2())
else -> Unit
}
pollsFlow.emit(polls)
return getLoadedPollsStatus(roomId)
}
private fun getActivePollsPart1(): List<PollSummary.ActivePoll> {
return listOf( return listOf(
PollSummary.ActivePoll( PollSummary.ActivePoll(
id = "id1", id = "id1",
@ -44,6 +81,11 @@ class GetPollsUseCase @Inject constructor() {
creationTimestamp = 1656194400000, creationTimestamp = 1656194400000,
title = "Which sport should the pupils do this year?" title = "Which sport should the pupils do this year?"
), ),
)
}
private fun getActivePollsPart2(): List<PollSummary.ActivePoll> {
return listOf(
PollSummary.ActivePoll( PollSummary.ActivePoll(
id = "id3", id = "id3",
// 2022/06/24 UTC+1 // 2022/06/24 UTC+1
@ -59,7 +101,7 @@ class GetPollsUseCase @Inject constructor() {
) )
} }
private fun getEndedPolls(): List<PollSummary.EndedPoll> { private fun getEndedPollsPart1(): List<PollSummary.EndedPoll> {
return listOf( return listOf(
PollSummary.EndedPoll( PollSummary.EndedPoll(
id = "id1-ended", id = "id1-ended",
@ -77,6 +119,11 @@ class GetPollsUseCase @Inject constructor() {
) )
), ),
), ),
)
}
private fun getEndedPollsPart2(): List<PollSummary.EndedPoll> {
return listOf(
PollSummary.EndedPoll( PollSummary.EndedPoll(
id = "id2-ended", id = "id2-ended",
// 2022/06/26 UTC+1 // 2022/06/26 UTC+1
@ -111,4 +158,17 @@ class GetPollsUseCase @Inject constructor() {
), ),
) )
} }
suspend fun syncPolls(roomId: String) {
Timber.d("roomId=$roomId")
// TODO
// unmock using SDK service + add unit tests
if (fakeLoadCounter == 0) {
// fake first load
loadMorePolls(roomId)
} else {
// fake sync
delay(3000)
}
}
} }

View File

@ -0,0 +1,43 @@
/*
* 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.roomprofile.polls.list.data
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class RoomPollRepository @Inject constructor(
private val roomPollDataSource: RoomPollDataSource,
) {
// TODO after unmock, expose domain layer model (entity) and do the mapping to PollSummary in the UI layer
fun getPolls(roomId: String): Flow<List<PollSummary>> {
return roomPollDataSource.getPolls(roomId)
}
fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
return roomPollDataSource.getLoadedPollsStatus(roomId)
}
suspend fun loadMorePolls(roomId: String): LoadedPollsStatus {
return roomPollDataSource.loadMorePolls(roomId)
}
suspend fun syncPolls(roomId: String) {
return roomPollDataSource.syncPolls(roomId)
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import javax.inject.Inject
class GetLoadedPollsStatusUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
fun execute(roomId: String): LoadedPollsStatus {
return roomPollRepository.getLoadedPollsStatus(roomId)
}
}

View File

@ -0,0 +1,33 @@
/*
* 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.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class GetPollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
fun execute(roomId: String): Flow<List<PollSummary>> {
return roomPollRepository.getPolls(roomId)
.map { it.sortedByDescending { poll -> poll.creationTimestamp } }
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import javax.inject.Inject
class LoadMorePollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
suspend fun execute(roomId: String): LoadedPollsStatus {
return roomPollRepository.loadMorePolls(roomId)
}
}

View File

@ -0,0 +1,32 @@
/*
* 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.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import javax.inject.Inject
/**
* Sync the polls of a given room from last manual loading (see LoadMorePollsUseCase) until now.
*/
class SyncPollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
suspend fun execute(roomId: String) {
roomPollRepository.syncPolls(roomId)
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.features.roomprofile.polls package im.vector.app.features.roomprofile.polls.list.ui
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.features.roomprofile.polls.list package im.vector.app.features.roomprofile.polls.list.ui
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView

View File

@ -0,0 +1,50 @@
/*
* 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.roomprofile.polls.list.ui
import android.widget.Button
import android.widget.ProgressBar
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick
@EpoxyModelClass
abstract class RoomPollLoadMoreItem : VectorEpoxyModel<RoomPollLoadMoreItem.Holder>(R.layout.item_poll_load_more) {
@EpoxyAttribute
var loadingMore: Boolean = false
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var clickListener: ClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.loadMoreButton.isEnabled = loadingMore.not()
holder.loadMoreButton.onClick(clickListener)
holder.loadMoreProgressBar.isVisible = loadingMore
}
class Holder : VectorEpoxyHolder() {
val loadMoreButton by bind<Button>(R.id.roomPollsLoadMore)
val loadMoreProgressBar by bind<ProgressBar>(R.id.roomPollsLoadMoreProgress)
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,38 +14,45 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.features.roomprofile.polls.list package im.vector.app.features.roomprofile.polls.list.ui
import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.roomprofile.polls.PollSummary import im.vector.app.features.roomprofile.polls.RoomPollsViewState
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
class RoomPollsController @Inject constructor( class RoomPollsController @Inject constructor(
val dateFormatter: VectorDateFormatter, val dateFormatter: VectorDateFormatter,
val stringProvider: StringProvider, val stringProvider: StringProvider,
) : TypedEpoxyController<List<PollSummary>>() { ) : TypedEpoxyController<RoomPollsViewState>() {
interface Listener { interface Listener {
fun onPollClicked(pollId: String) fun onPollClicked(pollId: String)
fun onLoadMoreClicked()
} }
var listener: Listener? = null var listener: Listener? = null
override fun buildModels(data: List<PollSummary>?) { override fun buildModels(viewState: RoomPollsViewState?) {
if (data.isNullOrEmpty()) { val polls = viewState?.polls
if (polls.isNullOrEmpty() || viewState.isSyncing) {
return return
} }
for (poll in data) { for (poll in polls) {
when (poll) { when (poll) {
is PollSummary.ActivePoll -> buildActivePollItem(poll) is PollSummary.ActivePoll -> buildActivePollItem(poll)
is PollSummary.EndedPoll -> buildEndedPollItem(poll) is PollSummary.EndedPoll -> buildEndedPollItem(poll)
} }
} }
if (viewState.canLoadMore) {
buildLoadMoreItem(viewState.isLoadingMore)
}
} }
private fun buildActivePollItem(poll: PollSummary.ActivePoll) { private fun buildActivePollItem(poll: PollSummary.ActivePoll) {
@ -73,4 +80,15 @@ class RoomPollsController @Inject constructor(
} }
} }
} }
private fun buildLoadMoreItem(isLoadingMore: Boolean) {
val host = this
roomPollLoadMoreItem {
id(UUID.randomUUID().toString())
loadingMore(isLoadingMore)
clickListener {
host.listener?.onLoadMoreClicked()
}
}
}
} }

View File

@ -0,0 +1,136 @@
/*
* 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.roomprofile.polls.list.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.resources.StringProvider
import im.vector.app.databinding.FragmentRoomPollsListBinding
import im.vector.app.features.roomprofile.polls.RoomPollsAction
import im.vector.app.features.roomprofile.polls.RoomPollsLoadingError
import im.vector.app.features.roomprofile.polls.RoomPollsType
import im.vector.app.features.roomprofile.polls.RoomPollsViewEvent
import im.vector.app.features.roomprofile.polls.RoomPollsViewModel
import im.vector.app.features.roomprofile.polls.RoomPollsViewState
import timber.log.Timber
import javax.inject.Inject
abstract class RoomPollsListFragment :
VectorBaseFragment<FragmentRoomPollsListBinding>(),
RoomPollsController.Listener {
@Inject
lateinit var roomPollsController: RoomPollsController
@Inject
lateinit var stringProvider: StringProvider
private val viewModel: RoomPollsViewModel by parentFragmentViewModel(RoomPollsViewModel::class)
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomPollsListBinding {
return FragmentRoomPollsListBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
observeViewEvents()
setupList()
setupLoadMoreButton()
}
private fun observeViewEvents() {
viewModel.observeViewEvents { viewEvent ->
when (viewEvent) {
RoomPollsViewEvent.LoadingError -> showErrorInSnackbar(RoomPollsLoadingError())
}
}
}
abstract fun getEmptyListTitle(canLoadMore: Boolean, nbLoadedDays: Int): String
abstract fun getRoomPollsType(): RoomPollsType
private fun setupList() = withState(viewModel) { viewState ->
roomPollsController.listener = this
views.roomPollsList.configureWith(roomPollsController)
views.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays,
)
}
private fun setupLoadMoreButton() {
views.roomPollsLoadMoreWhenEmpty.onClick {
onLoadMoreClicked()
}
}
override fun onDestroyView() {
cleanUpList()
super.onDestroyView()
}
private fun cleanUpList() {
views.roomPollsList.cleanup()
roomPollsController.listener = null
}
override fun invalidate() = withState(viewModel) { viewState ->
val filteredPolls = when (getRoomPollsType()) {
RoomPollsType.ACTIVE -> viewState.polls.filterIsInstance(PollSummary.ActivePoll::class.java)
RoomPollsType.ENDED -> viewState.polls.filterIsInstance(PollSummary.EndedPoll::class.java)
}
val updatedViewState = viewState.copy(polls = filteredPolls)
renderList(updatedViewState)
renderSyncingView(updatedViewState)
}
private fun renderSyncingView(viewState: RoomPollsViewState) {
views.roomPollsSyncingTitle.isVisible = viewState.isSyncing
views.roomPollsSyncingProgress.isVisible = viewState.isSyncing
}
private fun renderList(viewState: RoomPollsViewState) {
roomPollsController.setData(viewState)
views.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays,
)
views.roomPollsEmptyTitle.isVisible = !viewState.isSyncing && viewState.hasNoPolls()
views.roomPollsLoadMoreWhenEmpty.isVisible = viewState.hasNoPollsAndCanLoadMore()
views.roomPollsLoadMoreWhenEmpty.isEnabled = !viewState.isLoadingMore
views.roomPollsLoadMoreWhenEmptyProgress.isVisible = viewState.hasNoPollsAndCanLoadMore() && viewState.isLoadingMore
}
override fun onPollClicked(pollId: String) {
// TODO navigate to details
Timber.d("poll with id $pollId clicked")
}
override fun onLoadMoreClicked() {
viewModel.handle(RoomPollsAction.LoadMorePolls)
}
}

View File

@ -16,10 +16,21 @@
package im.vector.app.features.voicebroadcast package im.vector.app.features.voicebroadcast
import android.media.MediaPlayer
sealed class VoiceBroadcastFailure : Throwable() { sealed class VoiceBroadcastFailure : Throwable() {
sealed class RecordingError : VoiceBroadcastFailure() { sealed class RecordingError : VoiceBroadcastFailure() {
object NoPermission : RecordingError() object NoPermission : RecordingError()
object BlockedBySomeoneElse : RecordingError() object BlockedBySomeoneElse : RecordingError()
object UserAlreadyBroadcasting : RecordingError() object UserAlreadyBroadcasting : RecordingError()
} }
sealed class ListeningError : VoiceBroadcastFailure() {
/**
* @property what the type of error that has occurred, see [MediaPlayer.OnErrorListener.onError].
* @property extra an extra code, specific to the error, see [MediaPlayer.OnErrorListener.onError].
*/
data class UnableToPlay(val what: Int, val extra: Int) : ListeningError()
data class DownloadError(override val cause: Throwable?) : ListeningError()
}
} }

View File

@ -16,6 +16,7 @@
package im.vector.app.features.voicebroadcast.listening package im.vector.app.features.voicebroadcast.listening
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
interface VoiceBroadcastPlayer { interface VoiceBroadcastPlayer {
@ -26,7 +27,7 @@ interface VoiceBroadcastPlayer {
val currentVoiceBroadcast: VoiceBroadcast? val currentVoiceBroadcast: VoiceBroadcast?
/** /**
* The current playing [State], [State.IDLE] by default. * The current playing [State], [State.Idle] by default.
*/ */
val playingState: State val playingState: State
@ -68,11 +69,12 @@ interface VoiceBroadcastPlayer {
/** /**
* Player states. * Player states.
*/ */
enum class State { sealed interface State {
PLAYING, object Playing : State
PAUSED, object Paused : State
BUFFERING, object Buffering : State
IDLE data class Error(val failure: VoiceBroadcastFailure.ListeningError) : State
object Idle : State
} }
/** /**

View File

@ -24,7 +24,7 @@ import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.extensions.onFirst import im.vector.app.core.extensions.onFirst
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.isLive import im.vector.app.features.voicebroadcast.isLive
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.State import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.State
@ -79,7 +79,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
} }
override var playingState = State.IDLE override var playingState: State = State.Idle
@MainThread @MainThread
set(value) { set(value) {
if (field != value) { if (field != value) {
@ -96,7 +96,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
val hasChanged = currentVoiceBroadcast != voiceBroadcast val hasChanged = currentVoiceBroadcast != voiceBroadcast
when { when {
hasChanged -> startPlayback(voiceBroadcast) hasChanged -> startPlayback(voiceBroadcast)
playingState == State.PAUSED -> resumePlayback() playingState == State.Paused -> resumePlayback()
else -> Unit else -> Unit
} }
} }
@ -107,7 +107,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
override fun stop() { override fun stop() {
// Update state // Update state
playingState = State.IDLE playingState = State.Idle
// Stop and release media players // Stop and release media players
stopPlayer() stopPlayer()
@ -129,7 +129,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run { listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run {
listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) } listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) }
} }
listener.onPlayingStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.IDLE) listener.onPlayingStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.Idle)
listener.onLiveModeChanged(voiceBroadcast == currentVoiceBroadcast) listener.onLiveModeChanged(voiceBroadcast == currentVoiceBroadcast)
} }
@ -139,11 +139,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun startPlayback(voiceBroadcast: VoiceBroadcast) { private fun startPlayback(voiceBroadcast: VoiceBroadcast) {
// Stop listening previous voice broadcast if any // Stop listening previous voice broadcast if any
if (playingState != State.IDLE) stop() if (playingState != State.Idle) stop()
currentVoiceBroadcast = voiceBroadcast currentVoiceBroadcast = voiceBroadcast
playingState = State.BUFFERING playingState = State.Buffering
observeVoiceBroadcastStateEvent(voiceBroadcast) observeVoiceBroadcastStateEvent(voiceBroadcast)
} }
@ -175,13 +175,13 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun onPlaylistUpdated() { private fun onPlaylistUpdated() {
when (playingState) { when (playingState) {
State.PLAYING, State.Playing,
State.PAUSED -> { State.Paused -> {
if (nextMediaPlayer == null && !isPreparingNextPlayer) { if (nextMediaPlayer == null && !isPreparingNextPlayer) {
prepareNextMediaPlayer() prepareNextMediaPlayer()
} }
} }
State.BUFFERING -> { State.Buffering -> {
val nextItem = if (isLiveListening && playlist.currentSequence == null) { val nextItem = if (isLiveListening && playlist.currentSequence == null) {
// live listening, jump to the last item if playback has not started // live listening, jump to the last item if playback has not started
playlist.lastOrNull() playlist.lastOrNull()
@ -193,7 +193,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
startPlayback(nextItem.startTime) startPlayback(nextItem.startTime)
} }
} }
State.IDLE -> Unit // Should not happen is State.Error -> Unit
State.Idle -> Unit // Should not happen
} }
} }
@ -213,18 +214,17 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
if (sequencePosition > 0) { if (sequencePosition > 0) {
mp.seekTo(sequencePosition) mp.seekTo(sequencePosition)
} }
playingState = State.PLAYING playingState = State.Playing
prepareNextMediaPlayer() prepareNextMediaPlayer()
} }
} catch (failure: Throwable) { } catch (failure: VoiceBroadcastFailure.ListeningError.DownloadError) {
Timber.e(failure, "## Voice Broadcast | Unable to start playback: $failure") playingState = State.Error(failure)
throw VoiceFailure.UnableToPlay(failure)
} }
} }
} }
private fun pausePlayback() { private fun pausePlayback() {
playingState = State.PAUSED // This will trigger a playing state update and save the current position playingState = State.Paused // This will trigger a playing state update and save the current position
if (currentMediaPlayer != null) { if (currentMediaPlayer != null) {
currentMediaPlayer?.pause() currentMediaPlayer?.pause()
} else { } else {
@ -234,7 +234,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun resumePlayback() { private fun resumePlayback() {
if (currentMediaPlayer != null) { if (currentMediaPlayer != null) {
playingState = State.PLAYING playingState = State.Playing
currentMediaPlayer?.start() currentMediaPlayer?.start()
} else { } else {
val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0 val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0
@ -247,11 +247,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
voiceBroadcast != currentVoiceBroadcast -> { voiceBroadcast != currentVoiceBroadcast -> {
playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
} }
playingState == State.PLAYING || playingState == State.BUFFERING -> { playingState == State.Playing || playingState == State.Buffering -> {
updateLiveListeningMode(positionMillis) updateLiveListeningMode(positionMillis)
startPlayback(positionMillis) startPlayback(positionMillis)
} }
playingState == State.IDLE || playingState == State.PAUSED -> { playingState == State.Idle || playingState == State.Paused -> {
stopPlayer() stopPlayer()
playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
} }
@ -263,19 +263,29 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
if (nextItem != null) { if (nextItem != null) {
isPreparingNextPlayer = true isPreparingNextPlayer = true
sessionScope.launch { sessionScope.launch {
try {
prepareMediaPlayer(nextItem.audioEvent.content) { mp -> prepareMediaPlayer(nextItem.audioEvent.content) { mp ->
isPreparingNextPlayer = false isPreparingNextPlayer = false
nextMediaPlayer = mp nextMediaPlayer = mp
when (playingState) { when (playingState) {
State.PLAYING, State.Playing,
State.PAUSED -> { State.Paused -> {
currentMediaPlayer?.setNextMediaPlayer(mp) currentMediaPlayer?.setNextMediaPlayer(mp)
} }
State.BUFFERING -> { State.Buffering -> {
mp.start() mp.start()
onNextMediaPlayerStarted(mp) onNextMediaPlayerStarted(mp)
} }
State.IDLE -> stopPlayer() is State.Error,
State.Idle -> stopPlayer()
}
}
} catch (failure: VoiceBroadcastFailure.ListeningError.DownloadError) {
isPreparingNextPlayer = false
// Do not change the playingState if the current player is still valid,
// the error will be thrown again when switching to the next player
if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) {
playingState = State.Error(failure)
} }
} }
} }
@ -288,11 +298,12 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
session.fileService().downloadFile(messageAudioContent) session.fileService().downloadFile(messageAudioContent)
} catch (failure: Throwable) { } catch (failure: Throwable) {
Timber.e(failure, "Voice Broadcast | Download has failed: $failure") Timber.e(failure, "Voice Broadcast | Download has failed: $failure")
throw VoiceFailure.UnableToPlay(failure) throw VoiceBroadcastFailure.ListeningError.DownloadError(failure)
} }
return audioFile.inputStream().use { fis -> return audioFile.inputStream().use { fis ->
MediaPlayer().apply { MediaPlayer().apply {
setOnErrorListener(mediaPlayerListener)
setAudioAttributes( setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
// Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here // Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here
@ -302,10 +313,9 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
) )
setDataSource(fis.fd) setDataSource(fis.fd)
setOnInfoListener(mediaPlayerListener) setOnInfoListener(mediaPlayerListener)
setOnErrorListener(mediaPlayerListener)
setOnPreparedListener(onPreparedListener) setOnPreparedListener(onPreparedListener)
setOnCompletionListener(mediaPlayerListener) setOnCompletionListener(mediaPlayerListener)
prepare() prepareAsync()
} }
} }
} }
@ -327,11 +337,18 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId -> currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId ->
// Start or stop playback ticker // Start or stop playback ticker
when (playingState) { when (playingState) {
State.PLAYING -> playbackTicker.startPlaybackTicker(voiceBroadcastId) State.Playing -> playbackTicker.startPlaybackTicker(voiceBroadcastId)
State.PAUSED, State.Paused,
State.BUFFERING, State.Buffering,
State.IDLE -> playbackTicker.stopPlaybackTicker(voiceBroadcastId) is State.Error,
State.Idle -> playbackTicker.stopPlaybackTicker(voiceBroadcastId)
} }
// Notify playback tracker about error
if (playingState is State.Error) {
playbackTracker.onError(voiceBroadcastId, playingState.failure)
}
// Notify state change to all the listeners attached to the current voice broadcast id // Notify state change to all the listeners attached to the current voice broadcast id
listeners[voiceBroadcastId]?.forEach { listener -> listener.onPlayingStateChanged(playingState) } listeners[voiceBroadcastId]?.forEach { listener -> listener.onPlayingStateChanged(playingState) }
} }
@ -348,7 +365,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
// the current voice broadcast is not live (ended) // the current voice broadcast is not live (ended)
mostRecentVoiceBroadcastEvent?.isLive != true -> false mostRecentVoiceBroadcastEvent?.isLive != true -> false
// the player is stopped or paused // the player is stopped or paused
playingState == State.IDLE || playingState == State.PAUSED -> false playingState == State.Idle || playingState == State.Paused -> false
seekPosition != null -> { seekPosition != null -> {
val seekDirection = seekPosition.compareTo(getCurrentPlaybackPosition() ?: 0) val seekDirection = seekPosition.compareTo(getCurrentPlaybackPosition() ?: 0)
val newSequence = playlist.findByPosition(seekPosition)?.sequence val newSequence = playlist.findByPosition(seekPosition)?.sequence
@ -374,13 +391,14 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun onLiveListeningChanged(isLiveListening: Boolean) { private fun onLiveListeningChanged(isLiveListening: Boolean) {
// Live has ended and last chunk has been reached, we can stop the playback // Live has ended and last chunk has been reached, we can stop the playback
if (!isLiveListening && playingState == State.BUFFERING && playlist.currentSequence == mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence) { val hasReachedLastChunk = playlist.currentSequence == mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence
if (!isLiveListening && playingState == State.Buffering && hasReachedLastChunk) {
stop() stop()
} }
} }
private fun onNextMediaPlayerStarted(mp: MediaPlayer) { private fun onNextMediaPlayerStarted(mp: MediaPlayer) {
playingState = State.PLAYING playingState = State.Playing
playlist.currentSequence = playlist.currentSequence?.inc() playlist.currentSequence = playlist.currentSequence?.inc()
currentMediaPlayer = mp currentMediaPlayer = mp
nextMediaPlayer = null nextMediaPlayer = null
@ -389,16 +407,16 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun getCurrentPlaybackPosition(): Int? { private fun getCurrentPlaybackPosition(): Int? {
val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null
val computedPosition = currentMediaPlayer?.currentPosition?.let { playlist.currentItem?.startTime?.plus(it) } val computedPosition = tryOrNull { currentMediaPlayer?.currentPosition }?.let { playlist.currentItem?.startTime?.plus(it) }
val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId) val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId)
return computedPosition ?: savedPosition return computedPosition ?: savedPosition
} }
private fun getCurrentPlaybackPercentage(): Float? { private fun getCurrentPlaybackPercentage(): Float? {
val playlistPosition = playlist.currentItem?.startTime val playlistPosition = playlist.currentItem?.startTime
val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition val computedPosition = tryOrNull { currentMediaPlayer?.currentPosition }?.let { playlistPosition?.plus(it) } ?: playlistPosition
val duration = playlist.duration.takeIf { it > 0 } val duration = playlist.duration
val computedPercentage = if (computedPosition != null && duration != null) computedPosition.toFloat() / duration else null val computedPercentage = if (computedPosition != null && duration > 0) computedPosition.toFloat() / duration else null
val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) } val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) }
return computedPercentage ?: savedPercentage return computedPercentage ?: savedPercentage
} }
@ -416,6 +434,14 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
override fun onCompletion(mp: MediaPlayer) { override fun onCompletion(mp: MediaPlayer) {
// Release media player as soon as it completed
mp.release()
if (currentMediaPlayer == mp) {
currentMediaPlayer = null
} else {
error("The media player which has completed mismatches the current media player instance.")
}
// Next media player is already attached to this player and will start playing automatically // Next media player is already attached to this player and will start playing automatically
if (nextMediaPlayer != null) return if (nextMediaPlayer != null) return
@ -426,15 +452,18 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
// We'll not receive new chunks anymore so we can stop the live listening // We'll not receive new chunks anymore so we can stop the live listening
stop() stop()
} else { } else {
// Enter in buffering mode and release current media player playingState = State.Buffering
playingState = State.BUFFERING prepareNextMediaPlayer()
currentMediaPlayer?.release()
currentMediaPlayer = null
} }
} }
override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean {
stop() Timber.d("## Voice Broadcast | onError: what=$what, extra=$extra")
// Do not change the playingState if the current player is still valid,
// the error will be thrown again when switching to the next player
if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) {
playingState = State.Error(VoiceBroadcastFailure.ListeningError.UnableToPlay(what, extra))
}
return true return true
} }
} }
@ -462,24 +491,25 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
val playbackTime = getCurrentPlaybackPosition() val playbackTime = getCurrentPlaybackPosition()
val percentage = getCurrentPlaybackPercentage() val percentage = getCurrentPlaybackPercentage()
when (playingState) { when (playingState) {
State.PLAYING -> { State.Playing -> {
if (playbackTime != null && percentage != null) { if (playbackTime != null && percentage != null) {
playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage)
} }
} }
State.PAUSED, State.Paused,
State.BUFFERING -> { State.Buffering -> {
if (playbackTime != null && percentage != null) { if (playbackTime != null && percentage != null) {
playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage)
} }
} }
State.IDLE -> { State.Idle -> {
if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) { if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) {
playbackTracker.stopPlayback(id) playbackTracker.stopPlayback(id)
} else { } else {
playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage)
} }
} }
is State.Error -> Unit
} }
} }
} }

View File

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

View File

@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.transformWhile
import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session 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.getRoom
import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
@ -44,6 +43,7 @@ import javax.inject.Inject
class GetVoiceBroadcastStateEventLiveUseCase @Inject constructor( class GetVoiceBroadcastStateEventLiveUseCase @Inject constructor(
private val session: Session, private val session: Session,
private val getVoiceBroadcastStateEventUseCase: GetVoiceBroadcastStateEventUseCase,
) { ) {
fun execute(voiceBroadcast: VoiceBroadcast): Flow<Optional<VoiceBroadcastEvent>> { fun execute(voiceBroadcast: VoiceBroadcast): Flow<Optional<VoiceBroadcastEvent>> {
@ -93,7 +93,7 @@ class GetVoiceBroadcastStateEventLiveUseCase @Inject constructor(
* Get a flow of the most recent related event. * Get a flow of the most recent related event.
*/ */
private fun getMostRecentRelatedEventFlow(room: Room, voiceBroadcast: VoiceBroadcast): Flow<Optional<VoiceBroadcastEvent>> { 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()) { return if (mostRecentEvent.hasValue()) {
val stateKey = mostRecentEvent.get().root.stateKey.orEmpty() val stateKey = mostRecentEvent.get().root.stateKey.orEmpty()
// observe incoming voice broadcast state events // 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. * 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

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,16C12.418,16 16,12.418 16,8C16,3.582 12.418,0 8,0C3.582,0 0,3.582 0,8C0,12.418 3.582,16 8,16ZM6.777,4.135C6.717,3.451 7.221,2.851 7.905,2.803C8.577,2.755 9.177,3.259 9.249,3.943V4.135L8.865,8.935C8.829,9.379 8.457,9.715 8.013,9.715H7.941C7.521,9.679 7.197,9.355 7.161,8.935L6.777,4.135ZM9.056,12.067C9.056,12.651 8.583,13.123 8,13.123C7.417,13.123 6.944,12.651 6.944,12.067C6.944,11.484 7.417,11.011 8,11.011C8.583,11.011 9.056,11.484 9.056,12.067Z"
android:fillColor="#FF5B55"
android:fillType="evenOdd" />
</vector>

View File

@ -17,6 +17,34 @@
tools:itemCount="5" tools:itemCount="5"
tools:listitem="@layout/item_poll" /> tools:listitem="@layout/item_poll" />
<ProgressBar
android:id="@+id/roomPollsSyncingProgress"
style="?android:attr/progressBarStyle"
android:layout_width="16dp"
android:layout_height="16dp"
android:indeterminateTint="?vctr_content_secondary"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/roomPollsSyncingTitle"
app:layout_constraintEnd_toStartOf="@id/roomPollsSyncingTitle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/roomPollsSyncingTitle" />
<TextView
android:id="@+id/roomPollsSyncingTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="9dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:gravity="center"
android:text="@string/room_polls_wait_for_display"
android:textAppearance="@style/TextAppearance.Vector.Body"
android:textColor="?vctr_content_secondary"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/roomPollsSyncingProgress"
app:layout_constraintTop_toTopOf="@id/roomPollsTitleGuideline" />
<TextView <TextView
android:id="@+id/roomPollsEmptyTitle" android:id="@+id/roomPollsEmptyTitle"
android:layout_width="0dp" android:layout_width="0dp"
@ -26,14 +54,39 @@
android:gravity="center" android:gravity="center"
android:textAppearance="@style/TextAppearance.Vector.Body" android:textAppearance="@style/TextAppearance.Vector.Body"
android:textColor="?vctr_content_secondary" android:textColor="?vctr_content_secondary"
android:textSize="17sp"
android:visibility="gone" android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/roomPollsEmptyGuideline" app:layout_constraintTop_toTopOf="@id/roomPollsTitleGuideline"
tools:text="@string/room_polls_active_no_item" /> tools:text="@string/room_polls_active_no_item" />
<Button
android:id="@+id/roomPollsLoadMoreWhenEmpty"
style="@style/Widget.Vector.Button.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/layout_horizontal_margin"
android:layout_marginTop="8dp"
android:text="@string/room_polls_load_more"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomPollsEmptyTitle" />
<ProgressBar
android:id="@+id/roomPollsLoadMoreWhenEmptyProgress"
style="?android:attr/progressBarStyle"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="9dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/roomPollsLoadMoreWhenEmpty"
app:layout_constraintStart_toEndOf="@id/roomPollsLoadMoreWhenEmpty"
app:layout_constraintTop_toTopOf="@id/roomPollsLoadMoreWhenEmpty" />
<androidx.constraintlayout.widget.Guideline <androidx.constraintlayout.widget.Guideline
android:id="@+id/roomPollsEmptyGuideline" android:id="@+id/roomPollsTitleGuideline"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"

View File

@ -85,6 +85,7 @@
app:layout_constraintCircle="@id/breadcrumbsImageView" app:layout_constraintCircle="@id/breadcrumbsImageView"
app:layout_constraintCircleAngle="225" app:layout_constraintCircleAngle="225"
app:layout_constraintCircleRadius="28dp" app:layout_constraintCircleRadius="28dp"
app:tint="?vctr_content_primary"
tools:ignore="MissingConstraints" tools:ignore="MissingConstraints"
tools:visibility="visible" /> tools:visibility="visible" />

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/roomPollsLoadMore"
style="@style/Widget.Vector.Button.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="38dp"
android:layout_marginBottom="46dp"
android:padding="0dp"
android:text="@string/room_polls_load_more"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/roomPollsLoadMoreProgress"
style="?android:attr/progressBarStyle"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="9dp"
app:layout_constraintBottom_toBottomOf="@id/roomPollsLoadMore"
app:layout_constraintStart_toEndOf="@id/roomPollsLoadMore"
app:layout_constraintTop_toTopOf="@id/roomPollsLoadMore" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -176,4 +176,27 @@
tools:ignore="NegativeMargin" tools:ignore="NegativeMargin"
tools:text="-0:12" /> tools:text="-0:12" />
<androidx.constraintlayout.widget.Group
android:id="@+id/controlsGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="controllerButtonsFlow,seekBar,elapsedTime,remainingTime" />
<TextView
android:id="@+id/errorView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:drawablePadding="4dp"
android:text="@string/error_voice_broadcast_unable_to_play"
android:textColor="?colorError"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_voice_broadcast_error"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBottomBarrier"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -131,6 +131,24 @@ class PollItemViewStateFactoryTest {
) )
} }
@Test
fun `given a sent poll state with some decryption error when poll is closed then warning message is displayed`() {
// Given
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true, hasEncryptedRelatedEvents = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = closedPollInformationData,
)
// Then
pollViewState.votesStatus shouldBeEqualTo stringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll)
}
@Test @Test
fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() { fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() {
val stringProvider = FakeStringProvider() val stringProvider = FakeStringProvider()
@ -193,6 +211,34 @@ class PollItemViewStateFactoryTest {
) )
} }
@Test
fun `given a sent poll with decryption failure when my vote exists then a warning message is displayed`() {
// Given
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val votedPollData = A_POLL_RESPONSE_DATA.copy(
totalVotes = 1,
myVote = A_POLL_OPTION_IDS[0],
votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0)),
hasEncryptedRelatedEvents = true,
)
val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
kind = PollType.DISCLOSED_UNSTABLE
),
)
val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData)
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent,
informationData = votedInformationData,
)
// Then
pollViewState.votesStatus shouldBeEqualTo stringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll)
}
@Test @Test
fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() { fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() {
val stringProvider = FakeStringProvider() val stringProvider = FakeStringProvider()

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

@ -17,8 +17,17 @@
package im.vector.app.features.roomprofile.polls package im.vector.app.features.roomprofile.polls
import com.airbnb.mvrx.test.MavericksTestRule import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase
import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import im.vector.app.test.test import im.vector.app.test.test
import im.vector.app.test.testDispatcher import im.vector.app.test.testDispatcher
import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
@ -26,7 +35,7 @@ import kotlinx.coroutines.flow.flowOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
private const val ROOM_ID = "room-id" private const val A_ROOM_ID = "room-id"
class RoomPollsViewModelTest { class RoomPollsViewModelTest {
@ -34,21 +43,33 @@ class RoomPollsViewModelTest {
val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher) val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher)
private val fakeGetPollsUseCase = mockk<GetPollsUseCase>() private val fakeGetPollsUseCase = mockk<GetPollsUseCase>()
private val initialState = RoomPollsViewState(ROOM_ID) private val fakeGetLoadedPollsStatusUseCase = mockk<GetLoadedPollsStatusUseCase>()
private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>()
private val fakeSyncPollsUseCase = mockk<SyncPollsUseCase>()
private val initialState = RoomPollsViewState(A_ROOM_ID)
private fun createViewModel(): RoomPollsViewModel { private fun createViewModel(): RoomPollsViewModel {
return RoomPollsViewModel( return RoomPollsViewModel(
initialState = initialState, initialState = initialState,
getPollsUseCase = fakeGetPollsUseCase, getPollsUseCase = fakeGetPollsUseCase,
getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase,
loadMorePollsUseCase = fakeLoadMorePollsUseCase,
syncPollsUseCase = fakeSyncPollsUseCase,
) )
} }
@Test @Test
fun `given viewModel when created then polls list is observed and viewState is updated`() { fun `given viewModel when created then polls list is observed, sync is launched and viewState is updated`() {
// Given // Given
val loadedPollsStatus = givenGetLoadedPollsStatusSuccess()
givenSyncPollsWithSuccess()
val polls = listOf(givenAPollSummary()) val polls = listOf(givenAPollSummary())
every { fakeGetPollsUseCase.execute() } returns flowOf(polls) every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
val expectedViewState = initialState.copy(polls = polls) val expectedViewState = initialState.copy(
polls = polls,
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays,
)
// When // When
val viewModel = createViewModel() val viewModel = createViewModel()
@ -59,11 +80,88 @@ class RoomPollsViewModelTest {
.assertLatestState(expectedViewState) .assertLatestState(expectedViewState)
.finish() .finish()
verify { verify {
fakeGetPollsUseCase.execute() fakeGetPollsUseCase.execute(A_ROOM_ID)
} }
coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
}
@Test
fun `given viewModel and error during sync process when created then error is raised in view event`() {
// Given
givenGetLoadedPollsStatusSuccess()
givenSyncPollsWithError(Exception())
val polls = listOf(givenAPollSummary())
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
// Then
viewModelTest
.assertEvents(RoomPollsViewEvent.LoadingError)
.finish()
coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
}
@Test
fun `given viewModel when handle load more action then viewState is updated`() {
// Given
val loadedPollsStatus = givenGetLoadedPollsStatusSuccess()
givenSyncPollsWithSuccess()
val polls = listOf(givenAPollSummary())
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
val newLoadedPollsStatus = givenLoadMoreWithSuccess()
val viewModel = createViewModel()
val stateAfterInit = initialState.copy(
polls = polls,
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays,
)
// When
val viewModelTest = viewModel.test()
viewModel.handle(RoomPollsAction.LoadMorePolls)
// Then
viewModelTest
.assertStatesChanges(
stateAfterInit,
{ copy(isLoadingMore = true) },
{ copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbLoadedDays = newLoadedPollsStatus.nbLoadedDays) },
{ copy(isLoadingMore = false) },
)
.finish()
coVerify { fakeLoadMorePollsUseCase.execute(A_ROOM_ID) }
} }
private fun givenAPollSummary(): PollSummary { private fun givenAPollSummary(): PollSummary {
return mockk() return mockk()
} }
private fun givenSyncPollsWithSuccess() {
coJustRun { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
}
private fun givenSyncPollsWithError(error: Exception) {
coEvery { fakeSyncPollsUseCase.execute(A_ROOM_ID) } throws error
}
private fun givenLoadMoreWithSuccess(): LoadedPollsStatus {
val loadedPollsStatus = givenALoadedPollsStatus(canLoadMore = false, nbLoadedDays = 20)
coEvery { fakeLoadMorePollsUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus
}
private fun givenGetLoadedPollsStatusSuccess(): LoadedPollsStatus {
val loadedPollsStatus = givenALoadedPollsStatus()
every { fakeGetLoadedPollsStatusUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus
}
private fun givenALoadedPollsStatus(canLoadMore: Boolean = true, nbLoadedDays: Int = 10) =
LoadedPollsStatus(
canLoadMore = canLoadMore,
nbLoadedDays = nbLoadedDays,
)
} }

View File

@ -0,0 +1,95 @@
/*
* 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.roomprofile.polls.list.data
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private const val A_ROOM_ID = "room-id"
class RoomPollRepositoryTest {
private val fakeRoomPollDataSource = mockk<RoomPollDataSource>()
private val roomPollRepository = RoomPollRepository(
roomPollDataSource = fakeRoomPollDataSource,
)
@Test
fun `given data source when getting polls then correct method of data source is called`() = runTest {
// Given
val expectedPolls = listOf<PollSummary>()
every { fakeRoomPollDataSource.getPolls(A_ROOM_ID) } returns flowOf(expectedPolls)
// When
val result = roomPollRepository.getPolls(A_ROOM_ID).firstOrNull()
// Then
result shouldBeEqualTo expectedPolls
verify { fakeRoomPollDataSource.getPolls(A_ROOM_ID) }
}
@Test
fun `given data source when getting loaded polls status then correct method of data source is called`() {
// Given
val expectedStatus = LoadedPollsStatus(
canLoadMore = true,
nbLoadedDays = 10,
)
every { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) } returns expectedStatus
// When
val result = roomPollRepository.getLoadedPollsStatus(A_ROOM_ID)
// Then
result shouldBeEqualTo expectedStatus
verify { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) }
}
@Test
fun `given data source when loading more polls then correct method of data source is called`() = runTest {
// Given
coJustRun { fakeRoomPollDataSource.loadMorePolls(A_ROOM_ID) }
// When
roomPollRepository.loadMorePolls(A_ROOM_ID)
// Then
coVerify { fakeRoomPollDataSource.loadMorePolls(A_ROOM_ID) }
}
@Test
fun `given data source when syncing polls then correct method of data source is called`() = runTest {
// Given
coJustRun { fakeRoomPollDataSource.syncPolls(A_ROOM_ID) }
// When
roomPollRepository.syncPolls(A_ROOM_ID)
// Then
coVerify { fakeRoomPollDataSource.syncPolls(A_ROOM_ID) }
}
}

View File

@ -0,0 +1,52 @@
/*
* 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.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
class GetLoadedPollsStatusUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val getLoadedPollsStatusUseCase = GetLoadedPollsStatusUseCase(
roomPollRepository = fakeRoomPollRepository,
)
@Test
fun `given repo when execute then correct method of repo is called`() {
// Given
val aRoomId = "roomId"
val expectedStatus = LoadedPollsStatus(
canLoadMore = true,
nbLoadedDays = 10,
)
every { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus
// When
val status = getLoadedPollsStatusUseCase.execute(aRoomId)
// Then
status shouldBeEqualTo expectedStatus
verify { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) }
}
}

View File

@ -0,0 +1,63 @@
/*
* 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.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import im.vector.app.test.fixtures.RoomPollFixture
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
class GetPollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val getPollsUseCase = GetPollsUseCase(
roomPollRepository = fakeRoomPollRepository,
)
@Test
fun `given repo when execute then correct method of repo is called and polls are sorted most recent first`() = runTest {
// Given
val aRoomId = "roomId"
val poll1 = RoomPollFixture.anActivePollSummary(timestamp = 1)
val poll2 = RoomPollFixture.anActivePollSummary(timestamp = 2)
val poll3 = RoomPollFixture.anActivePollSummary(timestamp = 3)
val polls = listOf<PollSummary>(
poll1,
poll2,
poll3,
)
every { fakeRoomPollRepository.getPolls(aRoomId) } returns flowOf(polls)
val expectedPolls = listOf<PollSummary>(
poll3,
poll2,
poll1,
)
// When
val result = getPollsUseCase.execute(aRoomId).first()
// Then
result shouldBeEqualTo expectedPolls
verify { fakeRoomPollRepository.getPolls(aRoomId) }
}
}

View File

@ -0,0 +1,46 @@
/*
* 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.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LoadMorePollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val loadMorePollsUseCase = LoadMorePollsUseCase(
roomPollRepository = fakeRoomPollRepository,
)
@Test
fun `given repo when execute then correct method of repo is called`() = runTest {
// Given
val aRoomId = "roomId"
coJustRun { fakeRoomPollRepository.loadMorePolls(aRoomId) }
// When
loadMorePollsUseCase.execute(aRoomId)
// Then
coVerify { fakeRoomPollRepository.loadMorePolls(aRoomId) }
}
}

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