Merge branch 'develop' into feature/aris/threads

# Conflicts:
#	matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxRoom.kt
#	matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt
#	vector/src/main/java/im/vector/app/features/command/Command.kt
#	vector/src/main/java/im/vector/app/features/command/CommandParser.kt
#	vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt
This commit is contained in:
ariskotsomitopoulos 2022-01-18 12:41:40 +02:00
commit 636474b748
169 changed files with 1327 additions and 1014 deletions

View file

@ -61,8 +61,9 @@ Supported filename extensions are:
- ``.feature``: Signifying a new feature in Element Android or in the Matrix SDK. - ``.feature``: Signifying a new feature in Element Android or in the Matrix SDK.
- ``.bugfix``: Signifying a bug fix. - ``.bugfix``: Signifying a bug fix.
- ``.wip``: Signifying a work in progress change, typically a component of a larger feature which will be enabled once all tasks are complete.
- ``.doc``: Signifying a documentation improvement. - ``.doc``: Signifying a documentation improvement.
- ``.removal``: Signifying a deprecation or removal of public API. Can be used to notifying about API change in the Matrix SDK - ``.sdk``: Signifying a change to the Matrix SDK, this could be an addition, deprecation or removal of a public API.
- ``.misc``: Any other changes. - ``.misc``: Any other changes.
See https://github.com/twisted/towncrier#news-fragments if you need more details. See https://github.com/twisted/towncrier#news-fragments if you need more details.

View file

@ -47,12 +47,10 @@ android {
dependencies { dependencies {
implementation project(":library:ui-styles") implementation project(":library:ui-styles")
implementation project(":library:core-utils")
implementation 'com.github.chrisbanes:PhotoView:2.3.0' implementation 'com.github.chrisbanes:PhotoView:2.3.0'
implementation libs.rx.rxKotlin
implementation libs.rx.rxAndroid
implementation libs.androidx.core implementation libs.androidx.core
implementation libs.androidx.appCompat implementation libs.androidx.appCompat
implementation libs.androidx.recyclerview implementation libs.androidx.recyclerview

View file

@ -20,12 +20,9 @@ import android.util.Log
import android.view.View import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import im.vector.lib.attachmentviewer.databinding.ItemVideoAttachmentBinding import im.vector.lib.attachmentviewer.databinding.ItemVideoAttachmentBinding
import io.reactivex.Observable import im.vector.lib.core.utils.timer.CountUpTimer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import java.io.File import java.io.File
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.concurrent.TimeUnit
// TODO, it would be probably better to use a unique media player // TODO, it would be probably better to use a unique media player
// for better customization and control // for better customization and control
@ -35,7 +32,7 @@ class VideoViewHolder constructor(itemView: View) :
private var isSelected = false private var isSelected = false
private var mVideoPath: String? = null private var mVideoPath: String? = null
private var progressDisposable: Disposable? = null private var countUpTimer: CountUpTimer? = null
private var progress: Int = 0 private var progress: Int = 0
private var wasPaused = false private var wasPaused = false
@ -47,8 +44,7 @@ class VideoViewHolder constructor(itemView: View) :
override fun onRecycled() { override fun onRecycled() {
super.onRecycled() super.onRecycled()
progressDisposable?.dispose() stopTimer()
progressDisposable = null
mVideoPath = null mVideoPath = null
} }
@ -72,8 +68,7 @@ class VideoViewHolder constructor(itemView: View) :
override fun entersBackground() { override fun entersBackground() {
if (views.videoView.isPlaying) { if (views.videoView.isPlaying) {
progress = views.videoView.currentPosition progress = views.videoView.currentPosition
progressDisposable?.dispose() stopTimer()
progressDisposable = null
views.videoView.stopPlayback() views.videoView.stopPlayback()
views.videoView.pause() views.videoView.pause()
} }
@ -91,8 +86,7 @@ class VideoViewHolder constructor(itemView: View) :
} else { } else {
progress = 0 progress = 0
} }
progressDisposable?.dispose() stopTimer()
progressDisposable = null
} else { } else {
if (mVideoPath != null) { if (mVideoPath != null) {
startPlaying() startPlaying()
@ -107,11 +101,10 @@ class VideoViewHolder constructor(itemView: View) :
views.videoView.isVisible = true views.videoView.isVisible = true
views.videoView.setOnPreparedListener { views.videoView.setOnPreparedListener {
progressDisposable?.dispose() stopTimer()
progressDisposable = Observable.interval(100, TimeUnit.MILLISECONDS) countUpTimer = CountUpTimer(100).also {
.timeInterval() it.tickListener = object : CountUpTimer.TickListener {
.observeOn(AndroidSchedulers.mainThread()) override fun onTick(milliseconds: Long) {
.subscribe {
val duration = views.videoView.duration val duration = views.videoView.duration
val progress = views.videoView.currentPosition val progress = views.videoView.currentPosition
val isPlaying = views.videoView.isPlaying val isPlaying = views.videoView.isPlaying
@ -119,6 +112,9 @@ class VideoViewHolder constructor(itemView: View) :
eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration)) eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration))
} }
} }
it.resume()
}
}
try { try {
views.videoView.setVideoPath(mVideoPath) views.videoView.setVideoPath(mVideoPath)
} catch (failure: Throwable) { } catch (failure: Throwable) {
@ -134,6 +130,11 @@ class VideoViewHolder constructor(itemView: View) :
} }
} }
private fun stopTimer() {
countUpTimer?.stop()
countUpTimer = null
}
override fun handleCommand(commands: AttachmentCommands) { override fun handleCommand(commands: AttachmentCommands) {
if (!isSelected) return if (!isSelected) return
when (commands) { when (commands) {

View file

@ -158,13 +158,3 @@ ext {
// } // }
// } // }
//} //}
//
//project(":matrix-sdk-android-rx") {
// sonarqube {
// properties {
// property "sonar.sources", project(":matrix-sdk-android-rx").android.sourceSets.main.java.srcDirs
// // exclude source code from analyses separated by a colon (:)
// // property "sonar.exclusions", "**/*.*"
// }
// }
//}

1
changelog.d/3932.bugfix Normal file
View file

@ -0,0 +1 @@
Explore Rooms overflow menu - content update include "Create room"

1
changelog.d/4669.bugfix Normal file
View file

@ -0,0 +1 @@
Fix sync timeout after returning from background

1
changelog.d/4811.feature Normal file
View file

@ -0,0 +1 @@
Enabling native support for window resizing

1
changelog.d/4865.misc Normal file
View file

@ -0,0 +1 @@
"/kick" command is replaced with "/remove". Also replaced all occurrences in string resources

1
changelog.d/4880.wip Normal file
View file

@ -0,0 +1 @@
Updates the onboarding carousel images, copy and improves the handling of different device sizes

1
changelog.d/4895.removal Normal file
View file

@ -0,0 +1 @@
`StateService.sendStateEvent()` now takes a non-nullable String for the parameter `stateKey`. If null was used, just now use an empty string.

1
changelog.d/4914.wip Normal file
View file

@ -0,0 +1 @@
Disabling onboarding automatic carousel transitions on user interaction

1
changelog.d/4918.wip Normal file
View file

@ -0,0 +1 @@
Locking phones to portrait during the FTUE onboarding

1
changelog.d/4927.wip Normal file
View file

@ -0,0 +1 @@
Adds a messaging use case screen to the FTUE onboarding

1
changelog.d/4935.bugfix Normal file
View file

@ -0,0 +1 @@
Fix a wrong network error issue in the Legals screen

1
changelog.d/4942.misc Normal file
View file

@ -0,0 +1 @@
Remove unused module matrix-sdk-android-rx and do some cleanup

1
changelog.d/4948.bugfix Normal file
View file

@ -0,0 +1 @@
Prevent Alerts to be displayed in the automatically displayed analytics opt-in screen

1
changelog.d/4960.misc Normal file
View file

@ -0,0 +1 @@
Improves local echo blinking when non room events received

1
changelog.d/516.bugfix Normal file
View file

@ -0,0 +1 @@
Fix for stuck local event messages at the bottom of the screen

View file

@ -42,7 +42,6 @@ ext.libs = [
jetbrains : [ jetbrains : [
'coroutinesCore' : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutines", 'coroutinesCore' : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutines",
'coroutinesAndroid' : "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinCoroutines", 'coroutinesAndroid' : "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinCoroutines",
'coroutinesRx2' : "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$kotlinCoroutines",
'coroutinesTest' : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutines" 'coroutinesTest' : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutines"
], ],
androidx : [ androidx : [
@ -87,8 +86,7 @@ ext.libs = [
'retrofitMoshi' : "com.squareup.retrofit2:converter-moshi:$retrofit" 'retrofitMoshi' : "com.squareup.retrofit2:converter-moshi:$retrofit"
], ],
rx : [ rx : [
'rxKotlin' : "io.reactivex.rxjava2:rxkotlin:2.4.0", 'rxKotlin' : "io.reactivex.rxjava2:rxkotlin:2.4.0"
'rxAndroid' : "io.reactivex.rxjava2:rxandroid:2.1.1"
], ],
arrow : [ arrow : [
'core' : "io.arrow-kt:arrow-core:$arrow", 'core' : "io.arrow-kt:arrow-core:$arrow",

1
library/core-utils/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id 'com.android.library'
id 'kotlin-android'
}
android {
compileSdk versions.compileSdk
defaultConfig {
minSdk versions.minSdk
targetSdk versions.targetSdk
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility versions.sourceCompat
targetCompatibility versions.targetCompat
}
kotlinOptions {
jvmTarget = "11"
freeCompilerArgs += [
"-Xopt-in=kotlin.RequiresOptIn"
]
}
}
dependencies {
implementation libs.androidx.appCompat
implementation libs.jetbrains.coroutinesAndroid
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="im.vector.lib.core.utils" />

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.core.flow package im.vector.lib.core.utils.flow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -85,10 +85,12 @@ fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
} }
} }
@ExperimentalCoroutinesApi
fun tickerFlow(scope: CoroutineScope, delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow<Unit> { fun tickerFlow(scope: CoroutineScope, delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow<Unit> {
return scope.fixedPeriodTicker(delayMillis, initialDelayMillis).consumeAsFlow() return scope.fixedPeriodTicker(delayMillis, initialDelayMillis).consumeAsFlow()
} }
@ExperimentalCoroutinesApi
private fun CoroutineScope.fixedPeriodTicker(delayMillis: Long, initialDelayMillis: Long = delayMillis): ReceiveChannel<Unit> { private fun CoroutineScope.fixedPeriodTicker(delayMillis: Long, initialDelayMillis: Long = delayMillis): ReceiveChannel<Unit> {
require(delayMillis >= 0) { "Expected non-negative delay, but has $delayMillis ms" } require(delayMillis >= 0) { "Expected non-negative delay, but has $delayMillis ms" }
require(initialDelayMillis >= 0) { "Expected non-negative initial delay, but has $initialDelayMillis ms" } require(initialDelayMillis >= 0) { "Expected non-negative initial delay, but has $initialDelayMillis ms" }

View file

@ -14,9 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.core.utils package im.vector.lib.core.utils.timer
import im.vector.app.core.flow.tickerFlow import im.vector.lib.core.utils.flow.tickerFlow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.onEach
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class CountUpTimer(private val intervalInMs: Long = 1_000) { class CountUpTimer(private val intervalInMs: Long = 1_000) {
private val coroutineScope = CoroutineScope(Dispatchers.Main) private val coroutineScope = CoroutineScope(Dispatchers.Main)

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:endColor="?vctr_system"
android:startColor="#000000" />
</shape>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="ftue_auth_carousel_item_spacing" format="float" type="dimen">0.05</item>
<item name="ftue_auth_carousel_item_image_height" format="float" type="dimen">0.40</item>
</resources>

View file

@ -2,5 +2,6 @@
<resources> <resources>
<dimen name="width_percent">0.6</dimen> <dimen name="width_percent">0.6</dimen>
<bool name="is_tablet">true</bool>
</resources> </resources>

View file

@ -55,4 +55,7 @@
<!-- Onboarding --> <!-- Onboarding -->
<item name="ftue_auth_gutter_start_percent" format="float" type="dimen">0.05</item> <item name="ftue_auth_gutter_start_percent" format="float" type="dimen">0.05</item>
<item name="ftue_auth_gutter_end_percent" format="float" type="dimen">0.95</item> <item name="ftue_auth_gutter_end_percent" format="float" type="dimen">0.95</item>
<item name="ftue_auth_carousel_item_spacing" format="float" type="dimen">0.01</item>
<item name="ftue_auth_carousel_item_image_height" format="float" type="dimen">0.35</item>
</resources> </resources>

View file

@ -2,5 +2,6 @@
<resources> <resources>
<dimen name="width_percent">1</dimen> <dimen name="width_percent">1</dimen>
<bool name="is_tablet">false</bool>
</resources> </resources>

View file

@ -1 +0,0 @@
/build

View file

@ -1,47 +0,0 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
android {
compileSdk versions.compileSdk
defaultConfig {
minSdk versions.minSdk
targetSdk versions.targetSdk
// Multidex is useful for tests
multiDexEnabled true
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility versions.sourceCompat
targetCompatibility versions.targetCompat
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
implementation project(":matrix-sdk-android")
implementation libs.androidx.appCompat
implementation libs.rx.rxKotlin
implementation libs.rx.rxAndroid
implementation libs.jetbrains.coroutinesRx2
// Paging
implementation libs.androidx.pagingRuntimeKtx
// Logging
implementation libs.jakewharton.timber
}

View file

@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -1 +0,0 @@
<manifest package="org.matrix.android.sdk.rx" />

View file

@ -1,71 +0,0 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.rx
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import io.reactivex.Observable
import io.reactivex.android.MainThreadDisposable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
private class LiveDataObservable<T>(
private val liveData: LiveData<T>,
private val valueIfNull: T? = null
) : Observable<T>() {
override fun subscribeActual(observer: io.reactivex.Observer<in T>) {
val relay = RemoveObserverInMainThread(observer)
observer.onSubscribe(relay)
liveData.observeForever(relay)
}
private inner class RemoveObserverInMainThread(private val observer: io.reactivex.Observer<in T>) :
MainThreadDisposable(), Observer<T> {
override fun onChanged(t: T?) {
if (!isDisposed) {
if (t == null) {
if (valueIfNull != null) {
observer.onNext(valueIfNull)
} else {
observer.onError(NullPointerException(
"convert liveData value t to RxJava onNext(t), t cannot be null"))
}
} else {
observer.onNext(t)
}
}
}
override fun onDispose() {
liveData.removeObserver(this)
}
}
}
fun <T> LiveData<T>.asObservable(): Observable<T> {
return LiveDataObservable(this).observeOn(Schedulers.computation())
}
internal fun <T> Observable<T>.startWithCallable(supplier: () -> T): Observable<T> {
val startObservable = Observable
.fromCallable(supplier)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
return startWith(startObservable)
}

View file

@ -1,30 +0,0 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.rx
import io.reactivex.Observable
import org.matrix.android.sdk.api.util.Optional
fun <T : Any> Observable<Optional<T>>.unwrap(): Observable<T> {
return filter { it.hasValue() }.map { it.get() }
}
fun <T : Any, U : Any> Observable<Optional<T>>.mapOptional(fn: (T) -> U?): Observable<Optional<U>> {
return map {
it.map(fn)
}
}

View file

@ -1,161 +0,0 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.rx
import android.net.Uri
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.Single
import kotlinx.coroutines.rx2.rxCompletable
import kotlinx.coroutines.rx2.rxSingle
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.session.room.members.RoomMemberQueryParams
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.GuestAccess
import org.matrix.android.sdk.api.session.room.model.ReadReceipt
import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility
import org.matrix.android.sdk.api.session.room.model.RoomJoinRules
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState
import org.matrix.android.sdk.api.session.room.send.UserDraft
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
class RxRoom(private val room: Room) {
fun liveRoomSummary(): Observable<Optional<RoomSummary>> {
return room.getRoomSummaryLive()
.asObservable()
.startWithCallable { room.roomSummary().toOptional() }
}
fun liveRoomMembers(queryParams: RoomMemberQueryParams): Observable<List<RoomMemberSummary>> {
return room.getRoomMembersLive(queryParams).asObservable()
.startWithCallable {
room.getRoomMembers(queryParams)
}
}
fun liveAnnotationSummary(eventId: String): Observable<Optional<EventAnnotationsSummary>> {
return room.getEventAnnotationsSummaryLive(eventId).asObservable()
.startWithCallable {
room.getEventAnnotationsSummary(eventId).toOptional()
}
}
fun liveTimelineEvent(eventId: String): Observable<Optional<TimelineEvent>> {
return room.getTimeLineEventLive(eventId).asObservable()
.startWithCallable {
room.getTimeLineEvent(eventId).toOptional()
}
}
fun liveStateEvent(eventType: String, stateKey: QueryStringValue): Observable<Optional<Event>> {
return room.getStateEventLive(eventType, stateKey).asObservable()
.startWithCallable {
room.getStateEvent(eventType, stateKey).toOptional()
}
}
fun liveStateEvents(eventTypes: Set<String>): Observable<List<Event>> {
return room.getStateEventsLive(eventTypes).asObservable()
.startWithCallable {
room.getStateEvents(eventTypes)
}
}
fun liveReadMarker(): Observable<Optional<String>> {
return room.getReadMarkerLive().asObservable()
}
fun liveReadReceipt(): Observable<Optional<String>> {
return room.getMyReadReceiptLive().asObservable()
}
fun loadRoomMembersIfNeeded(): Single<Unit> = rxSingle {
room.loadRoomMembersIfNeeded()
}
fun joinRoom(reason: String? = null,
viaServers: List<String> = emptyList()): Single<Unit> = rxSingle {
room.join(reason, viaServers)
}
fun liveEventReadReceipts(eventId: String): Observable<List<ReadReceipt>> {
return room.getEventReadReceiptsLive(eventId).asObservable()
}
fun liveDraft(): Observable<Optional<UserDraft>> {
return room.getDraftLive().asObservable()
.startWithCallable {
room.getDraft().toOptional()
}
}
fun liveNotificationState(): Observable<RoomNotificationState> {
return room.getLiveRoomNotificationState().asObservable()
}
fun invite(userId: String, reason: String? = null): Completable = rxCompletable {
room.invite(userId, reason)
}
fun invite3pid(threePid: ThreePid): Completable = rxCompletable {
room.invite3pid(threePid)
}
fun updateTopic(topic: String): Completable = rxCompletable {
room.updateTopic(topic)
}
fun updateName(name: String): Completable = rxCompletable {
room.updateName(name)
}
fun updateHistoryReadability(readability: RoomHistoryVisibility): Completable = rxCompletable {
room.updateHistoryReadability(readability)
}
fun updateJoinRule(joinRules: RoomJoinRules?, guestAccess: GuestAccess?): Completable = rxCompletable {
room.updateJoinRule(joinRules, guestAccess)
}
fun updateAvatar(avatarUri: Uri, fileName: String): Completable = rxCompletable {
room.updateAvatar(avatarUri, fileName)
}
fun deleteAvatar(): Completable = rxCompletable {
room.deleteAvatar()
}
fun sendMedia(attachment: ContentAttachmentData, compressBeforeSending: Boolean, roomIds: Set<String>): Completable = rxCompletable {
room.sendMedia(
attachment = attachment,
compressBeforeSending = compressBeforeSending,
roomIds = roomIds)
}
}
fun Room.rx(): RxRoom {
return RxRoom(this)
}

View file

@ -1,251 +0,0 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.rx
import androidx.paging.PagedList
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.functions.Function3
import kotlinx.coroutines.rx2.rxSingle
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.accountdata.UserAccountDataEvent
import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME
import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
import org.matrix.android.sdk.api.session.crypto.crosssigning.MXCrossSigningInfo
import org.matrix.android.sdk.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME
import org.matrix.android.sdk.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
import org.matrix.android.sdk.api.session.group.GroupSummaryQueryParams
import org.matrix.android.sdk.api.session.group.model.GroupSummary
import org.matrix.android.sdk.api.session.identity.FoundThreePid
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.api.session.pushers.Pusher
import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams
import org.matrix.android.sdk.api.session.room.accountdata.RoomAccountDataEvent
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams
import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams
import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.api.session.user.model.User
import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo
import org.matrix.android.sdk.internal.crypto.store.PrivateKeysInfo
import org.matrix.android.sdk.internal.session.room.alias.RoomAliasDescription
class RxSession(private val session: Session) {
fun liveRoomSummaries(queryParams: RoomSummaryQueryParams): Observable<List<RoomSummary>> {
return session.getRoomSummariesLive(queryParams).asObservable()
.startWithCallable {
session.getRoomSummaries(queryParams)
}
}
fun liveGroupSummaries(queryParams: GroupSummaryQueryParams): Observable<List<GroupSummary>> {
return session.getGroupSummariesLive(queryParams).asObservable()
.startWithCallable {
session.getGroupSummaries(queryParams)
}
}
fun liveSpaceSummaries(queryParams: SpaceSummaryQueryParams): Observable<List<RoomSummary>> {
return session.spaceService().getSpaceSummariesLive(queryParams).asObservable()
.startWithCallable {
session.spaceService().getSpaceSummaries(queryParams)
}
}
fun liveBreadcrumbs(queryParams: RoomSummaryQueryParams): Observable<List<RoomSummary>> {
return session.getBreadcrumbsLive(queryParams).asObservable()
.startWithCallable {
session.getBreadcrumbs(queryParams)
}
}
fun liveMyDevicesInfo(): Observable<List<DeviceInfo>> {
return session.cryptoService().getLiveMyDevicesInfo().asObservable()
.startWithCallable {
session.cryptoService().getMyDevicesInfo()
}
}
fun liveSyncState(): Observable<SyncState> {
return session.getSyncStateLive().asObservable()
}
fun livePushers(): Observable<List<Pusher>> {
return session.getPushersLive().asObservable()
}
fun liveUser(userId: String): Observable<Optional<User>> {
return session.getUserLive(userId).asObservable()
.startWithCallable {
session.getUser(userId).toOptional()
}
}
fun liveRoomMember(userId: String, roomId: String): Observable<Optional<RoomMemberSummary>> {
return session.getRoomMemberLive(userId, roomId).asObservable()
.startWithCallable {
session.getRoomMember(userId, roomId).toOptional()
}
}
fun liveUsers(): Observable<List<User>> {
return session.getUsersLive().asObservable()
}
fun liveIgnoredUsers(): Observable<List<User>> {
return session.getIgnoredUsersLive().asObservable()
}
fun livePagedUsers(filter: String? = null, excludedUserIds: Set<String>? = null): Observable<PagedList<User>> {
return session.getPagedUsersLive(filter, excludedUserIds).asObservable()
}
fun liveThreePIds(refreshData: Boolean): Observable<List<ThreePid>> {
return session.getThreePidsLive(refreshData).asObservable()
.startWithCallable { session.getThreePids() }
}
fun livePendingThreePIds(): Observable<List<ThreePid>> {
return session.getPendingThreePidsLive().asObservable()
.startWithCallable { session.getPendingThreePids() }
}
fun createRoom(roomParams: CreateRoomParams): Single<String> = rxSingle {
session.createRoom(roomParams)
}
fun searchUsersDirectory(search: String,
limit: Int,
excludedUserIds: Set<String>): Single<List<User>> = rxSingle {
session.searchUsersDirectory(search, limit, excludedUserIds)
}
fun joinRoom(roomIdOrAlias: String,
reason: String? = null,
viaServers: List<String> = emptyList()): Single<Unit> = rxSingle {
session.joinRoom(roomIdOrAlias, reason, viaServers)
}
fun getRoomIdByAlias(roomAlias: String,
searchOnServer: Boolean): Single<Optional<RoomAliasDescription>> = rxSingle {
session.getRoomIdByAlias(roomAlias, searchOnServer)
}
fun getProfileInfo(userId: String): Single<JsonDict> = rxSingle {
session.getProfile(userId)
}
fun liveUserCryptoDevices(userId: String): Observable<List<CryptoDeviceInfo>> {
return session.cryptoService().getLiveCryptoDeviceInfo(userId).asObservable().startWithCallable {
session.cryptoService().getCryptoDeviceInfo(userId)
}
}
fun liveCrossSigningInfo(userId: String): Observable<Optional<MXCrossSigningInfo>> {
return session.cryptoService().crossSigningService().getLiveCrossSigningKeys(userId).asObservable()
.startWithCallable {
session.cryptoService().crossSigningService().getUserCrossSigningKeys(userId).toOptional()
}
}
fun liveCrossSigningPrivateKeys(): Observable<Optional<PrivateKeysInfo>> {
return session.cryptoService().crossSigningService().getLiveCrossSigningPrivateKeys().asObservable()
.startWithCallable {
session.cryptoService().crossSigningService().getCrossSigningPrivateKeys().toOptional()
}
}
fun liveUserAccountData(types: Set<String>): Observable<List<UserAccountDataEvent>> {
return session.accountDataService().getLiveUserAccountDataEvents(types).asObservable()
.startWithCallable {
session.accountDataService().getUserAccountDataEvents(types)
}
}
fun liveRoomAccountData(types: Set<String>): Observable<List<RoomAccountDataEvent>> {
return session.accountDataService().getLiveRoomAccountDataEvents(types).asObservable()
.startWithCallable {
session.accountDataService().getRoomAccountDataEvents(types)
}
}
fun liveRoomWidgets(
roomId: String,
widgetId: QueryStringValue,
widgetTypes: Set<String>? = null,
excludedTypes: Set<String>? = null
): Observable<List<Widget>> {
return session.widgetService().getRoomWidgetsLive(roomId, widgetId, widgetTypes, excludedTypes).asObservable()
.startWithCallable {
session.widgetService().getRoomWidgets(roomId, widgetId, widgetTypes, excludedTypes)
}
}
fun liveRoomChangeMembershipState(): Observable<Map<String, ChangeMembershipState>> {
return session.getChangeMembershipsLive().asObservable()
}
fun liveSecretSynchronisationInfo(): Observable<SecretsSynchronisationInfo> {
return Observable.combineLatest<List<UserAccountDataEvent>, Optional<MXCrossSigningInfo>, Optional<PrivateKeysInfo>, SecretsSynchronisationInfo>(
liveUserAccountData(setOf(MASTER_KEY_SSSS_NAME, USER_SIGNING_KEY_SSSS_NAME, SELF_SIGNING_KEY_SSSS_NAME, KEYBACKUP_SECRET_SSSS_NAME)),
liveCrossSigningInfo(session.myUserId),
liveCrossSigningPrivateKeys(),
Function3 { _, crossSigningInfo, pInfo ->
// first check if 4S is already setup
val is4SSetup = session.sharedSecretStorageService.isRecoverySetup()
val isCrossSigningEnabled = crossSigningInfo.getOrNull() != null
val isCrossSigningTrusted = crossSigningInfo.getOrNull()?.isTrusted() == true
val allPrivateKeysKnown = pInfo.getOrNull()?.allKnown().orFalse()
val keysBackupService = session.cryptoService().keysBackupService()
val currentBackupVersion = keysBackupService.currentBackupVersion
val megolmBackupAvailable = currentBackupVersion != null
val savedBackupKey = keysBackupService.getKeyBackupRecoveryKeyInfo()
val megolmKeyKnown = savedBackupKey?.version == currentBackupVersion
SecretsSynchronisationInfo(
isBackupSetup = is4SSetup,
isCrossSigningEnabled = isCrossSigningEnabled,
isCrossSigningTrusted = isCrossSigningTrusted,
allPrivateKeysKnown = allPrivateKeysKnown,
megolmBackupAvailable = megolmBackupAvailable,
megolmSecretKnown = megolmKeyKnown,
isMegolmKeyIn4S = session.sharedSecretStorageService.isMegolmKeyInBackup()
)
}
)
.distinctUntilChanged()
}
fun lookupThreePid(threePid: ThreePid): Single<Optional<FoundThreePid>> = rxSingle {
session.identityService().lookUp(listOf(threePid)).firstOrNull().toOptional()
}
}
fun Session.rx(): RxSession {
return RxSession(this)
}

View file

@ -118,6 +118,11 @@ dependencies {
implementation libs.squareup.retrofit implementation libs.squareup.retrofit
implementation libs.squareup.retrofitMoshi implementation libs.squareup.retrofitMoshi
// When version of okhttp is updated (current is 4.9.3), consider removing the workaround
// to force usage of Protocol.HTTP_1_1. Check the status of:
// - https://github.com/square/okhttp/issues/3278
// - https://github.com/square/okhttp/issues/4455
// - https://github.com/square/okhttp/issues/3146
implementation(platform("com.squareup.okhttp3:okhttp-bom:4.9.3")) implementation(platform("com.squareup.okhttp3:okhttp-bom:4.9.3"))
implementation 'com.squareup.okhttp3:okhttp' implementation 'com.squareup.okhttp3:okhttp'
implementation 'com.squareup.okhttp3:logging-interceptor' implementation 'com.squareup.okhttp3:logging-interceptor'

View file

@ -62,7 +62,7 @@ class EncryptionTest : InstrumentedTest {
// Send an encryption Event as a State Event // Send an encryption Event as a State Event
room.sendStateEvent( room.sendStateEvent(
eventType = EventType.STATE_ROOM_ENCRYPTION, eventType = EventType.STATE_ROOM_ENCRYPTION,
stateKey = null, stateKey = "",
body = EncryptionEventContent(algorithm = MXCRYPTO_ALGORITHM_MEGOLM).toContent() body = EncryptionEventContent(algorithm = MXCRYPTO_ALGORITHM_MEGOLM).toContent()
) )
} }

View file

@ -550,7 +550,7 @@ class SpaceHierarchyTest : InstrumentedTest {
?.setUserPowerLevel(aliceSession.myUserId, Role.Admin.value) ?.setUserPowerLevel(aliceSession.myUserId, Role.Admin.value)
?.toContent() ?.toContent()
room.sendStateEvent(EventType.STATE_ROOM_POWER_LEVELS, null, newPowerLevelsContent!!) room.sendStateEvent(EventType.STATE_ROOM_POWER_LEVELS, stateKey = "", newPowerLevelsContent!!)
it.countDown() it.countDown()
} }

View file

@ -75,9 +75,12 @@ interface MembershipService {
suspend fun unban(userId: String, reason: String? = null) suspend fun unban(userId: String, reason: String? = null)
/** /**
* Kick a user from the room * Remove a user from the room
*/ */
suspend fun kick(userId: String, reason: String? = null) suspend fun remove(userId: String, reason: String? = null)
@Deprecated("Use remove instead", ReplaceWith("remove(userId, reason)"))
suspend fun kick(userId: String, reason: String? = null) = remove(userId, reason)
/** /**
* Join the room, or accept an invitation. * Join the room, or accept an invitation.

View file

@ -68,8 +68,11 @@ interface StateService {
/** /**
* Send a state event to the room * Send a state event to the room
* @param eventType The type of event to send.
* @param stateKey The state_key for the state to send. Can be an empty string.
* @param body The content object of the event; the fields in this object will vary depending on the type of event
*/ */
suspend fun sendStateEvent(eventType: String, stateKey: String?, body: JsonDict) suspend fun sendStateEvent(eventType: String, stateKey: String, body: JsonDict)
/** /**
* Get a state event of the room * Get a state event of the room

View file

@ -22,6 +22,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import okhttp3.ConnectionSpec import okhttp3.ConnectionSpec
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.BuildConfig
import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.MatrixConfiguration
@ -71,6 +72,8 @@ internal object NetworkModule {
val spec = ConnectionSpec.Builder(matrixConfiguration.connectionSpec).build() val spec = ConnectionSpec.Builder(matrixConfiguration.connectionSpec).build()
return OkHttpClient.Builder() return OkHttpClient.Builder()
// workaround for #4669
.protocols(listOf(Protocol.HTTP_1_1))
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS)

View file

@ -130,7 +130,7 @@ internal class DefaultRoom(override val roomId: String,
else -> { else -> {
val params = SendStateTask.Params( val params = SendStateTask.Params(
roomId = roomId, roomId = roomId,
stateKey = null, stateKey = "",
eventType = EventType.STATE_ROOM_ENCRYPTION, eventType = EventType.STATE_ROOM_ENCRYPTION,
body = mapOf( body = mapOf(
"algorithm" to algorithm "algorithm" to algorithm

View file

@ -125,7 +125,7 @@ internal class DefaultMembershipService @AssistedInject constructor(
membershipAdminTask.execute(params) membershipAdminTask.execute(params)
} }
override suspend fun kick(userId: String, reason: String?) { override suspend fun remove(userId: String, reason: String?) {
val params = MembershipAdminTask.Params(MembershipAdminTask.Type.KICK, roomId, userId, reason) val params = MembershipAdminTask.Params(MembershipAdminTask.Type.KICK, roomId, userId, reason)
membershipAdminTask.execute(params) membershipAdminTask.execute(params)
} }

View file

@ -68,7 +68,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
override suspend fun sendStateEvent( override suspend fun sendStateEvent(
eventType: String, eventType: String,
stateKey: String?, stateKey: String,
body: JsonDict body: JsonDict
) { ) {
val params = SendStateTask.Params( val params = SendStateTask.Params(
@ -92,7 +92,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
sendStateEvent( sendStateEvent(
eventType = EventType.STATE_ROOM_TOPIC, eventType = EventType.STATE_ROOM_TOPIC,
body = mapOf("topic" to topic), body = mapOf("topic" to topic),
stateKey = null stateKey = ""
) )
} }
@ -100,7 +100,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
sendStateEvent( sendStateEvent(
eventType = EventType.STATE_ROOM_NAME, eventType = EventType.STATE_ROOM_NAME,
body = mapOf("name" to name), body = mapOf("name" to name),
stateKey = null stateKey = ""
) )
} }
@ -117,7 +117,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
// Sort for the cleanup // Sort for the cleanup
.sorted() .sorted()
).toContent(), ).toContent(),
stateKey = null stateKey = ""
) )
} }
@ -125,7 +125,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
sendStateEvent( sendStateEvent(
eventType = EventType.STATE_ROOM_HISTORY_VISIBILITY, eventType = EventType.STATE_ROOM_HISTORY_VISIBILITY,
body = mapOf("history_visibility" to readability), body = mapOf("history_visibility" to readability),
stateKey = null stateKey = ""
) )
} }
@ -142,14 +142,14 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
sendStateEvent( sendStateEvent(
eventType = EventType.STATE_ROOM_JOIN_RULES, eventType = EventType.STATE_ROOM_JOIN_RULES,
body = body, body = body,
stateKey = null stateKey = ""
) )
} }
if (guestAccess != null) { if (guestAccess != null) {
sendStateEvent( sendStateEvent(
eventType = EventType.STATE_ROOM_GUEST_ACCESS, eventType = EventType.STATE_ROOM_GUEST_ACCESS,
body = mapOf("guest_access" to guestAccess), body = mapOf("guest_access" to guestAccess),
stateKey = null stateKey = ""
) )
} }
} }
@ -159,7 +159,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
sendStateEvent( sendStateEvent(
eventType = EventType.STATE_ROOM_AVATAR, eventType = EventType.STATE_ROOM_AVATAR,
body = mapOf("url" to response.contentUri), body = mapOf("url" to response.contentUri),
stateKey = null stateKey = ""
) )
} }
@ -167,7 +167,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private
sendStateEvent( sendStateEvent(
eventType = EventType.STATE_ROOM_AVATAR, eventType = EventType.STATE_ROOM_AVATAR,
body = emptyMap(), body = emptyMap(),
stateKey = null stateKey = ""
) )
} }

View file

@ -26,7 +26,7 @@ import javax.inject.Inject
internal interface SendStateTask : Task<SendStateTask.Params, Unit> { internal interface SendStateTask : Task<SendStateTask.Params, Unit> {
data class Params( data class Params(
val roomId: String, val roomId: String,
val stateKey: String?, val stateKey: String,
val eventType: String, val eventType: String,
val body: JsonDict val body: JsonDict
) )
@ -39,7 +39,7 @@ internal class DefaultSendStateTask @Inject constructor(
override suspend fun execute(params: SendStateTask.Params) { override suspend fun execute(params: SendStateTask.Params) {
return executeRequest(globalErrorReceiver) { return executeRequest(globalErrorReceiver) {
if (params.stateKey == null) { if (params.stateKey.isEmpty()) {
roomAPI.sendStateEvent( roomAPI.sendStateEvent(
roomId = params.roomId, roomId = params.roomId,
stateEventType = params.eventType, stateEventType = params.eventType,

View file

@ -440,7 +440,8 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
} }
} }
} }
// Handle deletion of [stuck] local echos if needed
deleteLocalEchosIfNeeded(insertType, roomEntity, eventList)
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded( optimizedThreadSummaryMap.updateThreadSummaryIfNeeded(
roomId = roomId, roomId = roomId,
realm = realm, realm = realm,
@ -448,7 +449,6 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
// posting new events to timeline if any is registered // posting new events to timeline if any is registered
timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds) timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds)
return chunkEntity return chunkEntity
} }
@ -499,4 +499,49 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
return result return result
} }
/**
* There are multiple issues like #516 that report stuck local echo events
* at the bottom of each room timeline.
*
* That can happen when a message is SENT but not received back from the /sync.
* Until now we use unsignedData.transactionId to determine whether or not the local
* event should be deleted on every /sync. However, this is partially correct, lets have a look
* at the following scenario:
*
* [There is no Internet connection] --> [10 Messages are sent] --> [The 10 messages are in the queue] -->
* [Internet comes back for 1 second] --> [3 messages are sent] --> [Internet drops again] -->
* [No /sync response is triggered | home server can even replied with /sync but never arrived while we are offline]
*
* So the state until now is that we have 7 pending events to send and 3 sent but not received them back from /sync
* Subsequently, those 3 local messages will not be deleted while there is no transactionId from the /sync
*
* lets continue:
* [Now lets assume that in the same room another user sent 15 events] -->
* [We are finally back online!] -->
* [We will receive the 10 latest events for the room and of course sent the pending 7 messages] -->
* Now /sync response will NOT contain the 3 local messages so our events will stuck in the device.
*
* Someone can say, yes but it will come with the rooms/{roomId}/messages while paginating,
* so the problem will be solved. No that is not the case for two reasons:
* 1. rooms/{roomId}/messages response do not contain the unsignedData.transactionId so we cannot know which event
* to delete
* 2. even if transactionId was there, currently we are not deleting it from the pagination
*
* ---------------------------------------------------------------------------------------------
* While we cannot know when a specific event arrived from the pagination (no transactionId included), after each room /sync
* we clear all SENT events, and we are sure that we will receive it from /sync or pagination
*/
private fun deleteLocalEchosIfNeeded(insertType: EventInsertType, roomEntity: RoomEntity, eventList: List<Event>) {
// Skip deletion if we are on initial sync
if (insertType == EventInsertType.INITIAL_SYNC) return
// Skip deletion if there are no timeline events or there is no event received from the current user
if (eventList.firstOrNull { it.senderId == userId } == null) return
roomEntity.sendingTimelineEvents.filter { timelineEvent ->
timelineEvent.root?.sendState == SendState.SENT
}.forEach {
roomEntity.sendingTimelineEvents.remove(it)
it.deleteOnCascade(true)
}
}
} }

View file

@ -64,7 +64,7 @@ internal class DefaultTermsService @Inject constructor(
*/ */
override suspend fun getHomeserverTerms(baseUrl: String): TermsResponse { override suspend fun getHomeserverTerms(baseUrl: String): TermsResponse {
return try { return try {
val request = baseUrl + NetworkConstants.URI_API_PREFIX_PATH_R0 + "register" val request = baseUrl.ensureTrailingSlash() + NetworkConstants.URI_API_PREFIX_PATH_R0 + "register"
executeRequest(null) { executeRequest(null) {
termsAPI.register(request) termsAPI.register(request)
} }

View file

@ -1,8 +1,8 @@
include ':vector' include ':vector'
include ':matrix-sdk-android' include ':matrix-sdk-android'
include ':matrix-sdk-android-rx'
include ':diff-match-patch' include ':diff-match-patch'
include ':attachment-viewer' include ':attachment-viewer'
include ':multipicker' include ':multipicker'
include ':library:core-utils'
include ':library:ui-styles' include ':library:ui-styles'
include ':matrix-sdk-android-flow' include ':matrix-sdk-android-flow'

View file

@ -15,13 +15,18 @@
name = "Bugfixes 🐛" name = "Bugfixes 🐛"
showcontent = true showcontent = true
[[tool.towncrier.type]]
directory = "wip"
name = "In development 🚧"
showcontent = true
[[tool.towncrier.type]] [[tool.towncrier.type]]
directory = "doc" directory = "doc"
name = "Improved Documentation 📚" name = "Improved Documentation 📚"
showcontent = true showcontent = true
[[tool.towncrier.type]] [[tool.towncrier.type]]
directory = "removal" directory = "sdk"
name = "SDK API changes ⚠️" name = "SDK API changes ⚠️"
showcontent = true showcontent = true

View file

@ -336,6 +336,7 @@ dependencies {
implementation project(":multipicker") implementation project(":multipicker")
implementation project(":attachment-viewer") implementation project(":attachment-viewer")
implementation project(":library:ui-styles") implementation project(":library:ui-styles")
implementation project(":library:core-utils")
implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.multidex:multidex:2.0.1'
implementation libs.jetbrains.coroutinesCore implementation libs.jetbrains.coroutinesCore

View file

@ -36,13 +36,18 @@ class DebugFeaturesStateFactory @Inject constructor(
), ),
createBooleanFeature( createBooleanFeature(
label = "FTUE Splash - I already have an account", label = "FTUE Splash - I already have an account",
factory = VectorFeatures::isAlreadyHaveAccountSplashEnabled, key = DebugFeatureKeys.onboardingAlreadyHaveAnAccount,
key = DebugFeatureKeys.alreadyHaveAnAccount factory = VectorFeatures::isOnboardingAlreadyHaveAccountSplashEnabled
), ),
createBooleanFeature( createBooleanFeature(
label = "FTUE Splash - Carousel", label = "FTUE Splash - carousel",
factory = VectorFeatures::isSplashCarouselEnabled, key = DebugFeatureKeys.onboardingSplashCarousel,
key = DebugFeatureKeys.splashCarousel factory = VectorFeatures::isOnboardingSplashCarouselEnabled
),
createBooleanFeature(
label = "FTUE Use Case",
key = DebugFeatureKeys.onboardingUseCase,
factory = VectorFeatures::isOnboardingUseCaseEnabled
) )
)) ))
} }

View file

@ -43,10 +43,13 @@ class DebugVectorFeatures(
return readPreferences().getEnum<VectorFeatures.OnboardingVariant>() ?: vectorFeatures.onboardingVariant() return readPreferences().getEnum<VectorFeatures.OnboardingVariant>() ?: vectorFeatures.onboardingVariant()
} }
override fun isAlreadyHaveAccountSplashEnabled(): Boolean = read(DebugFeatureKeys.alreadyHaveAnAccount) override fun isOnboardingAlreadyHaveAccountSplashEnabled(): Boolean = read(DebugFeatureKeys.onboardingAlreadyHaveAnAccount)
?: vectorFeatures.isAlreadyHaveAccountSplashEnabled() ?: vectorFeatures.isOnboardingAlreadyHaveAccountSplashEnabled()
override fun isSplashCarouselEnabled(): Boolean = read(DebugFeatureKeys.splashCarousel) ?: vectorFeatures.isSplashCarouselEnabled() override fun isOnboardingSplashCarouselEnabled(): Boolean = read(DebugFeatureKeys.onboardingSplashCarousel)
?: vectorFeatures.isOnboardingSplashCarouselEnabled()
override fun isOnboardingUseCaseEnabled(): Boolean = read(DebugFeatureKeys.onboardingUseCase) ?: vectorFeatures.isOnboardingUseCaseEnabled()
fun <T> override(value: T?, key: Preferences.Key<T>) = updatePreferences { fun <T> override(value: T?, key: Preferences.Key<T>) = updatePreferences {
if (value == null) { if (value == null) {
@ -96,6 +99,7 @@ private inline fun <reified T : Enum<T>> enumPreferencesKey() = enumPreferencesK
private fun <T : Enum<T>> enumPreferencesKey(type: KClass<T>) = stringPreferencesKey("enum-${type.simpleName}") private fun <T : Enum<T>> enumPreferencesKey(type: KClass<T>) = stringPreferencesKey("enum-${type.simpleName}")
object DebugFeatureKeys { object DebugFeatureKeys {
val alreadyHaveAnAccount = booleanPreferencesKey("already-have-an-account") val onboardingAlreadyHaveAnAccount = booleanPreferencesKey("onboarding-already-have-an-account")
val splashCarousel = booleanPreferencesKey("splash-carousel") val onboardingSplashCarousel = booleanPreferencesKey("onboarding-splash-carousel")
val onboardingUseCase = booleanPreferencesKey("onbboarding-splash-carousel")
} }

View file

@ -76,6 +76,7 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:resizeableActivity="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Vector.Light" android:theme="@style/Theme.Vector.Light"
@ -402,7 +403,8 @@
android:value="androidx.startup" android:value="androidx.startup"
tools:node="remove" /> tools:node="remove" />
<!-- We init the lib ourself in EmojiCompatWrapper --> <!-- We init the lib ourself in EmojiCompatWrapper -->
<meta-data android:name="androidx.emoji2.text.EmojiCompatInitializer" <meta-data
android:name="androidx.emoji2.text.EmojiCompatInitializer"
tools:node="remove" /> tools:node="remove" />
</provider> </provider>

View file

@ -61,7 +61,7 @@ class AppStateHandler @Inject constructor(
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val selectedSpaceDataSource = BehaviorDataSource<Option<RoomGroupingMethod>>(Option.empty()) private val selectedSpaceDataSource = BehaviorDataSource<Option<RoomGroupingMethod>>(Option.empty())
val selectedRoomGroupingObservable = selectedSpaceDataSource.stream() val selectedRoomGroupingFlow = selectedSpaceDataSource.stream()
fun getCurrentRoomGroupingMethod(): RoomGroupingMethod? { fun getCurrentRoomGroupingMethod(): RoomGroupingMethod? {
// XXX we should somehow make it live :/ just a work around // XXX we should somehow make it live :/ just a work around

View file

@ -31,7 +31,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ActiveSessionHolder @Inject constructor(private val sessionObservableStore: ActiveSessionDataSource, class ActiveSessionHolder @Inject constructor(private val activeSessionDataSource: ActiveSessionDataSource,
private val keyRequestHandler: KeyRequestHandler, private val keyRequestHandler: KeyRequestHandler,
private val incomingVerificationRequestHandler: IncomingVerificationRequestHandler, private val incomingVerificationRequestHandler: IncomingVerificationRequestHandler,
private val callManager: WebRtcCallManager, private val callManager: WebRtcCallManager,
@ -46,7 +46,7 @@ class ActiveSessionHolder @Inject constructor(private val sessionObservableStore
fun setActiveSession(session: Session) { fun setActiveSession(session: Session) {
Timber.w("setActiveSession of ${session.myUserId}") Timber.w("setActiveSession of ${session.myUserId}")
activeSession.set(session) activeSession.set(session)
sessionObservableStore.post(Option.just(session)) activeSessionDataSource.post(Option.just(session))
keyRequestHandler.start(session) keyRequestHandler.start(session)
incomingVerificationRequestHandler.start(session) incomingVerificationRequestHandler.start(session)
@ -66,7 +66,7 @@ class ActiveSessionHolder @Inject constructor(private val sessionObservableStore
} }
activeSession.set(null) activeSession.set(null)
sessionObservableStore.post(Option.empty()) activeSessionDataSource.post(Option.empty())
keyRequestHandler.stop() keyRequestHandler.stop()
incomingVerificationRequestHandler.stop() incomingVerificationRequestHandler.stop()

View file

@ -105,6 +105,7 @@ import im.vector.app.features.onboarding.ftueauth.FtueAuthServerSelectionFragmen
import im.vector.app.features.onboarding.ftueauth.FtueAuthSignUpSignInSelectionFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthSignUpSignInSelectionFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthSplashCarouselFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthSplashCarouselFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthSplashFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthSplashFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthUseCaseFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthWaitForEmailFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthWaitForEmailFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthWebFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthWebFragment
import im.vector.app.features.onboarding.ftueauth.terms.FtueAuthTermsFragment import im.vector.app.features.onboarding.ftueauth.terms.FtueAuthTermsFragment
@ -450,6 +451,11 @@ interface FragmentModule {
@FragmentKey(FtueAuthSplashCarouselFragment::class) @FragmentKey(FtueAuthSplashCarouselFragment::class)
fun bindFtueAuthSplashCarouselFragment(fragment: FtueAuthSplashCarouselFragment): Fragment fun bindFtueAuthSplashCarouselFragment(fragment: FtueAuthSplashCarouselFragment): Fragment
@Binds
@IntoMap
@FragmentKey(FtueAuthUseCaseFragment::class)
fun bindFtueAuthUseCaseFragment(fragment: FtueAuthUseCaseFragment): Fragment
@Binds @Binds
@IntoMap @IntoMap
@FragmentKey(FtueAuthWaitForEmailFragment::class) @FragmentKey(FtueAuthWaitForEmailFragment::class)

View file

@ -82,7 +82,7 @@ fun TextView.setTextWithColoredPart(@StringRes fullTextRes: Int,
fun TextView.setTextWithColoredPart(fullText: String, fun TextView.setTextWithColoredPart(fullText: String,
coloredPart: String, coloredPart: String,
@AttrRes colorAttribute: Int = R.attr.colorPrimary, @AttrRes colorAttribute: Int = R.attr.colorPrimary,
underline: Boolean = false, underline: Boolean = true,
onClick: (() -> Unit)? = null) { onClick: (() -> Unit)? = null) {
val color = ThemeUtils.getColor(context, colorAttribute) val color = ThemeUtils.getColor(context, colorAttribute)
@ -101,7 +101,6 @@ fun TextView.setTextWithColoredPart(fullText: String,
override fun updateDrawState(ds: TextPaint) { override fun updateDrawState(ds: TextPaint) {
ds.color = color ds.color = color
ds.isUnderlineText = !underline
} }
} }
setSpan(clickableSpan, index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) setSpan(clickableSpan, index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

View file

@ -0,0 +1,42 @@
/*
* 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.core.platform
import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
import android.content.res.Resources
import androidx.appcompat.app.AppCompatActivity
import im.vector.app.R
import javax.inject.Inject
class ScreenOrientationLocker @Inject constructor(
private val resources: Resources
) {
// Some screens do not provide enough value for us to provide phone landscape experiences
@SuppressLint("SourceLockedOrientationActivity")
fun lockPhonesToPortrait(activity: AppCompatActivity) {
when (resources.getBoolean(R.bool.is_tablet)) {
true -> {
// do nothing
}
false -> {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
}
}

View file

@ -27,3 +27,5 @@ class LocaleProvider @Inject constructor(private val resources: Resources) {
return ConfigurationCompat.getLocales(resources.configuration)[0] return ConfigurationCompat.getLocales(resources.configuration)[0]
} }
} }
fun LocaleProvider.isEnglishSpeaking() = current().language.startsWith("en")

View file

@ -21,10 +21,9 @@ import im.vector.app.BuildConfig
interface VectorFeatures { interface VectorFeatures {
fun onboardingVariant(): OnboardingVariant fun onboardingVariant(): OnboardingVariant
fun isOnboardingAlreadyHaveAccountSplashEnabled(): Boolean
fun isAlreadyHaveAccountSplashEnabled(): Boolean fun isOnboardingSplashCarouselEnabled(): Boolean
fun isOnboardingUseCaseEnabled(): Boolean
fun isSplashCarouselEnabled(): Boolean
enum class OnboardingVariant { enum class OnboardingVariant {
LEGACY, LEGACY,
@ -35,6 +34,7 @@ interface VectorFeatures {
class DefaultVectorFeatures : VectorFeatures { class DefaultVectorFeatures : VectorFeatures {
override fun onboardingVariant(): VectorFeatures.OnboardingVariant = BuildConfig.ONBOARDING_VARIANT override fun onboardingVariant(): VectorFeatures.OnboardingVariant = BuildConfig.ONBOARDING_VARIANT
override fun isAlreadyHaveAccountSplashEnabled() = true override fun isOnboardingAlreadyHaveAccountSplashEnabled() = true
override fun isSplashCarouselEnabled() = false override fun isOnboardingSplashCarouselEnabled() = false
override fun isOnboardingUseCaseEnabled() = false
} }

View file

@ -16,9 +16,9 @@
package im.vector.app.features.analytics package im.vector.app.features.analytics
import im.vector.app.core.flow.tickerFlow
import im.vector.app.core.time.Clock import im.vector.app.core.time.Clock
import im.vector.app.features.analytics.plan.Error import im.vector.app.features.analytics.plan.Error
import im.vector.lib.core.utils.flow.tickerFlow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob

View file

@ -20,8 +20,10 @@ import com.airbnb.mvrx.viewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.extensions.addFragment import im.vector.app.core.extensions.addFragment
import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.ScreenOrientationLocker
import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivitySimpleBinding import im.vector.app.databinding.ActivitySimpleBinding
import javax.inject.Inject
/** /**
* Simple container for AnalyticsOptInFragment * Simple container for AnalyticsOptInFragment
@ -29,6 +31,8 @@ import im.vector.app.databinding.ActivitySimpleBinding
@AndroidEntryPoint @AndroidEntryPoint
class AnalyticsOptInActivity : VectorBaseActivity<ActivitySimpleBinding>() { class AnalyticsOptInActivity : VectorBaseActivity<ActivitySimpleBinding>() {
@Inject lateinit var orientationLocker: ScreenOrientationLocker
private val viewModel: AnalyticsConsentViewModel by viewModel() private val viewModel: AnalyticsConsentViewModel by viewModel()
override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater) override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater)
@ -36,6 +40,7 @@ class AnalyticsOptInActivity : VectorBaseActivity<ActivitySimpleBinding>() {
override fun getCoordinatorLayout() = views.coordinatorLayout override fun getCoordinatorLayout() = views.coordinatorLayout
override fun initUiAndData() { override fun initUiAndData() {
orientationLocker.lockPhonesToPortrait(this)
if (isFirstCreation()) { if (isFirstCreation()) {
addFragment(views.simpleFragmentContainer, AnalyticsOptInFragment::class.java) addFragment(views.simpleFragmentContainer, AnalyticsOptInFragment::class.java)
} }

View file

@ -67,7 +67,7 @@ class AutocompleteCommandPresenter @AssistedInject constructor(
if (query.isNullOrEmpty()) { if (query.isNullOrEmpty()) {
true true
} else { } else {
it.command.startsWith(query, 1, true) it.startsWith(query)
} }
} }
controller.setData(data) controller.setData(data)

View file

@ -19,9 +19,7 @@ package im.vector.app.features.call.webrtc
import android.content.Context import android.content.Context
import android.hardware.camera2.CameraManager import android.hardware.camera2.CameraManager
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import im.vector.app.core.flow.chunk
import im.vector.app.core.services.CallService import im.vector.app.core.services.CallService
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.core.utils.PublishDataSource import im.vector.app.core.utils.PublishDataSource
import im.vector.app.core.utils.TextUtils.formatDuration import im.vector.app.core.utils.TextUtils.formatDuration
import im.vector.app.features.call.CameraEventsHandlerAdapter import im.vector.app.features.call.CameraEventsHandlerAdapter
@ -37,6 +35,8 @@ import im.vector.app.features.call.utils.awaitSetLocalDescription
import im.vector.app.features.call.utils.awaitSetRemoteDescription import im.vector.app.features.call.utils.awaitSetRemoteDescription
import im.vector.app.features.call.utils.mapToCallCandidate import im.vector.app.features.call.utils.mapToCallCandidate
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
import im.vector.lib.core.utils.flow.chunk
import im.vector.lib.core.utils.timer.CountUpTimer
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View file

@ -24,42 +24,51 @@ import im.vector.app.R
* the user can write theses messages to perform some actions * the user can write theses messages to perform some actions
* the list will be displayed in this order * the list will be displayed in this order
*/ */
enum class Command(val command: String, val parameters: String, @StringRes val description: Int, val isDevCommand: Boolean, val isThreadCommand: Boolean) { enum class Command(val command: String,
EMOTE("/me", "<message>", R.string.command_description_emote, false, true), val aliases: Array<CharSequence>?,
BAN_USER("/ban", "<user-id> [reason]", R.string.command_description_ban_user, false, false), val parameters: String,
UNBAN_USER("/unban", "<user-id> [reason]", R.string.command_description_unban_user, false, false), @StringRes val description: Int,
IGNORE_USER("/ignore", "<user-id> [reason]", R.string.command_description_ignore_user, false, true), val isDevCommand: Boolean,
UNIGNORE_USER("/unignore", "<user-id>", R.string.command_description_unignore_user, false, true), val isThreadCommand: Boolean) {
SET_USER_POWER_LEVEL("/op", "<user-id> [<power-level>]", R.string.command_description_op_user, false, false), EMOTE("/me", null, "<message>", R.string.command_description_emote, false, true),
RESET_USER_POWER_LEVEL("/deop", "<user-id>", R.string.command_description_deop_user, false, false), BAN_USER("/ban", null, "<user-id> [reason]", R.string.command_description_ban_user, false, false),
ROOM_NAME("/roomname", "<name>", R.string.command_description_room_name, false, false), UNBAN_USER("/unban", null, "<user-id> [reason]", R.string.command_description_unban_user, false, false),
INVITE("/invite", "<user-id> [reason]", R.string.command_description_invite_user, false, false), IGNORE_USER("/ignore", null, "<user-id> [reason]", R.string.command_description_ignore_user, false, true),
JOIN_ROOM("/join", "<room-address> [reason]", R.string.command_description_join_room, false, false), UNIGNORE_USER("/unignore", null, "<user-id>", R.string.command_description_unignore_user, false, true),
PART("/part", "[<room-address>]", R.string.command_description_part_room, false, false), SET_USER_POWER_LEVEL("/op", null, "<user-id> [<power-level>]", R.string.command_description_op_user, false, false),
TOPIC("/topic", "<topic>", R.string.command_description_topic, false, false), RESET_USER_POWER_LEVEL("/deop", null, "<user-id>", R.string.command_description_deop_user, false, false),
KICK_USER("/kick", "<user-id> [reason]", R.string.command_description_kick_user, false, false), ROOM_NAME("/roomname", null, "<name>", R.string.command_description_room_name, false, false),
CHANGE_DISPLAY_NAME("/nick", "<display-name>", R.string.command_description_nick, false, false), INVITE("/invite", null, "<user-id> [reason]", R.string.command_description_invite_user, false, false),
CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", "<display-name>", R.string.command_description_nick_for_room, false, false), JOIN_ROOM("/join", arrayOf("/j", "/goto"), "<room-address> [reason]", R.string.command_description_join_room, false, false),
ROOM_AVATAR("/roomavatar", "<mxc_url>", R.string.command_description_room_avatar, true /* Since user has to know the mxc url */, false), PART("/part", null, "[<room-address>]", R.string.command_description_part_room, false, false),
CHANGE_AVATAR_FOR_ROOM("/myroomavatar", "<mxc_url>", R.string.command_description_avatar_for_room, true /* Since user has to know the mxc url */, false), TOPIC("/topic", null, "<topic>", R.string.command_description_topic, false, false),
MARKDOWN("/markdown", "<on|off>", R.string.command_description_markdown, false, false), REMOVE_USER("/remove", arrayOf("/kick"), "<user-id> [reason]", R.string.command_description_kick_user, false, false),
RAINBOW("/rainbow", "<message>", R.string.command_description_rainbow, false, true), CHANGE_DISPLAY_NAME("/nick", null, "<display-name>", R.string.command_description_nick, false, false),
RAINBOW_EMOTE("/rainbowme", "<message>", R.string.command_description_rainbow_emote, false, true), CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", arrayOf("/roomnick"), "<display-name>", R.string.command_description_nick_for_room, false, false),
CLEAR_SCALAR_TOKEN("/clear_scalar_token", "", R.string.command_description_clear_scalar_token, false, false), ROOM_AVATAR("/roomavatar", null, "<mxc_url>", R.string.command_description_room_avatar, true /* Since user has to know the mxc url */, false),
SPOILER("/spoiler", "<message>", R.string.command_description_spoiler, false, true), CHANGE_AVATAR_FOR_ROOM("/myroomavatar", null, "<mxc_url>", R.string.command_description_avatar_for_room, true /* Since user has to know the mxc url */, false),
SHRUG("/shrug", "<message>", R.string.command_description_shrug, false, true), MARKDOWN("/markdown", null, "<on|off>", R.string.command_description_markdown, false, false),
LENNY("/lenny", "<message>", R.string.command_description_lenny, false, true), RAINBOW("/rainbow", null, "<message>", R.string.command_description_rainbow, false, true),
PLAIN("/plain", "<message>", R.string.command_description_plain, false, true), RAINBOW_EMOTE("/rainbowme", null, "<message>", R.string.command_description_rainbow_emote, false, true),
WHOIS("/whois", "<user-id>", R.string.command_description_whois, false, true), CLEAR_SCALAR_TOKEN("/clear_scalar_token", null, "", R.string.command_description_clear_scalar_token, false, false),
DISCARD_SESSION("/discardsession", "", R.string.command_description_discard_session, false, false), SPOILER("/spoiler", null, "<message>", R.string.command_description_spoiler, false, true),
CONFETTI("/confetti", "<message>", R.string.command_confetti, false, false), SHRUG("/shrug", null, "<message>", R.string.command_description_shrug, false, true),
SNOWFALL("/snowfall", "<message>", R.string.command_snow, false, false), LENNY("/lenny", null, "<message>", R.string.command_description_lenny, false, true),
CREATE_SPACE("/createspace", "<name> <invitee>*", R.string.command_description_create_space, true, false), PLAIN("/plain", null, "<message>", R.string.command_description_plain, false, true),
ADD_TO_SPACE("/addToSpace", "spaceId", R.string.command_description_add_to_space, true, false), WHOIS("/whois", null, "<user-id>", R.string.command_description_whois, false, true),
JOIN_SPACE("/joinSpace", "spaceId", R.string.command_description_join_space, true, false), DISCARD_SESSION("/discardsession", null, "", R.string.command_description_discard_session, false, false),
LEAVE_ROOM("/leave", "<roomId?>", R.string.command_description_leave_room, true, false), CONFETTI("/confetti", null, "<message>", R.string.command_confetti, false, false),
UPGRADE_ROOM("/upgraderoom", "newVersion", R.string.command_description_upgrade_room, true, false); SNOWFALL("/snowfall", null, "<message>", R.string.command_snow, false, false),
CREATE_SPACE("/createspace", null, "<name> <invitee>*", R.string.command_description_create_space, true, false),
ADD_TO_SPACE("/addToSpace", null, "spaceId", R.string.command_description_add_to_space, true, false),
JOIN_SPACE("/joinSpace", null, "spaceId", R.string.command_description_join_space, true, false),
LEAVE_ROOM("/leave", null, "<roomId?>", R.string.command_description_leave_room, true, false),
UPGRADE_ROOM("/upgraderoom", null, "newVersion", R.string.command_description_upgrade_room, true, false);
val length val allAliases = arrayOf(command, *aliases.orEmpty())
get() = command.length + 1
fun matches(inputCommand: CharSequence) = allAliases.any { it.contentEquals(inputCommand, true) }
fun startsWith(input: CharSequence) =
allAliases.any { it.startsWith(input, 1, true) }
} }

View file

@ -33,12 +33,12 @@ object CommandParser {
* @param textMessage the text message * @param textMessage the text message
* @return a parsed slash command (ok or error) * @return a parsed slash command (ok or error)
*/ */
fun parseSplashCommand(textMessage: CharSequence, isInThreadTimeline: Boolean): ParsedCommand { fun parseSlashCommand(textMessage: CharSequence, isInThreadTimeline: Boolean): ParsedCommand {
// check if it has the Slash marker // check if it has the Slash marker
if (!textMessage.startsWith("/")) { if (!textMessage.startsWith("/")) {
return ParsedCommand.ErrorNotACommand return ParsedCommand.ErrorNotACommand
} else { } else {
Timber.v("parseSplashCommand") Timber.v("parseSlashCommand")
// "/" only // "/" only
if (textMessage.length == 1) { if (textMessage.length == 1) {
@ -53,7 +53,7 @@ object CommandParser {
val messageParts = try { val messageParts = try {
textMessage.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() } textMessage.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## manageSplashCommand() : split failed") Timber.e(e, "## manageSlashCommand() : split failed")
null null
} }
@ -62,10 +62,10 @@ object CommandParser {
return ParsedCommand.ErrorEmptySlashCommand return ParsedCommand.ErrorEmptySlashCommand
} }
// If the command is not supported by threads return error val slashCommand = messageParts.first()
val message = textMessage.substring(slashCommand.length).trim()
if (BuildConfig.THREADING_ENABLED && isInThreadTimeline) { if (BuildConfig.THREADING_ENABLED && isInThreadTimeline) {
val slashCommand = messageParts.first()
val notSupportedCommandsInThreads = Command.values().filter { val notSupportedCommandsInThreads = Command.values().filter {
!it.isThreadCommand !it.isThreadCommand
}.map { }.map {
@ -76,35 +76,29 @@ object CommandParser {
} }
} }
return when (val slashCommand = messageParts.first()) { return when {
Command.PLAIN.command -> { Command.PLAIN.matches(slashCommand) -> {
val text = textMessage.substring(Command.PLAIN.command.length).trim() if (message.isNotEmpty()) {
ParsedCommand.SendPlainText(message = message)
if (text.isNotEmpty()) {
ParsedCommand.SendPlainText(text)
} else { } else {
ParsedCommand.ErrorSyntax(Command.PLAIN) ParsedCommand.ErrorSyntax(Command.PLAIN)
} }
} }
Command.CHANGE_DISPLAY_NAME.command -> { Command.CHANGE_DISPLAY_NAME.matches(slashCommand) -> {
val newDisplayName = textMessage.substring(Command.CHANGE_DISPLAY_NAME.command.length).trim() if (message.isNotEmpty()) {
ParsedCommand.ChangeDisplayName(displayName = message)
if (newDisplayName.isNotEmpty()) {
ParsedCommand.ChangeDisplayName(newDisplayName)
} else { } else {
ParsedCommand.ErrorSyntax(Command.CHANGE_DISPLAY_NAME) ParsedCommand.ErrorSyntax(Command.CHANGE_DISPLAY_NAME)
} }
} }
Command.CHANGE_DISPLAY_NAME_FOR_ROOM.command -> { Command.CHANGE_DISPLAY_NAME_FOR_ROOM.matches(slashCommand) -> {
val newDisplayName = textMessage.substring(Command.CHANGE_DISPLAY_NAME_FOR_ROOM.command.length).trim() if (message.isNotEmpty()) {
ParsedCommand.ChangeDisplayNameForRoom(displayName = message)
if (newDisplayName.isNotEmpty()) {
ParsedCommand.ChangeDisplayNameForRoom(newDisplayName)
} else { } else {
ParsedCommand.ErrorSyntax(Command.CHANGE_DISPLAY_NAME_FOR_ROOM) ParsedCommand.ErrorSyntax(Command.CHANGE_DISPLAY_NAME_FOR_ROOM)
} }
} }
Command.ROOM_AVATAR.command -> { Command.ROOM_AVATAR.matches(slashCommand) -> {
if (messageParts.size == 2) { if (messageParts.size == 2) {
val url = messageParts[1] val url = messageParts[1]
@ -117,7 +111,7 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.ROOM_AVATAR) ParsedCommand.ErrorSyntax(Command.ROOM_AVATAR)
} }
} }
Command.CHANGE_AVATAR_FOR_ROOM.command -> { Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> {
if (messageParts.size == 2) { if (messageParts.size == 2) {
val url = messageParts[1] val url = messageParts[1]
@ -130,40 +124,42 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.CHANGE_AVATAR_FOR_ROOM) ParsedCommand.ErrorSyntax(Command.CHANGE_AVATAR_FOR_ROOM)
} }
} }
Command.TOPIC.command -> { Command.TOPIC.matches(slashCommand) -> {
val newTopic = textMessage.substring(Command.TOPIC.command.length).trim() if (message.isNotEmpty()) {
ParsedCommand.ChangeTopic(topic = message)
if (newTopic.isNotEmpty()) {
ParsedCommand.ChangeTopic(newTopic)
} else { } else {
ParsedCommand.ErrorSyntax(Command.TOPIC) ParsedCommand.ErrorSyntax(Command.TOPIC)
} }
} }
Command.EMOTE.command -> { Command.EMOTE.matches(slashCommand) -> {
val message = textMessage.subSequence(Command.EMOTE.command.length, textMessage.length).trim() if (message.isNotEmpty()) {
ParsedCommand.SendEmote(message) ParsedCommand.SendEmote(message)
} else {
ParsedCommand.ErrorSyntax(Command.EMOTE)
} }
Command.RAINBOW.command -> { }
val message = textMessage.subSequence(Command.RAINBOW.command.length, textMessage.length).trim() Command.RAINBOW.matches(slashCommand) -> {
if (message.isNotEmpty()) {
ParsedCommand.SendRainbow(message) ParsedCommand.SendRainbow(message)
} else {
ParsedCommand.ErrorSyntax(Command.RAINBOW)
} }
Command.RAINBOW_EMOTE.command -> { }
val message = textMessage.subSequence(Command.RAINBOW_EMOTE.command.length, textMessage.length).trim() Command.RAINBOW_EMOTE.matches(slashCommand) -> {
if (message.isNotEmpty()) {
ParsedCommand.SendRainbowEmote(message) ParsedCommand.SendRainbowEmote(message)
} else {
ParsedCommand.ErrorSyntax(Command.RAINBOW_EMOTE)
} }
Command.JOIN_ROOM.command -> { }
Command.JOIN_ROOM.matches(slashCommand) -> {
if (messageParts.size >= 2) { if (messageParts.size >= 2) {
val roomAlias = messageParts[1] val roomAlias = messageParts[1]
if (roomAlias.isNotEmpty()) { if (roomAlias.isNotEmpty()) {
ParsedCommand.JoinRoom( ParsedCommand.JoinRoom(
roomAlias, roomAlias,
textMessage.substring(Command.JOIN_ROOM.length + roomAlias.length) trimParts(textMessage, messageParts.take(2))
.trim()
.takeIf { it.isNotBlank() }
) )
} else { } else {
ParsedCommand.ErrorSyntax(Command.JOIN_ROOM) ParsedCommand.ErrorSyntax(Command.JOIN_ROOM)
@ -172,23 +168,21 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.JOIN_ROOM) ParsedCommand.ErrorSyntax(Command.JOIN_ROOM)
} }
} }
Command.PART.command -> { Command.PART.matches(slashCommand) -> {
when (messageParts.size) { when (messageParts.size) {
1 -> ParsedCommand.PartRoom(null) 1 -> ParsedCommand.PartRoom(null)
2 -> ParsedCommand.PartRoom(messageParts[1]) 2 -> ParsedCommand.PartRoom(messageParts[1])
else -> ParsedCommand.ErrorSyntax(Command.PART) else -> ParsedCommand.ErrorSyntax(Command.PART)
} }
} }
Command.ROOM_NAME.command -> { Command.ROOM_NAME.matches(slashCommand) -> {
val newRoomName = textMessage.substring(Command.ROOM_NAME.command.length).trim() if (message.isNotEmpty()) {
ParsedCommand.ChangeRoomName(name = message)
if (newRoomName.isNotEmpty()) {
ParsedCommand.ChangeRoomName(newRoomName)
} else { } else {
ParsedCommand.ErrorSyntax(Command.ROOM_NAME) ParsedCommand.ErrorSyntax(Command.ROOM_NAME)
} }
} }
Command.INVITE.command -> { Command.INVITE.matches(slashCommand) -> {
if (messageParts.size >= 2) { if (messageParts.size >= 2) {
val userId = messageParts[1] val userId = messageParts[1]
@ -196,9 +190,7 @@ object CommandParser {
MatrixPatterns.isUserId(userId) -> { MatrixPatterns.isUserId(userId) -> {
ParsedCommand.Invite( ParsedCommand.Invite(
userId, userId,
textMessage.substring(Command.INVITE.length + userId.length) trimParts(textMessage, messageParts.take(2))
.trim()
.takeIf { it.isNotBlank() }
) )
} }
userId.isEmail() -> { userId.isEmail() -> {
@ -215,34 +207,30 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.INVITE) ParsedCommand.ErrorSyntax(Command.INVITE)
} }
} }
Command.KICK_USER.command -> { Command.REMOVE_USER.matches(slashCommand) -> {
if (messageParts.size >= 2) { if (messageParts.size >= 2) {
val userId = messageParts[1] val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) { if (MatrixPatterns.isUserId(userId)) {
ParsedCommand.KickUser( ParsedCommand.RemoveUser(
userId, userId,
textMessage.substring(Command.KICK_USER.length + userId.length) trimParts(textMessage, messageParts.take(2))
.trim()
.takeIf { it.isNotBlank() }
) )
} else { } else {
ParsedCommand.ErrorSyntax(Command.KICK_USER) ParsedCommand.ErrorSyntax(Command.REMOVE_USER)
} }
} else { } else {
ParsedCommand.ErrorSyntax(Command.KICK_USER) ParsedCommand.ErrorSyntax(Command.REMOVE_USER)
} }
} }
Command.BAN_USER.command -> { Command.BAN_USER.matches(slashCommand) -> {
if (messageParts.size >= 2) { if (messageParts.size >= 2) {
val userId = messageParts[1] val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) { if (MatrixPatterns.isUserId(userId)) {
ParsedCommand.BanUser( ParsedCommand.BanUser(
userId, userId,
textMessage.substring(Command.BAN_USER.length + userId.length) trimParts(textMessage, messageParts.take(2))
.trim()
.takeIf { it.isNotBlank() }
) )
} else { } else {
ParsedCommand.ErrorSyntax(Command.BAN_USER) ParsedCommand.ErrorSyntax(Command.BAN_USER)
@ -251,16 +239,14 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.BAN_USER) ParsedCommand.ErrorSyntax(Command.BAN_USER)
} }
} }
Command.UNBAN_USER.command -> { Command.UNBAN_USER.matches(slashCommand) -> {
if (messageParts.size >= 2) { if (messageParts.size >= 2) {
val userId = messageParts[1] val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) { if (MatrixPatterns.isUserId(userId)) {
ParsedCommand.UnbanUser( ParsedCommand.UnbanUser(
userId, userId,
textMessage.substring(Command.UNBAN_USER.length + userId.length) trimParts(textMessage, messageParts.take(2))
.trim()
.takeIf { it.isNotBlank() }
) )
} else { } else {
ParsedCommand.ErrorSyntax(Command.UNBAN_USER) ParsedCommand.ErrorSyntax(Command.UNBAN_USER)
@ -269,7 +255,7 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.UNBAN_USER) ParsedCommand.ErrorSyntax(Command.UNBAN_USER)
} }
} }
Command.IGNORE_USER.command -> { Command.IGNORE_USER.matches(slashCommand) -> {
if (messageParts.size == 2) { if (messageParts.size == 2) {
val userId = messageParts[1] val userId = messageParts[1]
@ -282,7 +268,7 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.IGNORE_USER) ParsedCommand.ErrorSyntax(Command.IGNORE_USER)
} }
} }
Command.UNIGNORE_USER.command -> { Command.UNIGNORE_USER.matches(slashCommand) -> {
if (messageParts.size == 2) { if (messageParts.size == 2) {
val userId = messageParts[1] val userId = messageParts[1]
@ -295,7 +281,7 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.UNIGNORE_USER) ParsedCommand.ErrorSyntax(Command.UNIGNORE_USER)
} }
} }
Command.SET_USER_POWER_LEVEL.command -> { Command.SET_USER_POWER_LEVEL.matches(slashCommand) -> {
if (messageParts.size == 3) { if (messageParts.size == 3) {
val userId = messageParts[1] val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) { if (MatrixPatterns.isUserId(userId)) {
@ -315,7 +301,7 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL) ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL)
} }
} }
Command.RESET_USER_POWER_LEVEL.command -> { Command.RESET_USER_POWER_LEVEL.matches(slashCommand) -> {
if (messageParts.size == 2) { if (messageParts.size == 2) {
val userId = messageParts[1] val userId = messageParts[1]
@ -328,7 +314,7 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL) ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL)
} }
} }
Command.MARKDOWN.command -> { Command.MARKDOWN.matches(slashCommand) -> {
if (messageParts.size == 2) { if (messageParts.size == 2) {
when { when {
"on".equals(messageParts[1], true) -> ParsedCommand.SetMarkdown(true) "on".equals(messageParts[1], true) -> ParsedCommand.SetMarkdown(true)
@ -339,31 +325,34 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.MARKDOWN) ParsedCommand.ErrorSyntax(Command.MARKDOWN)
} }
} }
Command.CLEAR_SCALAR_TOKEN.command -> { Command.CLEAR_SCALAR_TOKEN.matches(slashCommand) -> {
if (messageParts.size == 1) { if (messageParts.size == 1) {
ParsedCommand.ClearScalarToken ParsedCommand.ClearScalarToken
} else { } else {
ParsedCommand.ErrorSyntax(Command.CLEAR_SCALAR_TOKEN) ParsedCommand.ErrorSyntax(Command.CLEAR_SCALAR_TOKEN)
} }
} }
Command.SPOILER.command -> { Command.SPOILER.matches(slashCommand) -> {
val message = textMessage.substring(Command.SPOILER.command.length).trim() if (message.isNotEmpty()) {
ParsedCommand.SendSpoiler(message) ParsedCommand.SendSpoiler(message)
} else {
ParsedCommand.ErrorSyntax(Command.SPOILER)
} }
Command.SHRUG.command -> { }
val message = textMessage.substring(Command.SHRUG.command.length).trim() Command.SHRUG.matches(slashCommand) -> {
ParsedCommand.SendShrug(message) ParsedCommand.SendShrug(message)
} }
Command.LENNY.command -> { Command.LENNY.matches(slashCommand) -> {
val message = textMessage.substring(Command.LENNY.command.length).trim()
ParsedCommand.SendLenny(message) ParsedCommand.SendLenny(message)
} }
Command.DISCARD_SESSION.command -> { Command.DISCARD_SESSION.matches(slashCommand) -> {
if (messageParts.size == 1) {
ParsedCommand.DiscardSession ParsedCommand.DiscardSession
} else {
ParsedCommand.ErrorSyntax(Command.DISCARD_SESSION)
} }
Command.WHOIS.command -> { }
Command.WHOIS.matches(slashCommand) -> {
if (messageParts.size == 2) { if (messageParts.size == 2) {
val userId = messageParts[1] val userId = messageParts[1]
@ -376,50 +365,44 @@ object CommandParser {
ParsedCommand.ErrorSyntax(Command.WHOIS) ParsedCommand.ErrorSyntax(Command.WHOIS)
} }
} }
Command.CONFETTI.command -> { Command.CONFETTI.matches(slashCommand) -> {
val message = textMessage.substring(Command.CONFETTI.command.length).trim()
ParsedCommand.SendChatEffect(ChatEffect.CONFETTI, message) ParsedCommand.SendChatEffect(ChatEffect.CONFETTI, message)
} }
Command.SNOWFALL.command -> { Command.SNOWFALL.matches(slashCommand) -> {
val message = textMessage.substring(Command.SNOWFALL.command.length).trim()
ParsedCommand.SendChatEffect(ChatEffect.SNOWFALL, message) ParsedCommand.SendChatEffect(ChatEffect.SNOWFALL, message)
} }
Command.CREATE_SPACE.command -> { Command.CREATE_SPACE.matches(slashCommand) -> {
val rawCommand = textMessage.substring(Command.CREATE_SPACE.command.length).trim() if (messageParts.size >= 2) {
val split = rawCommand.split(" ").map { it.trim() }
if (split.isEmpty()) {
ParsedCommand.ErrorSyntax(Command.CREATE_SPACE)
} else {
ParsedCommand.CreateSpace( ParsedCommand.CreateSpace(
split[0], messageParts[1],
split.subList(1, split.size) messageParts.drop(2)
) )
}
}
Command.ADD_TO_SPACE.command -> {
val rawCommand = textMessage.substring(Command.ADD_TO_SPACE.command.length).trim()
ParsedCommand.AddToSpace(
rawCommand
)
}
Command.JOIN_SPACE.command -> {
val spaceIdOrAlias = textMessage.substring(Command.JOIN_SPACE.command.length).trim()
ParsedCommand.JoinSpace(
spaceIdOrAlias
)
}
Command.LEAVE_ROOM.command -> {
val spaceIdOrAlias = textMessage.substring(Command.LEAVE_ROOM.command.length).trim()
ParsedCommand.LeaveRoom(
spaceIdOrAlias
)
}
Command.UPGRADE_ROOM.command -> {
val newVersion = textMessage.substring(Command.UPGRADE_ROOM.command.length).trim()
if (newVersion.isEmpty()) {
ParsedCommand.ErrorSyntax(Command.UPGRADE_ROOM)
} else { } else {
ParsedCommand.UpgradeRoom(newVersion) ParsedCommand.ErrorSyntax(Command.CREATE_SPACE)
}
}
Command.ADD_TO_SPACE.matches(slashCommand) -> {
if (messageParts.size == 1) {
ParsedCommand.AddToSpace(spaceId = message)
} else {
ParsedCommand.ErrorSyntax(Command.ADD_TO_SPACE)
}
}
Command.JOIN_SPACE.matches(slashCommand) -> {
if (messageParts.size == 1) {
ParsedCommand.JoinSpace(spaceIdOrAlias = message)
} else {
ParsedCommand.ErrorSyntax(Command.JOIN_SPACE)
}
}
Command.LEAVE_ROOM.matches(slashCommand) -> {
ParsedCommand.LeaveRoom(roomId = message)
}
Command.UPGRADE_ROOM.matches(slashCommand) -> {
if (message.isNotEmpty()) {
ParsedCommand.UpgradeRoom(newVersion = message)
} else {
ParsedCommand.ErrorSyntax(Command.UPGRADE_ROOM)
} }
} }
else -> { else -> {
@ -429,4 +412,10 @@ object CommandParser {
} }
} }
} }
private fun trimParts(message: CharSequence, messageParts: List<String>): String? {
val partsSize = messageParts.sumOf { it.length }
val gapsNumber = messageParts.size - 1
return message.substring(partsSize + gapsNumber).trim().takeIf { it.isNotEmpty() }
}
} }

View file

@ -53,7 +53,7 @@ sealed class ParsedCommand {
class JoinRoom(val roomAlias: String, val reason: String?) : ParsedCommand() class JoinRoom(val roomAlias: String, val reason: String?) : ParsedCommand()
class PartRoom(val roomAlias: String?) : ParsedCommand() class PartRoom(val roomAlias: String?) : ParsedCommand()
class ChangeTopic(val topic: String) : ParsedCommand() class ChangeTopic(val topic: String) : ParsedCommand()
class KickUser(val userId: String, val reason: String?) : ParsedCommand() class RemoveUser(val userId: String, val reason: String?) : ParsedCommand()
class ChangeDisplayName(val displayName: String) : ParsedCommand() class ChangeDisplayName(val displayName: String) : ParsedCommand()
class ChangeDisplayNameForRoom(val displayName: String) : ParsedCommand() class ChangeDisplayNameForRoom(val displayName: String) : ParsedCommand()
class ChangeRoomAvatar(val url: String) : ParsedCommand() class ChangeRoomAvatar(val url: String) : ParsedCommand()

View file

@ -26,10 +26,10 @@ import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.activityViewModel
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.flow.throttleFirst
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.startImportTextFromFileIntent import im.vector.app.core.utils.startImportTextFromFileIntent
import im.vector.app.databinding.FragmentSsssAccessFromKeyBinding import im.vector.app.databinding.FragmentSsssAccessFromKeyBinding
import im.vector.lib.core.utils.flow.throttleFirst
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull

View file

@ -25,10 +25,10 @@ import androidx.core.text.toSpannable
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.activityViewModel
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.flow.throttleFirst
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.databinding.FragmentSsssAccessFromPassphraseBinding import im.vector.app.databinding.FragmentSsssAccessFromPassphraseBinding
import im.vector.lib.core.utils.flow.throttleFirst
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.widget.editorActionEvents import reactivecircus.flowbinding.android.widget.editorActionEvents

View file

@ -27,9 +27,9 @@ import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.flow.throttleFirst
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentBootstrapEnterPassphraseBinding import im.vector.app.databinding.FragmentBootstrapEnterPassphraseBinding
import im.vector.lib.core.utils.flow.throttleFirst
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.widget.editorActionEvents import reactivecircus.flowbinding.android.widget.editorActionEvents

View file

@ -25,10 +25,10 @@ import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.parentFragmentViewModel import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.flow.throttleFirst
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentBootstrapEnterPassphraseBinding import im.vector.app.databinding.FragmentBootstrapEnterPassphraseBinding
import im.vector.app.features.settings.VectorLocale import im.vector.app.features.settings.VectorLocale
import im.vector.lib.core.utils.flow.throttleFirst
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.widget.editorActionEvents import reactivecircus.flowbinding.android.widget.editorActionEvents

View file

@ -33,12 +33,12 @@ import com.airbnb.mvrx.withState
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.flow.throttleFirst
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.utils.colorizeMatchingText import im.vector.app.core.utils.colorizeMatchingText
import im.vector.app.core.utils.startImportTextFromFileIntent import im.vector.app.core.utils.startImportTextFromFileIntent
import im.vector.app.databinding.FragmentBootstrapMigrateBackupBinding import im.vector.app.databinding.FragmentBootstrapMigrateBackupBinding
import im.vector.lib.core.utils.flow.throttleFirst
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull

View file

@ -174,8 +174,8 @@ class RoomDevToolViewModel @AssistedInject constructor(
?: throw IllegalArgumentException(stringProvider.getString(R.string.dev_tools_error_no_content)) ?: throw IllegalArgumentException(stringProvider.getString(R.string.dev_tools_error_no_content))
room.sendStateEvent( room.sendStateEvent(
state.selectedEvent?.type ?: "", state.selectedEvent?.type.orEmpty(),
state.selectedEvent?.stateKey, state.selectedEvent?.stateKey.orEmpty(),
json json
) )
@ -213,7 +213,7 @@ class RoomDevToolViewModel @AssistedInject constructor(
if (isState) { if (isState) {
room.sendStateEvent( room.sendStateEvent(
eventType, eventType,
state.sendEventDraft.stateKey, state.sendEventDraft.stateKey.orEmpty(),
json json
) )
} else { } else {

View file

@ -27,7 +27,6 @@ import im.vector.app.RoomGroupingMethod
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.extensions.singletonEntryPoint import im.vector.app.core.extensions.singletonEntryPoint
import im.vector.app.core.flow.throttleFirst
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.call.dialpad.DialPadLookup
import im.vector.app.features.call.lookup.CallProtocolsChecker import im.vector.app.features.call.lookup.CallProtocolsChecker
@ -37,6 +36,7 @@ import im.vector.app.features.invite.AutoAcceptInvites
import im.vector.app.features.invite.showInvites import im.vector.app.features.invite.showInvites
import im.vector.app.features.settings.VectorDataStore import im.vector.app.features.settings.VectorDataStore
import im.vector.app.features.ui.UiStateRepository import im.vector.app.features.ui.UiStateRepository
import im.vector.lib.core.utils.flow.throttleFirst
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
@ -197,7 +197,7 @@ class HomeDetailViewModel @AssistedInject constructor(
} }
private fun observeRoomGroupingMethod() { private fun observeRoomGroupingMethod() {
appStateHandler.selectedRoomGroupingObservable appStateHandler.selectedRoomGroupingFlow
.setOnEach { .setOnEach {
copy( copy(
roomGroupingMethod = it.orNull() ?: RoomGroupingMethod.BySpace(null) roomGroupingMethod = it.orNull() ?: RoomGroupingMethod.BySpace(null)
@ -206,7 +206,7 @@ class HomeDetailViewModel @AssistedInject constructor(
} }
private fun observeRoomSummaries() { private fun observeRoomSummaries() {
appStateHandler.selectedRoomGroupingObservable.distinctUntilChanged().flatMapLatest { appStateHandler.selectedRoomGroupingFlow.distinctUntilChanged().flatMapLatest {
// we use it as a trigger to all changes in room, but do not really load // we use it as a trigger to all changes in room, but do not really load
// the actual models // the actual models
session.getPagedRoomSummariesLive( session.getPagedRoomSummariesLive(

View file

@ -50,7 +50,7 @@ class PromoteRestrictedViewModel @AssistedInject constructor(
) : VectorViewModel<ActiveSpaceViewState, EmptyAction, EmptyViewEvents>(initialState) { ) : VectorViewModel<ActiveSpaceViewState, EmptyAction, EmptyViewEvents>(initialState) {
init { init {
appStateHandler.selectedRoomGroupingObservable.distinctUntilChanged().execute { state -> appStateHandler.selectedRoomGroupingFlow.distinctUntilChanged().execute { state ->
val groupingMethod = state.invoke()?.orNull() val groupingMethod = state.invoke()?.orNull()
val isSpaceMode = groupingMethod is RoomGroupingMethod.BySpace val isSpaceMode = groupingMethod is RoomGroupingMethod.BySpace
val currentSpace = (groupingMethod as? RoomGroupingMethod.BySpace)?.spaceSummary val currentSpace = (groupingMethod as? RoomGroupingMethod.BySpace)?.spaceSummary

View file

@ -26,12 +26,12 @@ import im.vector.app.AppStateHandler
import im.vector.app.RoomGroupingMethod import im.vector.app.RoomGroupingMethod
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.flow.throttleFirst
import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.invite.AutoAcceptInvites
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.lib.core.utils.flow.throttleFirst
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -107,8 +107,8 @@ class UnreadMessagesSharedViewModel @AssistedInject constructor(@Assisted initia
} }
combine( combine(
appStateHandler.selectedRoomGroupingObservable.distinctUntilChanged(), appStateHandler.selectedRoomGroupingFlow.distinctUntilChanged(),
appStateHandler.selectedRoomGroupingObservable.flatMapLatest { appStateHandler.selectedRoomGroupingFlow.flatMapLatest {
session.getPagedRoomSummariesLive( session.getPagedRoomSummariesLive(
roomSummaryQueryParams { roomSummaryQueryParams {
this.memberships = Membership.activeMemberships() this.memberships = Membership.activeMemberships()

View file

@ -2256,7 +2256,7 @@ class TimelineFragment @Inject constructor(
userId == session.myUserId) { userId == session.myUserId) {
// Empty composer, current user: start an emote // Empty composer, current user: start an emote
views.composerLayout.views.composerEditText.setText(Command.EMOTE.command + " ") views.composerLayout.views.composerEditText.setText(Command.EMOTE.command + " ")
views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.length) views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1)
} else { } else {
val roomMember = timelineViewModel.getMember(userId) val roomMember = timelineViewModel.getMember(userId)
// TODO move logic outside of fragment // TODO move logic outside of fragment

View file

@ -33,7 +33,6 @@ import im.vector.app.R
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.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.flow.chunk
import im.vector.app.core.mvrx.runCatchingToAsync import im.vector.app.core.mvrx.runCatchingToAsync
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
@ -55,6 +54,7 @@ import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorDataStore import im.vector.app.features.settings.VectorDataStore
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.lib.core.utils.flow.chunk
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine

View file

@ -183,7 +183,9 @@ class MessageComposerViewModel @AssistedInject constructor(
withState { state -> withState { state ->
when (state.sendMode) { when (state.sendMode) {
is SendMode.Regular -> { is SendMode.Regular -> {
when (val slashCommandResult = CommandParser.parseSplashCommand(action.text, state.isInThreadTimeline())) { when (val slashCommandResult = CommandParser.parseSplashCommand(
textMessage = action.text,
isInThreadTimeline = state.isInThreadTimeline())) {
is ParsedCommand.ErrorNotACommand -> { is ParsedCommand.ErrorNotACommand -> {
// Send the text message to the room // Send the text message to the room
if (state.rootThreadEventId != null) { if (state.rootThreadEventId != null) {
@ -257,8 +259,8 @@ class MessageComposerViewModel @AssistedInject constructor(
is ParsedCommand.UnignoreUser -> { is ParsedCommand.UnignoreUser -> {
handleUnignoreSlashCommand(slashCommandResult) handleUnignoreSlashCommand(slashCommandResult)
} }
is ParsedCommand.KickUser -> { is ParsedCommand.RemoveUser -> {
handleKickSlashCommand(slashCommandResult) handleRemoveSlashCommand(slashCommandResult)
} }
is ParsedCommand.JoinRoom -> { is ParsedCommand.JoinRoom -> {
handleJoinToAnotherRoomSlashCommand(slashCommandResult) handleJoinToAnotherRoomSlashCommand(slashCommandResult)
@ -625,7 +627,7 @@ class MessageComposerViewModel @AssistedInject constructor(
?: return ?: return
launchSlashCommandFlowSuspendable { launchSlashCommandFlowSuspendable {
room.sendStateEvent(EventType.STATE_ROOM_POWER_LEVELS, null, newPowerLevelsContent) room.sendStateEvent(EventType.STATE_ROOM_POWER_LEVELS, stateKey = "", newPowerLevelsContent)
} }
} }
@ -649,9 +651,9 @@ class MessageComposerViewModel @AssistedInject constructor(
} }
} }
private fun handleKickSlashCommand(kick: ParsedCommand.KickUser) { private fun handleRemoveSlashCommand(removeUser: ParsedCommand.RemoveUser) {
launchSlashCommandFlowSuspendable { launchSlashCommandFlowSuspendable {
room.kick(kick.userId, kick.reason) room.remove(removeUser.userId, removeUser.reason)
} }
} }
@ -692,7 +694,7 @@ class MessageComposerViewModel @AssistedInject constructor(
private fun handleChangeRoomAvatarSlashCommand(changeAvatar: ParsedCommand.ChangeRoomAvatar) { private fun handleChangeRoomAvatarSlashCommand(changeAvatar: ParsedCommand.ChangeRoomAvatar) {
launchSlashCommandFlowSuspendable { launchSlashCommandFlowSuspendable {
room.sendStateEvent(EventType.STATE_ROOM_AVATAR, null, RoomAvatarContent(changeAvatar.url).toContent()) room.sendStateEvent(EventType.STATE_ROOM_AVATAR, stateKey = "", RoomAvatarContent(changeAvatar.url).toContent())
} }
} }

View file

@ -21,11 +21,11 @@ import android.media.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voice.VoiceFailure
import im.vector.app.features.voice.VoiceRecorder import im.vector.app.features.voice.VoiceRecorder
import im.vector.app.features.voice.VoiceRecorderProvider import im.vector.app.features.voice.VoiceRecorderProvider
import im.vector.lib.core.utils.timer.CountUpTimer
import im.vector.lib.multipicker.entity.MultiPickerAudioType import im.vector.lib.multipicker.entity.MultiPickerAudioType
import im.vector.lib.multipicker.utils.toMultiPickerAudioType import im.vector.lib.multipicker.utils.toMultiPickerAudioType
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse

View file

@ -26,10 +26,10 @@ import im.vector.app.R
import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.hardware.vibrate import im.vector.app.core.hardware.vibrate
import im.vector.app.core.time.Clock import im.vector.app.core.time.Clock
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.lib.core.utils.timer.CountUpTimer
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.floor import kotlin.math.floor

View file

@ -104,7 +104,7 @@ class RoomListSectionBuilderGroup(
} }
} }
appStateHandler.selectedRoomGroupingObservable appStateHandler.selectedRoomGroupingFlow
.distinctUntilChanged() .distinctUntilChanged()
.onEach { groupingMethod -> .onEach { groupingMethod ->
val selectedGroupId = (groupingMethod.orNull() as? RoomGroupingMethod.ByLegacyGroup)?.groupSummary?.groupId val selectedGroupId = (groupingMethod.orNull() as? RoomGroupingMethod.ByLegacyGroup)?.groupSummary?.groupId

View file

@ -132,7 +132,7 @@ class RoomListSectionBuilderSpace(
} }
} }
appStateHandler.selectedRoomGroupingObservable appStateHandler.selectedRoomGroupingFlow
.distinctUntilChanged() .distinctUntilChanged()
.onEach { groupingMethod -> .onEach { groupingMethod ->
val selectedSpace = groupingMethod.orNull()?.space() val selectedSpace = groupingMethod.orNull()?.space()
@ -222,7 +222,7 @@ class RoomListSectionBuilderSpace(
// add suggested rooms // add suggested rooms
val suggestedRoomsFlow = // MutableLiveData<List<SpaceChildInfo>>() val suggestedRoomsFlow = // MutableLiveData<List<SpaceChildInfo>>()
appStateHandler.selectedRoomGroupingObservable appStateHandler.selectedRoomGroupingFlow
.distinctUntilChanged() .distinctUntilChanged()
.flatMapLatest { groupingMethod -> .flatMapLatest { groupingMethod ->
val selectedSpace = groupingMethod.orNull()?.space() val selectedSpace = groupingMethod.orNull()?.space()

View file

@ -92,7 +92,7 @@ class RoomListViewModel @AssistedInject constructor(
init { init {
observeMembershipChanges() observeMembershipChanges()
appStateHandler.selectedRoomGroupingObservable appStateHandler.selectedRoomGroupingFlow
.distinctUntilChanged() .distinctUntilChanged()
.execute { .execute {
copy( copy(

View file

@ -317,8 +317,8 @@ class DefaultNavigator @Inject constructor(
} }
} }
override fun openCreateRoom(context: Context, initialName: String) { override fun openCreateRoom(context: Context, initialName: String, openAfterCreate: Boolean) {
val intent = CreateRoomActivity.getIntent(context, initialName) val intent = CreateRoomActivity.getIntent(context = context, initialName = initialName, openAfterCreate = openAfterCreate)
context.startActivity(intent) context.startActivity(intent)
} }

View file

@ -77,7 +77,7 @@ interface Navigator {
fun openMatrixToBottomSheet(context: Context, link: String) fun openMatrixToBottomSheet(context: Context, link: String)
fun openCreateRoom(context: Context, initialName: String = "") fun openCreateRoom(context: Context, initialName: String = "", openAfterCreate: Boolean = true)
fun openCreateDirectRoom(context: Context) fun openCreateDirectRoom(context: Context)

View file

@ -1,5 +1,5 @@
/* /*
* Copyright 2020 The Matrix.org Foundation C.I.C. * Copyright (c) 2022 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,14 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package org.matrix.android.sdk.rx package im.vector.app.features.onboarding
data class SecretsSynchronisationInfo( enum class FtueUseCase {
val isBackupSetup: Boolean, FRIENDS_FAMILY,
val isCrossSigningEnabled: Boolean, TEAMS,
val isCrossSigningTrusted: Boolean, COMMUNITIES,
val allPrivateKeysKnown: Boolean, SKIP
val megolmBackupAvailable: Boolean, }
val megolmSecretKnown: Boolean,
val isMegolmKeyIn4S: Boolean
)

View file

@ -31,6 +31,8 @@ sealed class OnboardingAction : VectorViewModelAction {
data class UpdateServerType(val serverType: ServerType) : OnboardingAction() data class UpdateServerType(val serverType: ServerType) : OnboardingAction()
data class UpdateHomeServer(val homeServerUrl: String) : OnboardingAction() data class UpdateHomeServer(val homeServerUrl: String) : OnboardingAction()
data class UpdateUseCase(val useCase: FtueUseCase) : OnboardingAction()
object ResetUseCase : OnboardingAction()
data class UpdateSignMode(val signMode: SignMode) : OnboardingAction() data class UpdateSignMode(val signMode: SignMode) : OnboardingAction()
data class LoginWithToken(val loginToken: String) : OnboardingAction() data class LoginWithToken(val loginToken: String) : OnboardingAction()
data class WebLoginSuccess(val credentials: Credentials) : OnboardingAction() data class WebLoginSuccess(val credentials: Credentials) : OnboardingAction()

View file

@ -16,6 +16,7 @@
package im.vector.app.features.onboarding package im.vector.app.features.onboarding
import im.vector.app.core.platform.ScreenOrientationLocker
import im.vector.app.databinding.ActivityLoginBinding import im.vector.app.databinding.ActivityLoginBinding
import im.vector.app.features.VectorFeatures import im.vector.app.features.VectorFeatures
import im.vector.app.features.login2.LoginViewModel2 import im.vector.app.features.login2.LoginViewModel2
@ -24,6 +25,7 @@ import javax.inject.Inject
class OnboardingVariantFactory @Inject constructor( class OnboardingVariantFactory @Inject constructor(
private val vectorFeatures: VectorFeatures, private val vectorFeatures: VectorFeatures,
private val orientationLocker: ScreenOrientationLocker,
) { ) {
fun create(activity: OnboardingActivity, fun create(activity: OnboardingActivity,
@ -37,7 +39,8 @@ class OnboardingVariantFactory @Inject constructor(
onboardingViewModel = onboardingViewModel.value, onboardingViewModel = onboardingViewModel.value,
activity = activity, activity = activity,
supportFragmentManager = activity.supportFragmentManager, supportFragmentManager = activity.supportFragmentManager,
vectorFeatures = vectorFeatures vectorFeatures = vectorFeatures,
orientationLocker = orientationLocker
) )
VectorFeatures.OnboardingVariant.LOGIN_2 -> Login2Variant( VectorFeatures.OnboardingVariant.LOGIN_2 -> Login2Variant(
views = views, views = views,

View file

@ -34,6 +34,7 @@ sealed class OnboardingViewEvents : VectorViewEvents {
// Navigation event // Navigation event
object OpenUseCaseSelection : OnboardingViewEvents()
object OpenServerSelection : OnboardingViewEvents() object OpenServerSelection : OnboardingViewEvents()
data class OnServerSelectionDone(val serverType: ServerType) : OnboardingViewEvents() data class OnServerSelectionDone(val serverType: ServerType) : OnboardingViewEvents()
object OnLoginFlowRetrieved : OnboardingViewEvents() object OnLoginFlowRetrieved : OnboardingViewEvents()

View file

@ -35,6 +35,7 @@ import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.ensureTrailingSlash import im.vector.app.core.utils.ensureTrailingSlash
import im.vector.app.features.VectorFeatures
import im.vector.app.features.login.HomeServerConnectionConfigFactory import im.vector.app.features.login.HomeServerConnectionConfigFactory
import im.vector.app.features.login.LoginConfig import im.vector.app.features.login.LoginConfig
import im.vector.app.features.login.LoginMode import im.vector.app.features.login.LoginMode
@ -71,7 +72,8 @@ class OnboardingViewModel @AssistedInject constructor(
private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory, private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory,
private val reAuthHelper: ReAuthHelper, private val reAuthHelper: ReAuthHelper,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val homeServerHistoryService: HomeServerHistoryService private val homeServerHistoryService: HomeServerHistoryService,
private val vectorFeatures: VectorFeatures
) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) { ) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) {
@AssistedFactory @AssistedFactory
@ -123,6 +125,8 @@ class OnboardingViewModel @AssistedInject constructor(
when (action) { when (action) {
is OnboardingAction.OnGetStarted -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow) is OnboardingAction.OnGetStarted -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow)
is OnboardingAction.OnIAlreadyHaveAnAccount -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow) is OnboardingAction.OnIAlreadyHaveAnAccount -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow)
is OnboardingAction.UpdateUseCase -> handleUpdateUseCase()
OnboardingAction.ResetUseCase -> resetUseCase()
is OnboardingAction.UpdateServerType -> handleUpdateServerType(action) is OnboardingAction.UpdateServerType -> handleUpdateServerType(action)
is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action) is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action)
is OnboardingAction.InitWith -> handleInitWith(action) is OnboardingAction.InitWith -> handleInitWith(action)
@ -154,15 +158,28 @@ class OnboardingViewModel @AssistedInject constructor(
if (homeServerConnectionConfig == null) { if (homeServerConnectionConfig == null) {
// Url is invalid, in this case, just use the regular flow // Url is invalid, in this case, just use the regular flow
Timber.w("Url from config url was invalid: $configUrl") Timber.w("Url from config url was invalid: $configUrl")
_viewEvents.post(OnboardingViewEvents.OpenServerSelection) continueToPageAfterSplash(onboardingFlow)
} else { } else {
getLoginFlow(homeServerConnectionConfig, ServerType.Other) getLoginFlow(homeServerConnectionConfig, ServerType.Other)
} }
} else { } else {
_viewEvents.post(OnboardingViewEvents.OpenServerSelection) continueToPageAfterSplash(onboardingFlow)
} }
} }
private fun continueToPageAfterSplash(onboardingFlow: OnboardingFlow) {
val nextOnboardingStep = when (onboardingFlow) {
OnboardingFlow.SignUp -> if (vectorFeatures.isOnboardingUseCaseEnabled()) {
OnboardingViewEvents.OpenUseCaseSelection
} else {
OnboardingViewEvents.OpenServerSelection
}
OnboardingFlow.SignIn,
OnboardingFlow.SignInSignUp -> OnboardingViewEvents.OpenServerSelection
}
_viewEvents.post(nextOnboardingStep)
}
private fun handleUserAcceptCertificate(action: OnboardingAction.UserAcceptCertificate) { private fun handleUserAcceptCertificate(action: OnboardingAction.UserAcceptCertificate) {
// It happens when we get the login flow, or during direct authentication. // It happens when we get the login flow, or during direct authentication.
// So alter the homeserver config and retrieve again the login flow // So alter the homeserver config and retrieve again the login flow
@ -441,6 +458,15 @@ class OnboardingViewModel @AssistedInject constructor(
} }
} }
private fun handleUpdateUseCase() {
// TODO act on the use case selection
_viewEvents.post(OnboardingViewEvents.OpenServerSelection)
}
private fun resetUseCase() {
// TODO remove stored use case
}
private fun handleUpdateServerType(action: OnboardingAction.UpdateServerType) { private fun handleUpdateServerType(action: OnboardingAction.UpdateServerType) {
setState { setState {
copy( copy(

View file

@ -49,7 +49,8 @@ private const val CAROUSEL_TRANSITION_TIME_MS = 500L
class FtueAuthSplashCarouselFragment @Inject constructor( class FtueAuthSplashCarouselFragment @Inject constructor(
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val vectorFeatures: VectorFeatures, private val vectorFeatures: VectorFeatures,
private val carouselController: SplashCarouselController private val carouselController: SplashCarouselController,
private val carouselStateFactory: SplashCarouselStateFactory
) : AbstractFtueAuthFragment<FragmentFtueSplashCarouselBinding>() { ) : AbstractFtueAuthFragment<FragmentFtueSplashCarouselBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueSplashCarouselBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueSplashCarouselBinding {
@ -65,11 +66,11 @@ class FtueAuthSplashCarouselFragment @Inject constructor(
val carouselAdapter = carouselController.adapter val carouselAdapter = carouselController.adapter
views.splashCarousel.adapter = carouselAdapter views.splashCarousel.adapter = carouselAdapter
TabLayoutMediator(views.carouselIndicator, views.splashCarousel) { _, _ -> }.attach() TabLayoutMediator(views.carouselIndicator, views.splashCarousel) { _, _ -> }.attach()
carouselController.setData(SplashCarouselState()) carouselController.setData(carouselStateFactory.create())
views.loginSplashSubmit.debouncedClicks { getStarted() } views.loginSplashSubmit.debouncedClicks { getStarted() }
views.loginSplashAlreadyHaveAccount.apply { views.loginSplashAlreadyHaveAccount.apply {
isVisible = vectorFeatures.isAlreadyHaveAccountSplashEnabled() isVisible = vectorFeatures.isOnboardingAlreadyHaveAccountSplashEnabled()
debouncedClicks { alreadyHaveAnAccount() } debouncedClicks { alreadyHaveAnAccount() }
} }
@ -80,17 +81,26 @@ class FtueAuthSplashCarouselFragment @Inject constructor(
"Branch: ${BuildConfig.GIT_BRANCH_NAME}" "Branch: ${BuildConfig.GIT_BRANCH_NAME}"
views.loginSplashVersion.debouncedClicks { navigator.openDebug(requireContext()) } views.loginSplashVersion.debouncedClicks { navigator.openDebug(requireContext()) }
} }
views.splashCarousel.registerAutomaticUntilInteractionTransitions()
}
views.splashCarousel.apply { private fun ViewPager2.registerAutomaticUntilInteractionTransitions() {
var scheduledTransition: Job? = null var scheduledTransition: Job? = null
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
private var hasUserManuallyInteractedWithCarousel: Boolean = false
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
hasUserManuallyInteractedWithCarousel = !isFakeDragging
}
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
scheduledTransition?.cancel() scheduledTransition?.cancel()
// only schedule automatic transitions whilst the user has not interacted with the carousel
if (!hasUserManuallyInteractedWithCarousel) {
scheduledTransition = scheduleCarouselTransition() scheduledTransition = scheduleCarouselTransition()
} }
}
}) })
scheduledTransition = scheduleCarouselTransition()
}
} }
private fun ViewPager2.scheduleCarouselTransition(): Job { private fun ViewPager2.scheduleCarouselTransition(): Job {
@ -102,7 +112,7 @@ class FtueAuthSplashCarouselFragment @Inject constructor(
} }
private fun getStarted() { private fun getStarted() {
val getStartedFlow = if (vectorFeatures.isAlreadyHaveAccountSplashEnabled()) OnboardingFlow.SignUp else OnboardingFlow.SignInSignUp val getStartedFlow = if (vectorFeatures.isOnboardingAlreadyHaveAccountSplashEnabled()) OnboardingFlow.SignUp else OnboardingFlow.SignInSignUp
viewModel.handle(OnboardingAction.OnGetStarted(resetLoginConfig = false, onboardingFlow = getStartedFlow)) viewModel.handle(OnboardingAction.OnGetStarted(resetLoginConfig = false, onboardingFlow = getStartedFlow))
} }

View file

@ -55,7 +55,7 @@ class FtueAuthSplashFragment @Inject constructor(
private fun setupViews() { private fun setupViews() {
views.loginSplashSubmit.debouncedClicks { getStarted() } views.loginSplashSubmit.debouncedClicks { getStarted() }
views.loginSplashAlreadyHaveAccount.apply { views.loginSplashAlreadyHaveAccount.apply {
isVisible = vectorFeatures.isAlreadyHaveAccountSplashEnabled() isVisible = vectorFeatures.isOnboardingAlreadyHaveAccountSplashEnabled()
debouncedClicks { alreadyHaveAnAccount() } debouncedClicks { alreadyHaveAnAccount() }
} }
@ -70,7 +70,7 @@ class FtueAuthSplashFragment @Inject constructor(
} }
private fun getStarted() { private fun getStarted() {
val getStartedFlow = if (vectorFeatures.isAlreadyHaveAccountSplashEnabled()) OnboardingFlow.SignUp else OnboardingFlow.SignInSignUp val getStartedFlow = if (vectorFeatures.isOnboardingAlreadyHaveAccountSplashEnabled()) OnboardingFlow.SignUp else OnboardingFlow.SignInSignUp
viewModel.handle(OnboardingAction.OnGetStarted(resetLoginConfig = false, onboardingFlow = getStartedFlow)) viewModel.handle(OnboardingAction.OnGetStarted(resetLoginConfig = false, onboardingFlow = getStartedFlow))
} }

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.onboarding.ftueauth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.StringRes
import im.vector.app.R
import im.vector.app.core.extensions.setTextWithColoredPart
import im.vector.app.databinding.FragmentFtueAuthUseCaseBinding
import im.vector.app.features.login.ServerType
import im.vector.app.features.onboarding.FtueUseCase
import im.vector.app.features.onboarding.OnboardingAction
import javax.inject.Inject
class FtueAuthUseCaseFragment @Inject constructor() : AbstractFtueAuthFragment<FragmentFtueAuthUseCaseBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueAuthUseCaseBinding {
return FragmentFtueAuthUseCaseBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupViews()
}
private fun setupViews() {
views.useCaseOptionOne.setUseCase(R.string.ftue_auth_use_case_option_one, FtueUseCase.FRIENDS_FAMILY)
views.useCaseOptionTwo.setUseCase(R.string.ftue_auth_use_case_option_two, FtueUseCase.TEAMS)
views.useCaseOptionThree.setUseCase(R.string.ftue_auth_use_case_option_three, FtueUseCase.COMMUNITIES)
views.useCaseSkip.setTextWithColoredPart(
fullTextRes = R.string.ftue_auth_use_case_skip,
coloredTextRes = R.string.ftue_auth_use_case_skip_partial,
underline = false,
colorAttribute = R.attr.colorAccent,
onClick = { viewModel.handle(OnboardingAction.UpdateUseCase(FtueUseCase.SKIP)) }
)
views.useCaseConnectToServer.setOnClickListener {
viewModel.handle(OnboardingAction.UpdateServerType(ServerType.Other))
}
}
override fun resetViewModel() {
viewModel.handle(OnboardingAction.ResetUseCase)
}
private fun TextView.setUseCase(@StringRes label: Int, useCase: FtueUseCase) {
setText(label)
debouncedClicks {
viewModel.handle(OnboardingAction.UpdateUseCase(useCase))
}
}
}

View file

@ -15,6 +15,7 @@
*/ */
package im.vector.app.features.onboarding.ftueauth package im.vector.app.features.onboarding.ftueauth
import android.content.Intent import android.content.Intent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -31,6 +32,7 @@ import im.vector.app.core.extensions.POP_BACK_STACK_EXCLUSIVE
import im.vector.app.core.extensions.addFragment import im.vector.app.core.extensions.addFragment
import im.vector.app.core.extensions.addFragmentToBackstack import im.vector.app.core.extensions.addFragmentToBackstack
import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.ScreenOrientationLocker
import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityLoginBinding import im.vector.app.databinding.ActivityLoginBinding
import im.vector.app.features.VectorFeatures import im.vector.app.features.VectorFeatures
@ -62,7 +64,8 @@ class FtueAuthVariant(
private val onboardingViewModel: OnboardingViewModel, private val onboardingViewModel: OnboardingViewModel,
private val activity: VectorBaseActivity<ActivityLoginBinding>, private val activity: VectorBaseActivity<ActivityLoginBinding>,
private val supportFragmentManager: FragmentManager, private val supportFragmentManager: FragmentManager,
private val vectorFeatures: VectorFeatures private val vectorFeatures: VectorFeatures,
private val orientationLocker: ScreenOrientationLocker,
) : OnboardingVariant { ) : OnboardingVariant {
private val enterAnim = R.anim.enter_fade_in private val enterAnim = R.anim.enter_fade_in
@ -91,6 +94,7 @@ class FtueAuthVariant(
} }
with(activity) { with(activity) {
orientationLocker.lockPhonesToPortrait(this)
onboardingViewModel.onEach { onboardingViewModel.onEach {
updateWithState(it) updateWithState(it)
} }
@ -109,7 +113,7 @@ class FtueAuthVariant(
} }
private fun addFirstFragment() { private fun addFirstFragment() {
val splashFragment = when (vectorFeatures.isSplashCarouselEnabled()) { val splashFragment = when (vectorFeatures.isOnboardingSplashCarouselEnabled()) {
true -> FtueAuthSplashCarouselFragment::class.java true -> FtueAuthSplashCarouselFragment::class.java
else -> FtueAuthSplashFragment::class.java else -> FtueAuthSplashFragment::class.java
} }
@ -151,13 +155,16 @@ class FtueAuthVariant(
activity.addFragmentToBackstack(views.loginFragmentContainer, activity.addFragmentToBackstack(views.loginFragmentContainer,
FtueAuthServerSelectionFragment::class.java, FtueAuthServerSelectionFragment::class.java,
option = { ft -> option = { ft ->
if (vectorFeatures.isOnboardingUseCaseEnabled()) {
ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim)
} else {
activity.findViewById<View?>(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } activity.findViewById<View?>(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") }
// TODO Disabled because it provokes a flickering
// Disable transition of text // Disable transition of text
// findViewById<View?>(R.id.loginSplashTitle)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } // findViewById<View?>(R.id.loginSplashTitle)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") }
// No transition here now actually // No transition here now actually
// findViewById<View?>(R.id.loginSplashSubmit)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } // findViewById<View?>(R.id.loginSplashSubmit)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") }
// TODO Disabled because it provokes a flickering }
// ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim)
}) })
is OnboardingViewEvents.OnServerSelectionDone -> onServerSelectionDone(viewEvents) is OnboardingViewEvents.OnServerSelectionDone -> onServerSelectionDone(viewEvents)
is OnboardingViewEvents.OnSignModeSelected -> onSignModeSelected(viewEvents) is OnboardingViewEvents.OnSignModeSelected -> onSignModeSelected(viewEvents)
@ -208,6 +215,11 @@ class FtueAuthVariant(
is OnboardingViewEvents.Loading -> is OnboardingViewEvents.Loading ->
// This is handled by the Fragments // This is handled by the Fragments
Unit Unit
OnboardingViewEvents.OpenUseCaseSelection -> {
activity.addFragmentToBackstack(views.loginFragmentContainer,
FtueAuthUseCaseFragment::class.java,
option = commonOption)
}
}.exhaustive }.exhaustive
} }

View file

@ -35,7 +35,7 @@ abstract class SplashCarouselItem : VectorEpoxyModel<SplashCarouselItem.Holder>(
holder.view.setBackgroundResource(item.pageBackground) holder.view.setBackgroundResource(item.pageBackground)
holder.image.setImageResource(item.image) holder.image.setImageResource(item.image)
holder.title.setText(item.title) holder.title.text = item.title.charSequence
holder.body.setText(item.body) holder.body.setText(item.body)
} }

View file

@ -18,38 +18,13 @@ package im.vector.app.features.onboarding.ftueauth
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import im.vector.app.R import im.vector.app.core.epoxy.charsequence.EpoxyCharSequence
data class SplashCarouselState( data class SplashCarouselState(
val items: List<Item> = listOf( val items: List<Item>
Item(
R.string.ftue_auth_carousel_1_title,
R.string.ftue_auth_carousel_1_body,
R.drawable.onboarding_carousel_conversations,
R.drawable.bg_carousel_page_1
),
Item(
R.string.ftue_auth_carousel_2_title,
R.string.ftue_auth_carousel_2_body,
R.drawable.onboarding_carousel_ems,
R.drawable.bg_carousel_page_2
),
Item(
R.string.ftue_auth_carousel_3_title,
R.string.ftue_auth_carousel_3_body,
R.drawable.onboarding_carousel_connect,
R.drawable.bg_carousel_page_3
),
Item(
R.string.ftue_auth_carousel_4_title,
R.string.ftue_auth_carousel_4_body,
R.drawable.onboarding_carousel_universal,
R.drawable.bg_carousel_page_4
)
)
) { ) {
data class Item( data class Item(
@StringRes val title: Int, val title: EpoxyCharSequence,
@StringRes val body: Int, @StringRes val body: Int,
@DrawableRes val image: Int, @DrawableRes val image: Int,
@DrawableRes val pageBackground: Int @DrawableRes val pageBackground: Int

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