Merge branch 'develop' of github.com:vector-im/element-android into feature/dla/fix_reply_and_quote_newlines

This commit is contained in:
David Langley 2021-11-22 17:19:23 +00:00
commit 460596d6b3
69 changed files with 1837 additions and 1226 deletions

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

@ -0,0 +1 @@
Voice recording mic button refactor with small animation tweaks in preparation for voice drafts

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

@ -0,0 +1 @@
Fix a crash when displaying the bootstrap bottom sheet

View File

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#DDD"
android:orientation="vertical"
android:padding="16dp"
tools:ignore="HardcodedText">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Light" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@color/element_background_light"
android:orientation="vertical"
android:padding="16dp">
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Google.Light"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Facebook.Light"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Github.Light"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Gitlab.Light"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Apple.Light"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Twitter.Light"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Dark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@color/element_background_dark"
android:orientation="vertical"
android:padding="16dp">
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Google.Dark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Facebook.Dark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Github.Dark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Gitlab.Dark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Apple.Dark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
<Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Twitter.Dark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Continue with XXX" />
</LinearLayout>
</LinearLayout>

View File

@ -9,7 +9,7 @@
<item name="iconGravity">start</item>
<item name="android:textSize">14sp</item>
<item name="android:textAlignment">center</item>
<item name="android:paddingStart">2dp</item>
<item name="android:paddingStart">4dp</item>
<!-- Compensate icon size to center text correctly-->
<item name="android:paddingEnd">38dp</item>
<item name="android:clipToPadding">false</item>

View File

@ -129,7 +129,9 @@
<item name="android:windowSharedElementEnterTransition">@transition/image_preview_transition</item>
<item name="android:windowSharedElementExitTransition">@transition/image_preview_transition</item>
<item name="vctr_social_login_button_google_style">@style/Widget.Vector.Button.Outlined.SocialLogin.Google.Dark</item>
<!-- For Google button style, use same values than for light theme, for a better rendering (white background)
see https://github.com/vector-im/element-android/issues/4285#issuecomment-974270998 -->
<item name="vctr_social_login_button_google_style">@style/Widget.Vector.Button.Outlined.SocialLogin.Google.Light</item>
<item name="vctr_social_login_button_github_style">@style/Widget.Vector.Button.Outlined.SocialLogin.Github.Dark</item>
<item name="vctr_social_login_button_facebook_style">@style/Widget.Vector.Button.Outlined.SocialLogin.Facebook.Dark</item>
<item name="vctr_social_login_button_twitter_style">@style/Widget.Vector.Button.Outlined.SocialLogin.Twitter.Dark</item>

View File

@ -84,4 +84,7 @@
<!-- Bug in lint agp 4.1 incorrectly thinks kotlin forEach is using java 8 API's. -->
<!-- FIXME this workaround should be removed in a near future -->
<issue id="NewApi" severity="warning" />
<!-- DI -->
<issue id="JvmStaticProvidesInObjectDetector" severity="error" />
</lint>

View File

@ -5,6 +5,7 @@
<application>
<activity android:name=".features.debug.TestLinkifyActivity" />
<activity android:name=".features.debug.DebugPermissionActivity" />
<activity android:name=".features.debug.settings.DebugPrivateSettingsActivity" />
<activity android:name=".features.debug.sas.DebugSasEmojiActivity" />
</application>

View File

@ -35,6 +35,7 @@ import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.core.utils.toast
import im.vector.app.databinding.ActivityDebugMenuBinding
import im.vector.app.features.debug.sas.DebugSasEmojiActivity
import im.vector.app.features.debug.settings.DebugPrivateSettingsActivity
import im.vector.app.features.qrcode.QrCodeScannerActivity
import im.vector.lib.ui.styles.debug.DebugMaterialThemeDarkDefaultActivity
import im.vector.lib.ui.styles.debug.DebugMaterialThemeDarkTestActivity
@ -75,6 +76,7 @@ class DebugMenuActivity : VectorBaseActivity<ActivityDebugMenuBinding>() {
}
private fun setupViews() {
views.debugPrivateSetting.setOnClickListener { openPrivateSettings() }
views.debugTestTextViewLink.setOnClickListener { testTextViewLink() }
views.debugOpenButtonStylesLight.setOnClickListener {
startActivity(Intent(this, DebugVectorButtonStylesLightActivity::class.java))
@ -115,6 +117,10 @@ class DebugMenuActivity : VectorBaseActivity<ActivityDebugMenuBinding>() {
}
}
private fun openPrivateSettings() {
startActivity(Intent(this, DebugPrivateSettingsActivity::class.java))
}
private fun renderQrCode(text: String) {
views.debugQrCode.setData(text)
}

View File

@ -0,0 +1,36 @@
/*
* 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.debug.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.IntoMap
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.MavericksViewModelComponent
import im.vector.app.core.di.MavericksViewModelKey
import im.vector.app.features.debug.settings.DebugPrivateSettingsViewModel
@InstallIn(MavericksViewModelComponent::class)
@Module
interface MavericksViewModelDebugModule {
@Binds
@IntoMap
@MavericksViewModelKey(DebugPrivateSettingsViewModel::class)
fun debugPrivateSettingsViewModelFactory(factory: DebugPrivateSettingsViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
}

View File

@ -0,0 +1,38 @@
/*
* 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.debug.settings
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.addFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivitySimpleBinding
@AndroidEntryPoint
class DebugPrivateSettingsActivity : VectorBaseActivity<ActivitySimpleBinding>() {
override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater)
override fun initUiAndData() {
if (isFirstCreation()) {
addFragment(
R.id.simpleFragmentContainer,
DebugPrivateSettingsFragment::class.java
)
}
}
}

View File

@ -0,0 +1,51 @@
/*
* 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.debug.settings
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentDebugPrivateSettingsBinding
class DebugPrivateSettingsFragment : VectorBaseFragment<FragmentDebugPrivateSettingsBinding>() {
private val viewModel: DebugPrivateSettingsViewModel by fragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentDebugPrivateSettingsBinding {
return FragmentDebugPrivateSettingsBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setViewListeners()
}
private fun setViewListeners() {
views.forceDialPadTabDisplay.setOnCheckedChangeListener { _, isChecked ->
viewModel.handle(DebugPrivateSettingsViewActions.SetDialPadVisibility(isChecked))
}
}
override fun invalidate() = withState(viewModel) {
views.forceDialPadTabDisplay.isChecked = it.dialPadVisible
}
}

View File

@ -0,0 +1,23 @@
/*
* 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.debug.settings
import im.vector.app.core.platform.VectorViewModelAction
sealed class DebugPrivateSettingsViewActions : VectorViewModelAction {
data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions()
}

View File

@ -0,0 +1,65 @@
/*
* 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.debug.settings
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.settings.VectorDataStore
import kotlinx.coroutines.launch
class DebugPrivateSettingsViewModel @AssistedInject constructor(
@Assisted initialState: DebugPrivateSettingsViewState,
private val vectorDataStore: VectorDataStore
) : VectorViewModel<DebugPrivateSettingsViewState, DebugPrivateSettingsViewActions, EmptyViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<DebugPrivateSettingsViewModel, DebugPrivateSettingsViewState> {
override fun create(initialState: DebugPrivateSettingsViewState): DebugPrivateSettingsViewModel
}
companion object : MavericksViewModelFactory<DebugPrivateSettingsViewModel, DebugPrivateSettingsViewState> by hiltMavericksViewModelFactory()
init {
observeVectorDataStore()
}
private fun observeVectorDataStore() {
vectorDataStore.forceDialPadDisplayFlow.setOnEach {
copy(
dialPadVisible = it
)
}
}
override fun handle(action: DebugPrivateSettingsViewActions) {
when (action) {
is DebugPrivateSettingsViewActions.SetDialPadVisibility -> handleSetDialPadVisibility(action)
}
}
private fun handleSetDialPadVisibility(action: DebugPrivateSettingsViewActions.SetDialPadVisibility) {
viewModelScope.launch {
vectorDataStore.setForceDialPadDisplay(action.force)
}
}
}

View File

@ -0,0 +1,23 @@
/*
* 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.debug.settings
import com.airbnb.mvrx.MavericksState
data class DebugPrivateSettingsViewState(
val dialPadVisible: Boolean = false
) : MavericksState

View File

@ -20,6 +20,12 @@
android:padding="@dimen/layout_horizontal_margin"
android:showDividers="middle">
<Button
android:id="@+id/debug_private_setting"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Private settings" />
<Button
android:id="@+id/debug_test_text_view_link"
android:layout_width="wrap_content"

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/coordinatorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".features.debug.settings.DebugPrivateSettingsActivity"
tools:ignore="HardcodedText">
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:divider="@drawable/linear_divider"
android:orientation="vertical"
android:padding="@dimen/layout_horizontal_margin"
android:showDividers="middle">
<CheckBox
android:id="@+id/forceDialPadTabDisplay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Force DialPad tab display" />
</LinearLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -25,16 +25,12 @@ import im.vector.app.core.services.GuardServiceStarter
import im.vector.app.fdroid.service.FDroidGuardServiceStarter
import im.vector.app.features.settings.VectorPreferences
@Module
@InstallIn(SingletonComponent::class)
abstract class FlavorModule {
@Module
object FlavorModule {
companion object {
@Provides
@JvmStatic
fun provideGuardServiceStarter(preferences: VectorPreferences, appContext: Context): GuardServiceStarter {
return FDroidGuardServiceStarter(preferences, appContext)
}
@Provides
fun provideGuardServiceStarter(preferences: VectorPreferences, appContext: Context): GuardServiceStarter {
return FDroidGuardServiceStarter(preferences, appContext)
}
}

View File

@ -24,14 +24,10 @@ import im.vector.app.core.services.GuardServiceStarter
@InstallIn(SingletonComponent::class)
@Module
abstract class FlavorModule {
object FlavorModule {
companion object {
@Provides
@JvmStatic
fun provideGuardServiceStarter(): GuardServiceStarter {
return object : GuardServiceStarter {}
}
@Provides
fun provideGuardServiceStarter(): GuardServiceStarter {
return object : GuardServiceStarter {}
}
}

View File

@ -175,8 +175,7 @@ class VectorApplication :
}
override fun onPause(owner: LifecycleOwner) {
Timber.i("App entered background") // call persistInfo
notificationDrawerManager.persistInfo()
Timber.i("App entered background")
FcmHelper.onEnterBackground(appContext, vectorPreferences, activeSessionHolder)
}
})

View File

@ -28,7 +28,6 @@ import im.vector.app.features.home.room.detail.timeline.helper.TimelineAsyncHelp
@InstallIn(ActivityComponent::class)
object HomeModule {
@Provides
@JvmStatic
@TimelineEventControllerHandler
fun providesTimelineBackgroundHandler(): Handler {
return TimelineAsyncHelper.getBackgroundHandler()

View File

@ -41,7 +41,7 @@ import im.vector.app.features.home.PromoteRestrictedViewModel
import im.vector.app.features.home.UnknownDeviceDetectorSharedViewModel
import im.vector.app.features.home.UnreadMessagesSharedViewModel
import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel
import im.vector.app.features.home.room.detail.composer.TextComposerViewModel
import im.vector.app.features.home.room.detail.RoomDetailViewModel
import im.vector.app.features.home.room.detail.search.SearchViewModel
import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel
import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryViewModel
@ -505,8 +505,8 @@ interface MavericksViewModelModule {
@Binds
@IntoMap
@MavericksViewModelKey(TextComposerViewModel::class)
fun textComposerViewModelFactory(factory: TextComposerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@MavericksViewModelKey(RoomDetailViewModel::class)
fun roomDetailViewModelFactory(factory: RoomDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap

View File

@ -30,11 +30,9 @@ import im.vector.app.core.glide.GlideApp
object ScreenModule {
@Provides
@JvmStatic
fun providesGlideRequests(context: AppCompatActivity) = GlideApp.with(context)
@Provides
@JvmStatic
@ActivityScoped
fun providesSharedViewPool() = RecyclerView.RecycledViewPool()
}

View File

@ -73,69 +73,58 @@ abstract class VectorBindModule {
object VectorStaticModule {
@Provides
@JvmStatic
fun providesContext(application: Application): Context {
return application.applicationContext
}
@Provides
@JvmStatic
fun providesResources(context: Context): Resources {
return context.resources
}
@Provides
@JvmStatic
fun providesSharedPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences("im.vector.riot", MODE_PRIVATE)
}
@Provides
@JvmStatic
fun providesMatrix(context: Context): Matrix {
return Matrix.getInstance(context)
}
@Provides
@JvmStatic
fun providesCurrentSession(activeSessionHolder: ActiveSessionHolder): Session {
// TODO: handle session injection better
return activeSessionHolder.getActiveSession()
}
@Provides
@JvmStatic
fun providesLegacySessionImporter(matrix: Matrix): LegacySessionImporter {
return matrix.legacySessionImporter()
}
@Provides
@JvmStatic
fun providesAuthenticationService(matrix: Matrix): AuthenticationService {
return matrix.authenticationService()
}
@Provides
@JvmStatic
fun providesRawService(matrix: Matrix): RawService {
return matrix.rawService()
}
@Provides
@JvmStatic
fun providesHomeServerHistoryService(matrix: Matrix): HomeServerHistoryService {
return matrix.homeServerHistoryService()
}
@Provides
@JvmStatic
@Singleton
fun providesApplicationCoroutineScope(): CoroutineScope {
return CoroutineScope(SupervisorJob() + Dispatchers.Main)
}
@Provides
@JvmStatic
fun providesCoroutineDispatchers(): CoroutineDispatchers {
return CoroutineDispatchers(io = Dispatchers.IO, computation = Dispatchers.Default)
}

View File

@ -16,9 +16,7 @@
package im.vector.app.core.extensions
import android.os.Bundle
import android.util.Patterns
import androidx.fragment.app.Fragment
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.matrix.android.sdk.api.extensions.ensurePrefix
@ -27,11 +25,6 @@ fun Boolean.toOnOff() = if (this) "ON" else "OFF"
inline fun <T> T.ooi(block: (T) -> Unit): T = also(block)
/**
* Apply argument to a Fragment
*/
fun <T : Fragment> T.withArgs(block: Bundle.() -> Unit) = apply { arguments = Bundle().apply(block) }
/**
* Check if a CharSequence is an email
*/

View File

@ -22,6 +22,7 @@ import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -60,6 +61,7 @@ import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.restart
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.core.extensions.singletonEntryPoint
import im.vector.app.core.extensions.toMvRxBundle
import im.vector.app.core.flow.throttleFirst
import im.vector.app.core.utils.toast
import im.vector.app.features.MainActivity
@ -385,9 +387,9 @@ abstract class VectorBaseActivity<VB : ViewBinding> : AppCompatActivity(), Maver
bugReporter.inMultiWindowMode = isInMultiWindowMode
}
protected fun createFragment(fragmentClass: Class<out Fragment>, args: Bundle?): Fragment {
protected fun createFragment(fragmentClass: Class<out Fragment>, argsParcelable: Parcelable? = null): Fragment {
return fragmentFactory.instantiate(classLoader, fragmentClass.name).apply {
arguments = args
arguments = argsParcelable?.toMvRxBundle()
}
}
@ -554,7 +556,8 @@ abstract class VectorBaseActivity<VB : ViewBinding> : AppCompatActivity(), Maver
open fun initUiAndData() = Unit
override fun invalidate() = Unit
// Note: does not seem to be called
final override fun invalidate() = Unit
@StringRes
open fun getTitleRes() = -1

View File

@ -28,13 +28,13 @@ import androidx.annotation.CallSuper
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.MavericksView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.EntryPointAccessors
import im.vector.app.core.di.ActivityEntryPoint
import im.vector.app.core.extensions.toMvRxBundle
import im.vector.app.core.flow.throttleFirst
import im.vector.app.core.utils.DimensionConverter
import kotlinx.coroutines.flow.launchIn
@ -159,7 +159,7 @@ abstract class VectorBaseBottomSheetDialogFragment<VB : ViewBinding> : BottomShe
}
protected fun setArguments(args: Parcelable? = null) {
arguments = args?.let { Bundle().apply { putParcelable(Mavericks.KEY_ARG, it) } }
arguments = args.toMvRxBundle()
}
/* ==========================================================================================

View File

@ -116,7 +116,6 @@ class MainActivity : VectorBaseActivity<ActivityMainBinding>(), UnlockedActivity
private fun clearNotifications() {
// Dismiss all notifications
notificationDrawerManager.clearAllEvents()
notificationDrawerManager.persistInfo()
// Also clear the dynamic shortcuts
shortcutsHandler.clearShortcuts()

View File

@ -38,6 +38,7 @@ import im.vector.app.R
import im.vector.app.core.extensions.commitTransaction
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.toMvRxBundle
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.databinding.BottomSheetBootstrapBinding
import im.vector.app.features.auth.ReAuthActivity
@ -154,48 +155,48 @@ class BootstrapBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetBoot
is BootstrapStep.CheckingMigration -> {
views.bootstrapIcon.isVisible = false
views.bootstrapTitleText.text = getString(R.string.bottom_sheet_setup_secure_backup_title)
showFragment(BootstrapWaitingFragment::class, Bundle())
showFragment(BootstrapWaitingFragment::class)
}
is BootstrapStep.FirstForm -> {
views.bootstrapIcon.isVisible = false
views.bootstrapTitleText.text = getString(R.string.bottom_sheet_setup_secure_backup_title)
showFragment(BootstrapSetupRecoveryKeyFragment::class, Bundle())
showFragment(BootstrapSetupRecoveryKeyFragment::class)
}
is BootstrapStep.SetupPassphrase -> {
views.bootstrapIcon.isVisible = true
views.bootstrapIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_security_phrase_24dp))
views.bootstrapTitleText.text = getString(R.string.set_a_security_phrase_title)
showFragment(BootstrapEnterPassphraseFragment::class, Bundle())
showFragment(BootstrapEnterPassphraseFragment::class)
}
is BootstrapStep.ConfirmPassphrase -> {
views.bootstrapIcon.isVisible = true
views.bootstrapIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_security_phrase_24dp))
views.bootstrapTitleText.text = getString(R.string.set_a_security_phrase_title)
showFragment(BootstrapConfirmPassphraseFragment::class, Bundle())
showFragment(BootstrapConfirmPassphraseFragment::class)
}
is BootstrapStep.AccountReAuth -> {
views.bootstrapIcon.isVisible = true
views.bootstrapIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_user))
views.bootstrapTitleText.text = getString(R.string.re_authentication_activity_title)
showFragment(BootstrapReAuthFragment::class, Bundle())
showFragment(BootstrapReAuthFragment::class)
}
is BootstrapStep.Initializing -> {
views.bootstrapIcon.isVisible = true
views.bootstrapIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_security_key_24dp))
views.bootstrapTitleText.text = getString(R.string.bootstrap_loading_title)
showFragment(BootstrapWaitingFragment::class, Bundle())
showFragment(BootstrapWaitingFragment::class)
}
is BootstrapStep.SaveRecoveryKey -> {
views.bootstrapIcon.isVisible = true
views.bootstrapIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_security_key_24dp))
views.bootstrapTitleText.text = getString(R.string.bottom_sheet_save_your_recovery_key_title)
showFragment(BootstrapSaveRecoveryKeyFragment::class, Bundle())
showFragment(BootstrapSaveRecoveryKeyFragment::class)
}
is BootstrapStep.DoneSuccess -> {
views.bootstrapIcon.isVisible = true
views.bootstrapIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_security_key_24dp))
views.bootstrapTitleText.text = getString(R.string.bootstrap_finish_title)
showFragment(BootstrapConclusionFragment::class, Bundle())
showFragment(BootstrapConclusionFragment::class)
}
is BootstrapStep.GetBackupSecretForMigration -> {
val isKey = state.step.useKey()
@ -206,7 +207,7 @@ class BootstrapBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetBoot
drawableRes)
)
views.bootstrapTitleText.text = getString(R.string.upgrade_security)
showFragment(BootstrapMigrateBackupFragment::class, Bundle())
showFragment(BootstrapMigrateBackupFragment::class)
}
}.exhaustive
super.invalidate()
@ -214,26 +215,22 @@ class BootstrapBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetBoot
companion object {
const val EXTRA_ARGS = "EXTRA_ARGS"
fun show(fragmentManager: FragmentManager, mode: SetupMode): BootstrapBottomSheet {
return BootstrapBottomSheet().apply {
isCancelable = false
arguments = Bundle().apply {
this.putParcelable(EXTRA_ARGS, Args(setUpMode = mode))
}
setArguments(Args(setUpMode = mode))
}.also {
it.show(fragmentManager, "BootstrapBottomSheet")
}
}
}
private fun showFragment(fragmentClass: KClass<out Fragment>, bundle: Bundle) {
private fun showFragment(fragmentClass: KClass<out Fragment>, argsParcelable: Parcelable? = null) {
if (childFragmentManager.findFragmentByTag(fragmentClass.simpleName) == null) {
childFragmentManager.commitTransaction {
replace(R.id.bottomSheetFragmentContainer,
fragmentClass.java,
bundle,
argsParcelable?.toMvRxBundle(),
fragmentClass.simpleName
)
}

View File

@ -24,7 +24,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -33,6 +32,7 @@ import im.vector.app.R
import im.vector.app.core.extensions.commitTransaction
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.toMvRxBundle
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.databinding.BottomSheetVerificationBinding
@ -178,36 +178,37 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetV
}
if (state.quadSHasBeenReset) {
showFragment(VerificationConclusionFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationConclusionFragment.Args(
isSuccessFull = true,
isMe = true,
cancelReason = null
))
})
showFragment(
VerificationConclusionFragment::class,
VerificationConclusionFragment.Args(
isSuccessFull = true,
isMe = true,
cancelReason = null
))
return@withState
}
if (state.userThinkItsNotHim) {
views.otherUserNameText.text = getString(R.string.dialog_title_warning)
showFragment(VerificationNotMeFragment::class, Bundle())
showFragment(VerificationNotMeFragment::class)
return@withState
}
if (state.userWantsToCancel) {
views.otherUserNameText.text = getString(R.string.are_you_sure)
showFragment(VerificationCancelFragment::class, Bundle())
showFragment(VerificationCancelFragment::class)
return@withState
}
if (state.selfVerificationMode && state.verifyingFrom4S) {
showFragment(QuadSLoadingFragment::class, Bundle())
showFragment(QuadSLoadingFragment::class)
return@withState
}
if (state.selfVerificationMode && state.verifiedFromPrivateKeys) {
showFragment(VerificationConclusionFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationConclusionFragment.Args(true, null, state.isMe))
})
showFragment(
VerificationConclusionFragment::class,
VerificationConclusionFragment.Args(true, null, state.isMe)
)
return@withState
}
@ -229,23 +230,27 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetV
is VerificationTxState.SendingMac,
is VerificationTxState.MacSent,
is VerificationTxState.Verifying -> {
showFragment(VerificationEmojiCodeFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationArgs(
state.otherUserMxItem?.id ?: "",
// If it was outgoing it.transaction id would be null, but the pending request
// would be updated (from localId to txId)
state.pendingRequest.invoke()?.transactionId ?: state.transactionId))
})
showFragment(
VerificationEmojiCodeFragment::class,
VerificationArgs(
state.otherUserMxItem?.id ?: "",
// If it was outgoing it.transaction id would be null, but the pending request
// would be updated (from localId to txId)
state.pendingRequest.invoke()?.transactionId ?: state.transactionId
)
)
}
is VerificationTxState.Verified -> {
showFragment(VerificationConclusionFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationConclusionFragment.Args(true, null, state.isMe))
})
showFragment(
VerificationConclusionFragment::class,
VerificationConclusionFragment.Args(true, null, state.isMe)
)
}
is VerificationTxState.Cancelled -> {
showFragment(VerificationConclusionFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationConclusionFragment.Args(false, state.sasTransactionState.cancelCode.value, state.isMe))
})
showFragment(
VerificationConclusionFragment::class,
VerificationConclusionFragment.Args(false, state.sasTransactionState.cancelCode.value, state.isMe)
)
}
}
@ -254,29 +259,32 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetV
when (state.qrTransactionState) {
is VerificationTxState.QrScannedByOther -> {
showFragment(VerificationQrScannedByOtherFragment::class, Bundle())
showFragment(VerificationQrScannedByOtherFragment::class)
return@withState
}
is VerificationTxState.Started,
is VerificationTxState.WaitingOtherReciprocateConfirm -> {
showFragment(VerificationQRWaitingFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationQRWaitingFragment.Args(
isMe = state.isMe,
otherUserName = state.otherUserMxItem?.getBestName() ?: ""
))
})
showFragment(
VerificationQRWaitingFragment::class,
VerificationQRWaitingFragment.Args(
isMe = state.isMe,
otherUserName = state.otherUserMxItem?.getBestName() ?: ""
)
)
return@withState
}
is VerificationTxState.Verified -> {
showFragment(VerificationConclusionFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationConclusionFragment.Args(true, null, state.isMe))
})
showFragment(
VerificationConclusionFragment::class,
VerificationConclusionFragment.Args(true, null, state.isMe)
)
return@withState
}
is VerificationTxState.Cancelled -> {
showFragment(VerificationConclusionFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationConclusionFragment.Args(false, state.qrTransactionState.cancelCode.value, state.isMe))
})
showFragment(
VerificationConclusionFragment::class,
VerificationConclusionFragment.Args(false, state.qrTransactionState.cancelCode.value, state.isMe)
)
return@withState
}
else -> Unit
@ -288,12 +296,14 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetV
if (state.pendingRequest.invoke()?.cancelConclusion != null) {
// The request has been declined, we should dismiss
views.otherUserNameText.text = getString(R.string.verification_cancelled)
showFragment(VerificationConclusionFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationConclusionFragment.Args(
false,
state.pendingRequest.invoke()?.cancelConclusion?.value ?: CancelCode.User.value,
state.isMe))
})
showFragment(
VerificationConclusionFragment::class,
VerificationConclusionFragment.Args(
isSuccessFull = false,
cancelReason = state.pendingRequest.invoke()?.cancelConclusion?.value ?: CancelCode.User.value,
isMe = state.isMe
)
)
return@withState
}
@ -303,36 +313,44 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetV
if (state.pendingRequest.invoke()?.isReady == true) {
Timber.v("## SAS show bottom sheet for outgoing and ready request")
// Show choose method fragment with waiting
showFragment(VerificationChooseMethodFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationArgs(state.otherUserMxItem?.id
?: "", state.pendingRequest.invoke()?.transactionId))
})
showFragment(
VerificationChooseMethodFragment::class,
VerificationArgs(
otherUserId = state.otherUserMxItem?.id ?: "",
verificationId = state.pendingRequest.invoke()?.transactionId
)
)
} else {
// Stay on the start fragment
showFragment(VerificationRequestFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationArgs(
state.otherUserMxItem?.id ?: "",
state.pendingRequest.invoke()?.transactionId,
state.roomId))
})
showFragment(
VerificationRequestFragment::class,
VerificationArgs(
otherUserId = state.otherUserMxItem?.id ?: "",
verificationId = state.pendingRequest.invoke()?.transactionId,
verificationLocalId = state.roomId
)
)
}
} else if (state.pendingRequest.invoke()?.isIncoming == true) {
Timber.v("## SAS show bottom sheet for Incoming request")
// For incoming we can switch to choose method because ready is being sent or already sent
showFragment(VerificationChooseMethodFragment::class, Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationArgs(state.otherUserMxItem?.id
?: "", state.pendingRequest.invoke()?.transactionId))
})
showFragment(
VerificationChooseMethodFragment::class,
VerificationArgs(
otherUserId = state.otherUserMxItem?.id ?: "",
verificationId = state.pendingRequest.invoke()?.transactionId
)
)
}
super.invalidate()
}
private fun showFragment(fragmentClass: KClass<out Fragment>, bundle: Bundle) {
private fun showFragment(fragmentClass: KClass<out Fragment>, argsParcelable: Parcelable? = null) {
if (childFragmentManager.findFragmentByTag(fragmentClass.simpleName) == null) {
childFragmentManager.commitTransaction {
replace(R.id.bottomSheetFragmentContainer,
fragmentClass.java,
bundle,
argsParcelable?.toMvRxBundle(),
fragmentClass.simpleName
)
}
@ -342,37 +360,31 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetV
companion object {
fun withArgs(roomId: String?, otherUserId: String, transactionId: String? = null): VerificationBottomSheet {
return VerificationBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationArgs(
otherUserId = otherUserId,
roomId = roomId,
verificationId = transactionId,
selfVerificationMode = false
))
}
setArguments(VerificationArgs(
otherUserId = otherUserId,
roomId = roomId,
verificationId = transactionId,
selfVerificationMode = false
))
}
}
fun forSelfVerification(session: Session): VerificationBottomSheet {
return VerificationBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationArgs(
otherUserId = session.myUserId,
selfVerificationMode = true
))
}
setArguments(VerificationArgs(
otherUserId = session.myUserId,
selfVerificationMode = true
))
}
}
fun forSelfVerification(session: Session, outgoingRequest: String): VerificationBottomSheet {
return VerificationBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationArgs(
otherUserId = session.myUserId,
selfVerificationMode = true,
verificationId = outgoingRequest
))
}
setArguments(VerificationArgs(
otherUserId = session.myUserId,
selfVerificationMode = true,
verificationId = outgoingRequest
))
}
}

View File

@ -18,7 +18,6 @@ package im.vector.app.features.devtools
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.view.Menu
import android.view.MenuItem
@ -37,7 +36,6 @@ import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.extensions.toMvRxBundle
import im.vector.app.core.platform.SimpleFragmentActivity
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.utils.createJSonViewerStyleProvider
@ -109,15 +107,15 @@ class RoomDevToolActivity : SimpleFragmentActivity(), FragmentManager.OnBackStac
}
RoomDevToolViewState.Mode.StateEventList,
RoomDevToolViewState.Mode.StateEventListByType -> {
val frag = createFragment(RoomDevToolStateEventListFragment::class.java, Bundle().toMvRxBundle())
val frag = createFragment(RoomDevToolStateEventListFragment::class.java)
navigateTo(frag)
}
RoomDevToolViewState.Mode.EditEventContent -> {
val frag = createFragment(RoomDevToolEditFragment::class.java, Bundle().toMvRxBundle())
val frag = createFragment(RoomDevToolEditFragment::class.java)
navigateTo(frag)
}
is RoomDevToolViewState.Mode.SendEventForm -> {
val frag = createFragment(RoomDevToolSendFormFragment::class.java, Bundle().toMvRxBundle())
val frag = createFragment(RoomDevToolSendFormFragment::class.java)
navigateTo(frag)
}
}

View File

@ -23,7 +23,6 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.core.view.iterator
import androidx.fragment.app.Fragment
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.fragmentViewModel

View File

@ -38,7 +38,6 @@ import im.vector.app.features.invite.showInvites
import im.vector.app.features.settings.VectorDataStore
import im.vector.app.features.ui.UiStateRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
@ -60,15 +59,16 @@ import timber.log.Timber
* View model used to update the home bottom bar notification counts, observe the sync state and
* change the selected room list view
*/
class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: HomeDetailViewState,
private val session: Session,
private val uiStateRepository: UiStateRepository,
private val vectorDataStore: VectorDataStore,
private val callManager: WebRtcCallManager,
private val directRoomHelper: DirectRoomHelper,
private val appStateHandler: AppStateHandler,
private val autoAcceptInvites: AutoAcceptInvites) :
VectorViewModel<HomeDetailViewState, HomeDetailAction, HomeDetailViewEvents>(initialState),
class HomeDetailViewModel @AssistedInject constructor(
@Assisted initialState: HomeDetailViewState,
private val session: Session,
private val uiStateRepository: UiStateRepository,
private val vectorDataStore: VectorDataStore,
private val callManager: WebRtcCallManager,
private val directRoomHelper: DirectRoomHelper,
private val appStateHandler: AppStateHandler,
private val autoAcceptInvites: AutoAcceptInvites
) : VectorViewModel<HomeDetailViewState, HomeDetailAction, HomeDetailViewEvents>(initialState),
CallProtocolsChecker.Listener {
@AssistedFactory
@ -90,7 +90,7 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho
observeSyncState()
observeRoomGroupingMethod()
observeRoomSummaries()
updateShowDialPadTab()
updatePstnSupportFlag()
observeDataStore()
callManager.addProtocolsCheckerListener(this)
session.flow().liveUser(session.myUserId).execute {
@ -101,14 +101,16 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho
}
private fun observeDataStore() {
viewModelScope.launch {
vectorDataStore.pushCounterFlow.collect { nbOfPush ->
setState {
copy(
pushCounter = nbOfPush
)
}
}
vectorDataStore.pushCounterFlow.setOnEach { nbOfPush ->
copy(
pushCounter = nbOfPush
)
}
vectorDataStore.forceDialPadDisplayFlow.setOnEach { force ->
copy(
forceDialPadTab = force
)
}
}
@ -150,12 +152,12 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho
}
override fun onPSTNSupportUpdated() {
updateShowDialPadTab()
updatePstnSupportFlag()
}
private fun updateShowDialPadTab() {
private fun updatePstnSupportFlag() {
setState {
copy(showDialPadTab = callManager.supportsPSTNProtocol)
copy(pstnSupportFlag = callManager.supportsPSTNProtocol)
}
}

View File

@ -42,8 +42,11 @@ data class HomeDetailViewState(
val syncState: SyncState = SyncState.Idle,
val incrementalSyncStatus: SyncStatusService.Status.IncrementalSyncStatus = SyncStatusService.Status.IncrementalSyncIdle,
val pushCounter: Int = 0,
val showDialPadTab: Boolean = false
) : MavericksState
val pstnSupportFlag: Boolean = false,
val forceDialPadTab: Boolean = false
) : MavericksState {
val showDialPadTab = forceDialPadTab || pstnSupportFlag
}
sealed class HomeTab(@StringRes val titleRes: Int) {
data class RoomList(val displayMode: RoomListDisplayMode) : HomeTab(displayMode.titleRes)

View File

@ -1,36 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home
import android.os.Handler
import dagger.Module
import dagger.Provides
import dagger.hilt.migration.DisableInstallInCheck
import im.vector.app.features.home.room.detail.timeline.TimelineEventControllerHandler
import im.vector.app.features.home.room.detail.timeline.helper.TimelineAsyncHelper
@DisableInstallInCheck
@Module
object HomeModule {
@Provides
@JvmStatic
@TimelineEventControllerHandler
fun providesTimelineBackgroundHandler(): Handler {
return TimelineAsyncHelper.getBackgroundHandler()
}
}

View File

@ -21,7 +21,6 @@ import android.view.View
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.call.conference.ConferenceEvent
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.timeline.Timeline
@ -108,12 +107,4 @@ sealed class RoomDetailAction : VectorViewModelAction {
object RemoveAllFailedMessages : RoomDetailAction()
data class RoomUpgradeSuccess(val replacementRoomId: String) : RoomDetailAction()
// Voice Message
object StartRecordingVoiceMessage : RoomDetailAction()
data class EndRecordingVoiceMessage(val isCancelled: Boolean) : RoomDetailAction()
object PauseRecordingVoiceMessage : RoomDetailAction()
data class PlayOrPauseVoicePlayback(val eventId: String, val messageAudioContent: MessageAudioContent) : RoomDetailAction()
object PlayOrPauseRecordingPlayback : RoomDetailAction()
data class EndAllVoiceActions(val deleteRecord: Boolean = true) : RoomDetailAction()
}

View File

@ -62,7 +62,6 @@ import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.OnModelBuildFinishedListener
import com.airbnb.epoxy.addGlidePreloader
import com.airbnb.epoxy.glidePreloader
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
@ -132,13 +131,14 @@ import im.vector.app.features.command.Command
import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivity
import im.vector.app.features.crypto.verification.VerificationBottomSheet
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.composer.MessageComposerAction
import im.vector.app.features.home.room.detail.composer.MessageComposerView
import im.vector.app.features.home.room.detail.composer.MessageComposerViewEvents
import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel
import im.vector.app.features.home.room.detail.composer.MessageComposerViewState
import im.vector.app.features.home.room.detail.composer.SendMode
import im.vector.app.features.home.room.detail.composer.TextComposerAction
import im.vector.app.features.home.room.detail.composer.TextComposerView
import im.vector.app.features.home.room.detail.composer.TextComposerViewEvents
import im.vector.app.features.home.room.detail.composer.TextComposerViewModel
import im.vector.app.features.home.room.detail.composer.TextComposerViewState
import im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
@ -240,7 +240,7 @@ class RoomDetailFragment @Inject constructor(
autoCompleterFactory: AutoCompleter.Factory,
private val permalinkHandler: PermalinkHandler,
private val notificationDrawerManager: NotificationDrawerManager,
val roomDetailViewModelFactory: RoomDetailViewModel.Factory,
val messageComposerViewModelFactory: MessageComposerViewModel.Factory,
private val eventHtmlRenderer: EventHtmlRenderer,
private val vectorPreferences: VectorPreferences,
private val colorProvider: ColorProvider,
@ -293,7 +293,7 @@ class RoomDetailFragment @Inject constructor(
autoCompleterFactory.create(roomDetailArgs.roomId)
}
private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel()
private val textComposerViewModel: TextComposerViewModel by fragmentViewModel()
private val messageComposerViewModel: MessageComposerViewModel by fragmentViewModel()
private val debouncer = Debouncer(createUIHandler())
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback
@ -386,7 +386,7 @@ class RoomDetailFragment @Inject constructor(
updateJumpToReadMarkerViewVisibility()
}
textComposerViewModel.onEach(TextComposerViewState::sendMode, TextComposerViewState::canSendMessage) { mode, canSend ->
messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend ->
if (!canSend) {
return@onEach
}
@ -411,25 +411,26 @@ class RoomDetailFragment @Inject constructor(
)
}
textComposerViewModel.observeViewEvents {
messageComposerViewModel.observeViewEvents {
when (it) {
is TextComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
is TextComposerViewEvents.SendMessageResult -> renderSendMessageResult(it)
is TextComposerViewEvents.ShowMessage -> showSnackWithMessage(it.message)
is TextComposerViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it)
is TextComposerViewEvents.AnimateSendButtonVisibility -> handleSendButtonVisibilityChanged(it)
is TextComposerViewEvents.OpenRoomMemberProfile -> openRoomMemberProfile(it.userId)
}.exhaustive
}
roomDetailViewModel.observeViewEvents {
when (it) {
is RoomDetailViewEvents.Failure -> {
is MessageComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
is MessageComposerViewEvents.SendMessageResult -> renderSendMessageResult(it)
is MessageComposerViewEvents.ShowMessage -> showSnackWithMessage(it.message)
is MessageComposerViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it)
is MessageComposerViewEvents.AnimateSendButtonVisibility -> handleSendButtonVisibilityChanged(it)
is MessageComposerViewEvents.OpenRoomMemberProfile -> openRoomMemberProfile(it.userId)
is MessageComposerViewEvents.VoicePlaybackOrRecordingFailure -> {
if (it.throwable is VoiceFailure.UnableToRecord) {
onCannotRecord()
}
showErrorInSnackbar(it.throwable)
}
}.exhaustive
}
roomDetailViewModel.observeViewEvents {
when (it) {
is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable)
is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds)
is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it)
is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it)
@ -469,7 +470,7 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun handleSendButtonVisibilityChanged(event: TextComposerViewEvents.AnimateSendButtonVisibility) {
private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.AnimateSendButtonVisibility) {
if (event.isVisible) {
views.voiceMessageRecorderView.isVisible = false
views.composerLayout.views.sendButton.alpha = 0f
@ -505,7 +506,7 @@ class RoomDetailFragment @Inject constructor(
private fun onCannotRecord() {
// Update the UI, cancel the animation
views.voiceMessageRecorderView.initVoiceRecordingViews()
messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.None))
}
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
@ -524,7 +525,7 @@ class RoomDetailFragment @Inject constructor(
JoinReplacementRoomBottomSheet().show(childFragmentManager, tag)
}
private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: TextComposerViewEvents.ShowRoomUpgradeDialog) {
private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: MessageComposerViewEvents.ShowRoomUpgradeDialog) {
val tag = MigrateRoomBottomSheet::javaClass.name
MigrateRoomBottomSheet.newInstance(roomDetailArgs.roomId, roomDetailViewEvents.newVersion)
.show(parentFragmentManager, tag)
@ -692,32 +693,56 @@ class RoomDetailFragment @Inject constructor(
}
private fun setupVoiceMessageView() {
views.voiceMessageRecorderView.voiceMessagePlaybackTracker = voiceMessagePlaybackTracker
voiceMessagePlaybackTracker.track(VoiceMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView)
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
override fun onVoiceRecordingStarted(): Boolean {
return if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
override fun onVoiceRecordingStarted() {
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage)
vibrate(requireContext())
true
} else {
// Permission dialog is displayed
false
updateRecordingUiState(RecordingUiState.Started)
}
}
override fun onVoiceRecordingEnded(isCancelled: Boolean) {
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled))
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(false))
}
override fun onVoiceRecordingPlaybackModeOn() {
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
}
override fun onVoicePlaybackButtonClicked() {
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
messageComposerViewModel.handle(MessageComposerAction.PlayOrPauseRecordingPlayback)
}
override fun onVoiceRecordingCancelled() {
messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true))
updateRecordingUiState(RecordingUiState.Cancelled)
}
override fun onVoiceRecordingLocked() {
updateRecordingUiState(RecordingUiState.Locked)
}
override fun onVoiceRecordingEnded() {
onSendVoiceMessage()
}
override fun onSendVoiceMessage() {
messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false))
updateRecordingUiState(RecordingUiState.None)
}
override fun onDeleteVoiceMessage() {
messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true))
updateRecordingUiState(RecordingUiState.None)
}
override fun onRecordingLimitReached() {
messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage)
updateRecordingUiState(RecordingUiState.Playback)
}
override fun onRecordingWaveformClicked() {
messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage)
updateRecordingUiState(RecordingUiState.Playback)
}
private fun updateRecordingUiState(state: RecordingUiState) {
messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(state))
}
}
}
@ -794,7 +819,7 @@ class RoomDetailFragment @Inject constructor(
.show()
}
private fun handleJoinedToAnotherRoom(action: TextComposerViewEvents.JoinRoomCommandSuccess) {
private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) {
views.composerLayout.setTextIfDifferent("")
lockSendButton = false
navigator.openRoom(vectorBaseActivity, action.roomId)
@ -803,7 +828,7 @@ class RoomDetailFragment @Inject constructor(
private fun handleShareData() {
when (val sharedData = roomDetailArgs.sharedData) {
is SharedData.Text -> {
textComposerViewModel.handle(TextComposerAction.EnterRegularMode(sharedData.text, fromSharing = true))
messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(sharedData.text, fromSharing = true))
}
is SharedData.Attachments -> {
// open share edition
@ -1108,11 +1133,11 @@ class RoomDetailFragment @Inject constructor(
notificationDrawerManager.setCurrentRoom(null)
textComposerViewModel.handle(TextComposerAction.SaveDraft(views.composerLayout.text.toString()))
messageComposerViewModel.handle(MessageComposerAction.SaveDraft(views.composerLayout.text.toString()))
// We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed.
roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions(deleteRecord = false))
views.voiceMessageRecorderView.initVoiceRecordingViews()
messageComposerViewModel.handle(MessageComposerAction.EndAllVoiceActions(deleteRecord = false))
views.voiceMessageRecorderView.render(RecordingUiState.None)
}
private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
@ -1227,12 +1252,12 @@ class RoomDetailFragment @Inject constructor(
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
(model as? AbsMessageItem)?.attributes?.informationData?.let {
val eventId = it.eventId
textComposerViewModel.handle(TextComposerAction.EnterReplyMode(eventId, views.composerLayout.text.toString()))
messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(eventId, views.composerLayout.text.toString()))
}
}
override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
val canSendMessage = withState(textComposerViewModel) {
val canSendMessage = withState(messageComposerViewModel) {
it.canSendMessage
}
if (!canSendMessage) {
@ -1321,7 +1346,7 @@ class RoomDetailFragment @Inject constructor(
views.composerLayout.views.composerEmojiButton.isVisible = vectorPreferences.showEmojiKeyboard()
views.composerLayout.callback = object : TextComposerView.Callback {
views.composerLayout.callback = object : MessageComposerView.Callback {
override fun onAddAttachment() {
if (!::attachmentTypeSelector.isInitialized) {
attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@RoomDetailFragment)
@ -1334,7 +1359,7 @@ class RoomDetailFragment @Inject constructor(
}
override fun onCloseRelatedMessage() {
textComposerViewModel.handle(TextComposerAction.EnterRegularMode(views.composerLayout.text.toString(), false))
messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(views.composerLayout.text.toString(), false))
}
override fun onRichContentSelected(contentUri: Uri): Boolean {
@ -1342,7 +1367,7 @@ class RoomDetailFragment @Inject constructor(
}
override fun onTextChanged(text: CharSequence) {
textComposerViewModel.handle(TextComposerAction.OnTextChanged(text))
messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(text))
}
}
}
@ -1356,7 +1381,7 @@ class RoomDetailFragment @Inject constructor(
// We collapse ASAP, if not there will be a slight annoying delay
views.composerLayout.collapse(true)
lockSendButton = true
textComposerViewModel.handle(TextComposerAction.SendMessage(text, vectorPreferences.isMarkdownEnabled()))
messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, vectorPreferences.isMarkdownEnabled()))
emojiPopup.dismiss()
}
}
@ -1368,7 +1393,7 @@ class RoomDetailFragment @Inject constructor(
.map { it.isNotEmpty() }
.onEach {
Timber.d("Typing: User is typing: $it")
textComposerViewModel.handle(TextComposerAction.UserIsTyping(it))
messageComposerViewModel.handle(MessageComposerAction.UserIsTyping(it))
}
.launchIn(viewLifecycleOwner.lifecycleScope)
@ -1388,7 +1413,7 @@ class RoomDetailFragment @Inject constructor(
return isHandled
}
override fun invalidate() = withState(roomDetailViewModel, textComposerViewModel) { mainState, textComposerState ->
override fun invalidate() = withState(roomDetailViewModel, messageComposerViewModel) { mainState, messageComposerState ->
invalidateOptionsMenu()
val summary = mainState.asyncRoomSummary()
renderToolbar(summary, mainState.formattedTypingUsers)
@ -1405,12 +1430,13 @@ class RoomDetailFragment @Inject constructor(
timelineEventController.update(mainState)
lazyLoadedViews.inviteView(false)?.isVisible = false
if (mainState.tombstoneEvent == null) {
views.composerLayout.isInvisible = !textComposerState.isComposerVisible
views.voiceMessageRecorderView.isVisible = textComposerState.isVoiceMessageRecorderVisible
views.composerLayout.views.sendButton.isInvisible = !textComposerState.isSendButtonVisible
views.composerLayout.isInvisible = !messageComposerState.isComposerVisible
views.voiceMessageRecorderView.isVisible = messageComposerState.isVoiceMessageRecorderVisible
views.composerLayout.views.sendButton.isInvisible = !messageComposerState.isSendButtonVisible
views.voiceMessageRecorderView.render(messageComposerState.voiceRecordingUiState)
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
// views.composerLayout.alwaysShowSendButton = false
if (textComposerState.canSendMessage) {
if (messageComposerState.canSendMessage) {
views.notificationAreaView.render(NotificationAreaView.State.Hidden)
} else {
views.notificationAreaView.render(NotificationAreaView.State.NoPermissionToPost)
@ -1467,27 +1493,27 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun renderSendMessageResult(sendMessageResult: TextComposerViewEvents.SendMessageResult) {
private fun renderSendMessageResult(sendMessageResult: MessageComposerViewEvents.SendMessageResult) {
when (sendMessageResult) {
is TextComposerViewEvents.SlashCommandLoading -> {
is MessageComposerViewEvents.SlashCommandLoading -> {
showLoading(null)
}
is TextComposerViewEvents.SlashCommandError -> {
is MessageComposerViewEvents.SlashCommandError -> {
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
}
is TextComposerViewEvents.SlashCommandUnknown -> {
is MessageComposerViewEvents.SlashCommandUnknown -> {
displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
}
is TextComposerViewEvents.SlashCommandResultOk -> {
is MessageComposerViewEvents.SlashCommandResultOk -> {
dismissLoadingDialog()
views.composerLayout.setTextIfDifferent("")
sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) }
}
is TextComposerViewEvents.SlashCommandResultError -> {
is MessageComposerViewEvents.SlashCommandResultError -> {
dismissLoadingDialog()
displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable))
}
is TextComposerViewEvents.SlashCommandNotImplemented -> {
is MessageComposerViewEvents.SlashCommandNotImplemented -> {
displayCommandError(getString(R.string.not_implemented))
}
} // .exhaustive
@ -1609,10 +1635,11 @@ class RoomDetailFragment @Inject constructor(
is RoomDetailAction.ResumeVerification -> {
val otherUserId = data.otherUserId ?: return
VerificationBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(Mavericks.KEY_ARG, VerificationBottomSheet.VerificationArgs(
otherUserId, data.transactionId, roomId = roomDetailArgs.roomId))
}
setArguments(VerificationBottomSheet.VerificationArgs(
otherUserId = otherUserId,
verificationId = data.transactionId,
roomId = roomDetailArgs.roomId
))
}.show(parentFragmentManager, "REQ")
}
}
@ -1857,7 +1884,7 @@ class RoomDetailFragment @Inject constructor(
}
override fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent) {
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseVoicePlayback(eventId, messageAudioContent))
messageComposerViewModel.handle(MessageComposerAction.PlayOrPauseVoicePlayback(eventId, messageAudioContent))
}
private fun onShareActionClicked(action: EventSharedAction.Share) {
@ -1962,18 +1989,18 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
}
is EventSharedAction.Edit -> {
if (!views.voiceMessageRecorderView.isActive()) {
textComposerViewModel.handle(TextComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
} else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
}
}
is EventSharedAction.Quote -> {
textComposerViewModel.handle(TextComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString()))
messageComposerViewModel.handle(MessageComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString()))
}
is EventSharedAction.Reply -> {
if (!views.voiceMessageRecorderView.isActive()) {
textComposerViewModel.handle(TextComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString()))
if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString()))
} else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
}
@ -2186,7 +2213,7 @@ class RoomDetailFragment @Inject constructor(
override fun onContactAttachmentReady(contactAttachment: ContactAttachment) {
super.onContactAttachmentReady(contactAttachment)
val formattedContact = contactAttachment.toHumanReadable()
textComposerViewModel.handle(TextComposerAction.SendMessage(formattedContact, false))
messageComposerViewModel.handle(MessageComposerAction.SendMessage(formattedContact, false))
}
private fun onViewWidgetsClicked() {

View File

@ -21,24 +21,23 @@ import androidx.annotation.IdRes
import androidx.lifecycle.asFlow
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
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.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.BehaviorDataSource
import im.vector.app.features.attachments.toContentAttachmentData
import im.vector.app.features.call.conference.ConferenceEvent
import im.vector.app.features.call.conference.JitsiActiveConferenceHolder
import im.vector.app.features.call.conference.JitsiService
@ -47,7 +46,6 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.createdirect.DirectRoomHelper
import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy
import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider
import im.vector.app.features.home.room.detail.composer.VoiceMessageHelper
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
@ -56,10 +54,8 @@ import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorDataStore
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voice.VoicePlayerHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@ -116,8 +112,6 @@ class RoomDetailViewModel @AssistedInject constructor(
private val directRoomHelper: DirectRoomHelper,
private val jitsiService: JitsiService,
private val activeConferenceHolder: JitsiActiveConferenceHolder,
private val voiceMessageHelper: VoiceMessageHelper,
private val voicePlayerHelper: VoicePlayerHelper,
timelineFactory: TimelineFactory
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener {
@ -144,22 +138,12 @@ class RoomDetailViewModel @AssistedInject constructor(
private var prepareToEncrypt: Async<Unit> = Uninitialized
@AssistedFactory
interface Factory {
fun create(initialState: RoomDetailViewState): RoomDetailViewModel
interface Factory : MavericksAssistedViewModelFactory<RoomDetailViewModel, RoomDetailViewState> {
override fun create(initialState: RoomDetailViewState): RoomDetailViewModel
}
/**
* Can't use the hiltMaverick here because some dependencies are injected here and in fragment but they don't share the graph.
*/
companion object : MavericksViewModelFactory<RoomDetailViewModel, RoomDetailViewState> {
companion object : MavericksViewModelFactory<RoomDetailViewModel, RoomDetailViewState> by hiltMavericksViewModelFactory() {
const val PAGINATION_COUNT = 50
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: RoomDetailViewState): RoomDetailViewModel {
val fragment: RoomDetailFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.roomDetailViewModelFactory.create(state)
}
}
init {
@ -195,14 +179,10 @@ class RoomDetailViewModel @AssistedInject constructor(
}
private fun observeDataStore() {
viewModelScope.launch {
vectorDataStore.pushCounterFlow.collect { nbOfPush ->
setState {
copy(
pushCounter = nbOfPush
)
}
}
vectorDataStore.pushCounterFlow.setOnEach { nbOfPush ->
copy(
pushCounter = nbOfPush
)
}
}
@ -343,12 +323,6 @@ class RoomDetailViewModel @AssistedInject constructor(
is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action)
RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages()
RoomDetailAction.ResendAll -> handleResendAll()
RoomDetailAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage()
is RoomDetailAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled)
is RoomDetailAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action)
RoomDetailAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage()
RoomDetailAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback()
is RoomDetailAction.EndAllVoiceActions -> handleEndAllVoiceActions(action.deleteRecord)
is RoomDetailAction.RoomUpgradeSuccess -> {
setState {
copy(joinUpgradedRoomAsync = Success(action.replacementRoomId))
@ -612,56 +586,6 @@ class RoomDetailViewModel @AssistedInject constructor(
}
}
private fun handleStartRecordingVoiceMessage() {
try {
voiceMessageHelper.startRecording()
} catch (failure: Throwable) {
_viewEvents.post(RoomDetailViewEvents.Failure(failure))
}
}
private fun handleEndRecordingVoiceMessage(isCancelled: Boolean) {
voiceMessageHelper.stopPlayback()
if (isCancelled) {
voiceMessageHelper.deleteRecording()
} else {
voiceMessageHelper.stopRecording()?.let { audioType ->
if (audioType.duration > 1000) {
room.sendMedia(audioType.toContentAttachmentData(), false, emptySet())
} else {
voiceMessageHelper.deleteRecording()
}
}
}
}
private fun handlePlayOrPauseVoicePlayback(action: RoomDetailAction.PlayOrPauseVoicePlayback) {
viewModelScope.launch(Dispatchers.IO) {
try {
// Download can fail
val audioFile = session.fileService().downloadFile(action.messageAudioContent)
// Conversion can fail, fallback to the original file in this case and let the player fail for us
val convertedFile = voicePlayerHelper.convertFile(audioFile) ?: audioFile
// Play can fail
voiceMessageHelper.startOrPausePlayback(action.eventId, convertedFile)
} catch (failure: Throwable) {
_viewEvents.post(RoomDetailViewEvents.Failure(failure))
}
}
}
private fun handlePlayOrPauseRecordingPlayback() {
voiceMessageHelper.startOrPauseRecordingPlayback()
}
private fun handleEndAllVoiceActions(deleteRecord: Boolean) {
voiceMessageHelper.stopAllVoiceActions(deleteRecord)
}
private fun handlePauseRecordingVoiceMessage() {
voiceMessageHelper.pauseRecording()
}
private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled()
fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state ->

View File

@ -0,0 +1,41 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.composer
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
sealed class MessageComposerAction : VectorViewModelAction {
data class SaveDraft(val draft: String) : MessageComposerAction()
data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : MessageComposerAction()
data class EnterEditMode(val eventId: String, val text: String) : MessageComposerAction()
data class EnterQuoteMode(val eventId: String, val text: String) : MessageComposerAction()
data class EnterReplyMode(val eventId: String, val text: String) : MessageComposerAction()
data class EnterRegularMode(val text: String, val fromSharing: Boolean) : MessageComposerAction()
data class UserIsTyping(val isTyping: Boolean) : MessageComposerAction()
data class OnTextChanged(val text: CharSequence) : MessageComposerAction()
// Voice Message
data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : MessageComposerAction()
object StartRecordingVoiceMessage : MessageComposerAction()
data class EndRecordingVoiceMessage(val isCancelled: Boolean) : MessageComposerAction()
object PauseRecordingVoiceMessage : MessageComposerAction()
data class PlayOrPauseVoicePlayback(val eventId: String, val messageAudioContent: MessageAudioContent) : MessageComposerAction()
object PlayOrPauseRecordingPlayback : MessageComposerAction()
data class EndAllVoiceActions(val deleteRecord: Boolean = true) : MessageComposerAction()
}

View File

@ -36,7 +36,7 @@ import im.vector.app.databinding.ComposerLayoutBinding
/**
* Encapsulate the timeline composer UX.
*/
class TextComposerView @JvmOverloads constructor(
class MessageComposerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) {

View File

@ -20,13 +20,13 @@ import androidx.annotation.StringRes
import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.command.Command
sealed class TextComposerViewEvents : VectorViewEvents {
sealed class MessageComposerViewEvents : VectorViewEvents {
data class AnimateSendButtonVisibility(val isVisible: Boolean) : TextComposerViewEvents()
data class AnimateSendButtonVisibility(val isVisible: Boolean) : MessageComposerViewEvents()
data class ShowMessage(val message: String) : TextComposerViewEvents()
data class ShowMessage(val message: String) : MessageComposerViewEvents()
abstract class SendMessageResult : TextComposerViewEvents()
abstract class SendMessageResult : MessageComposerViewEvents()
object MessageSent : SendMessageResult()
data class JoinRoomCommandSuccess(val roomId: String) : SendMessageResult()
@ -36,10 +36,12 @@ sealed class TextComposerViewEvents : VectorViewEvents {
data class SlashCommandResultOk(@StringRes val messageRes: Int? = null) : SendMessageResult()
class SlashCommandResultError(val throwable: Throwable) : SendMessageResult()
data class OpenRoomMemberProfile(val userId: String) : TextComposerViewEvents()
data class OpenRoomMemberProfile(val userId: String) : MessageComposerViewEvents()
// TODO Remove
object SlashCommandNotImplemented : SendMessageResult()
data class ShowRoomUpgradeDialog(val newVersion: String, val isPublic: Boolean) : TextComposerViewEvents()
data class ShowRoomUpgradeDialog(val newVersion: String, val isPublic: Boolean) : MessageComposerViewEvents()
data class VoicePlaybackOrRecordingFailure(val throwable: Throwable) : MessageComposerViewEvents()
}

View File

@ -16,24 +16,27 @@
package im.vector.app.features.home.room.detail.composer
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.R
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.attachments.toContentAttachmentData
import im.vector.app.features.command.CommandParser
import im.vector.app.features.command.ParsedCommand
import im.vector.app.features.home.room.detail.ChatEffect
import im.vector.app.features.home.room.detail.RoomDetailFragment
import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator
import im.vector.app.features.home.room.detail.toMessageType
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voice.VoicePlayerHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.query.QueryStringValue
@ -53,13 +56,15 @@ import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent
import org.matrix.android.sdk.api.session.space.CreateSpaceParams
import timber.log.Timber
class TextComposerViewModel @AssistedInject constructor(
@Assisted initialState: TextComposerViewState,
class MessageComposerViewModel @AssistedInject constructor(
@Assisted initialState: MessageComposerViewState,
private val session: Session,
private val stringProvider: StringProvider,
private val vectorPreferences: VectorPreferences,
private val rainbowGenerator: RainbowGenerator
) : VectorViewModel<TextComposerViewState, TextComposerAction, TextComposerViewEvents>(initialState) {
private val rainbowGenerator: RainbowGenerator,
private val voiceMessageHelper: VoiceMessageHelper,
private val voicePlayerHelper: VoicePlayerHelper
) : VectorViewModel<MessageComposerViewState, MessageComposerAction, MessageComposerViewEvents>(initialState) {
private val room = session.getRoom(initialState.roomId)!!
@ -72,26 +77,32 @@ class TextComposerViewModel @AssistedInject constructor(
subscribeToStateInternal()
}
override fun handle(action: TextComposerAction) {
override fun handle(action: MessageComposerAction) {
Timber.v("Handle action: $action")
when (action) {
is TextComposerAction.EnterEditMode -> handleEnterEditMode(action)
is TextComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
is TextComposerAction.EnterRegularMode -> handleEnterRegularMode(action)
is TextComposerAction.EnterReplyMode -> handleEnterReplyMode(action)
is TextComposerAction.SaveDraft -> handleSaveDraft(action)
is TextComposerAction.SendMessage -> handleSendMessage(action)
is TextComposerAction.UserIsTyping -> handleUserIsTyping(action)
is TextComposerAction.OnTextChanged -> handleOnTextChanged(action)
is TextComposerAction.OnVoiceRecordingStateChanged -> handleOnVoiceRecordingStateChanged(action)
is MessageComposerAction.EnterEditMode -> handleEnterEditMode(action)
is MessageComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
is MessageComposerAction.EnterRegularMode -> handleEnterRegularMode(action)
is MessageComposerAction.EnterReplyMode -> handleEnterReplyMode(action)
is MessageComposerAction.SaveDraft -> handleSaveDraft(action)
is MessageComposerAction.SendMessage -> handleSendMessage(action)
is MessageComposerAction.UserIsTyping -> handleUserIsTyping(action)
is MessageComposerAction.OnTextChanged -> handleOnTextChanged(action)
is MessageComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action)
MessageComposerAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage()
is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled)
is MessageComposerAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action)
MessageComposerAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage()
MessageComposerAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback()
is MessageComposerAction.EndAllVoiceActions -> handleEndAllVoiceActions(action.deleteRecord)
}
}
private fun handleOnVoiceRecordingStateChanged(action: TextComposerAction.OnVoiceRecordingStateChanged) = setState {
copy(isVoiceRecording = action.isRecording)
private fun handleOnVoiceRecordingUiStateChanged(action: MessageComposerAction.OnVoiceRecordingUiStateChanged) = setState {
copy(voiceRecordingUiState = action.uiState)
}
private fun handleOnTextChanged(action: TextComposerAction.OnTextChanged) {
private fun handleOnTextChanged(action: MessageComposerAction.OnTextChanged) {
setState {
// Makes sure currentComposerText is upToDate when accessing further setState
currentComposerText = action.text
@ -101,7 +112,7 @@ class TextComposerViewModel @AssistedInject constructor(
}
private fun subscribeToStateInternal() {
onEach(TextComposerViewState::sendMode, TextComposerViewState::canSendMessage, TextComposerViewState::isVoiceRecording) { _, _, _ ->
onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage, MessageComposerViewState::isVoiceRecording) { _, _, _ ->
updateIsSendButtonVisibility(false)
}
}
@ -109,16 +120,16 @@ class TextComposerViewModel @AssistedInject constructor(
private fun updateIsSendButtonVisibility(triggerAnimation: Boolean) = setState {
val isSendButtonVisible = isComposerVisible && (sendMode !is SendMode.REGULAR || currentComposerText.isNotBlank())
if (this.isSendButtonVisible != isSendButtonVisible && triggerAnimation) {
_viewEvents.post(TextComposerViewEvents.AnimateSendButtonVisibility(isSendButtonVisible))
_viewEvents.post(MessageComposerViewEvents.AnimateSendButtonVisibility(isSendButtonVisible))
}
copy(isSendButtonVisible = isSendButtonVisible)
}
private fun handleEnterRegularMode(action: TextComposerAction.EnterRegularMode) = setState {
private fun handleEnterRegularMode(action: MessageComposerAction.EnterRegularMode) = setState {
copy(sendMode = SendMode.REGULAR(action.text, action.fromSharing))
}
private fun handleEnterEditMode(action: TextComposerAction.EnterEditMode) {
private fun handleEnterEditMode(action: MessageComposerAction.EnterEditMode) {
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
setState { copy(sendMode = SendMode.EDIT(timelineEvent, timelineEvent.getTextEditableContent())) }
}
@ -132,19 +143,19 @@ class TextComposerViewModel @AssistedInject constructor(
}
}
private fun handleEnterQuoteMode(action: TextComposerAction.EnterQuoteMode) {
private fun handleEnterQuoteMode(action: MessageComposerAction.EnterQuoteMode) {
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
setState { copy(sendMode = SendMode.QUOTE(timelineEvent, action.text)) }
}
}
private fun handleEnterReplyMode(action: TextComposerAction.EnterReplyMode) {
private fun handleEnterReplyMode(action: MessageComposerAction.EnterReplyMode) {
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
setState { copy(sendMode = SendMode.REPLY(timelineEvent, action.text)) }
}
}
private fun handleSendMessage(action: TextComposerAction.SendMessage) {
private fun handleSendMessage(action: MessageComposerAction.SendMessage) {
withState { state ->
when (state.sendMode) {
is SendMode.REGULAR -> {
@ -152,22 +163,22 @@ class TextComposerViewModel @AssistedInject constructor(
is ParsedCommand.ErrorNotACommand -> {
// Send the text message to the room
room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
_viewEvents.post(TextComposerViewEvents.MessageSent)
_viewEvents.post(MessageComposerViewEvents.MessageSent)
popDraft()
}
is ParsedCommand.ErrorSyntax -> {
_viewEvents.post(TextComposerViewEvents.SlashCommandError(slashCommandResult.command))
_viewEvents.post(MessageComposerViewEvents.SlashCommandError(slashCommandResult.command))
}
is ParsedCommand.ErrorEmptySlashCommand -> {
_viewEvents.post(TextComposerViewEvents.SlashCommandUnknown("/"))
_viewEvents.post(MessageComposerViewEvents.SlashCommandUnknown("/"))
}
is ParsedCommand.ErrorUnknownSlashCommand -> {
_viewEvents.post(TextComposerViewEvents.SlashCommandUnknown(slashCommandResult.slashCommand))
_viewEvents.post(MessageComposerViewEvents.SlashCommandUnknown(slashCommandResult.slashCommand))
}
is ParsedCommand.SendPlainText -> {
// Send the text message to the room, without markdown
room.sendTextMessage(slashCommandResult.message, autoMarkdown = false)
_viewEvents.post(TextComposerViewEvents.MessageSent)
_viewEvents.post(MessageComposerViewEvents.MessageSent)
popDraft()
}
is ParsedCommand.ChangeRoomName -> {
@ -184,11 +195,11 @@ class TextComposerViewModel @AssistedInject constructor(
}
is ParsedCommand.ClearScalarToken -> {
// TODO
_viewEvents.post(TextComposerViewEvents.SlashCommandNotImplemented)
_viewEvents.post(MessageComposerViewEvents.SlashCommandNotImplemented)
}
is ParsedCommand.SetMarkdown -> {
vectorPreferences.setMarkdownEnabled(slashCommandResult.enable)
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk(
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(
if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled))
popDraft()
}
@ -216,21 +227,21 @@ class TextComposerViewModel @AssistedInject constructor(
}
is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, autoMarkdown = action.autoMarkdown)
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
popDraft()
}
is ParsedCommand.SendRainbow -> {
slashCommandResult.message.toString().let {
room.sendFormattedTextMessage(it, rainbowGenerator.generate(it))
}
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
popDraft()
}
is ParsedCommand.SendRainbowEmote -> {
slashCommandResult.message.toString().let {
room.sendFormattedTextMessage(it, rainbowGenerator.generate(it), MessageType.MSGTYPE_EMOTE)
}
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
popDraft()
}
is ParsedCommand.SendSpoiler -> {
@ -238,22 +249,22 @@ class TextComposerViewModel @AssistedInject constructor(
"[${stringProvider.getString(R.string.spoiler)}](${slashCommandResult.message})",
"<span data-mx-spoiler>${slashCommandResult.message}</span>"
)
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
popDraft()
}
is ParsedCommand.SendShrug -> {
sendPrefixedMessage("¯\\_(ツ)_/¯", slashCommandResult.message)
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
popDraft()
}
is ParsedCommand.SendLenny -> {
sendPrefixedMessage("( ͡° ͜ʖ ͡°)", slashCommandResult.message)
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
popDraft()
}
is ParsedCommand.SendChatEffect -> {
sendChatEffect(slashCommandResult)
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
popDraft()
}
is ParsedCommand.ChangeTopic -> {
@ -272,25 +283,25 @@ class TextComposerViewModel @AssistedInject constructor(
handleChangeAvatarForRoomSlashCommand(slashCommandResult)
}
is ParsedCommand.ShowUser -> {
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
handleWhoisSlashCommand(slashCommandResult)
popDraft()
}
is ParsedCommand.DiscardSession -> {
if (room.isEncrypted()) {
session.cryptoService().discardOutboundSession(room.roomId)
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
popDraft()
} else {
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(
TextComposerViewEvents
MessageComposerViewEvents
.ShowMessage(stringProvider.getString(R.string.command_description_discard_session_not_handled))
)
}
}
is ParsedCommand.CreateSpace -> {
_viewEvents.post(TextComposerViewEvents.SlashCommandLoading)
_viewEvents.post(MessageComposerViewEvents.SlashCommandLoading)
viewModelScope.launch(Dispatchers.IO) {
try {
val params = CreateSpaceParams().apply {
@ -306,15 +317,15 @@ class TextComposerViewModel @AssistedInject constructor(
true
)
popDraft()
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
} catch (failure: Throwable) {
_viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure))
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultError(failure))
}
}
Unit
}
is ParsedCommand.AddToSpace -> {
_viewEvents.post(TextComposerViewEvents.SlashCommandLoading)
_viewEvents.post(MessageComposerViewEvents.SlashCommandLoading)
viewModelScope.launch(Dispatchers.IO) {
try {
session.spaceService().getSpace(slashCommandResult.spaceId)
@ -325,22 +336,22 @@ class TextComposerViewModel @AssistedInject constructor(
false
)
popDraft()
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
} catch (failure: Throwable) {
_viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure))
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultError(failure))
}
}
Unit
}
is ParsedCommand.JoinSpace -> {
_viewEvents.post(TextComposerViewEvents.SlashCommandLoading)
_viewEvents.post(MessageComposerViewEvents.SlashCommandLoading)
viewModelScope.launch(Dispatchers.IO) {
try {
session.spaceService().joinSpace(slashCommandResult.spaceIdOrAlias)
popDraft()
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
} catch (failure: Throwable) {
_viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure))
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultError(failure))
}
}
Unit
@ -350,21 +361,21 @@ class TextComposerViewModel @AssistedInject constructor(
try {
session.getRoom(slashCommandResult.roomId)?.leave(null)
popDraft()
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
} catch (failure: Throwable) {
_viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure))
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultError(failure))
}
}
Unit
}
is ParsedCommand.UpgradeRoom -> {
_viewEvents.post(
TextComposerViewEvents.ShowRoomUpgradeDialog(
MessageComposerViewEvents.ShowRoomUpgradeDialog(
slashCommandResult.newVersion,
room.roomSummary()?.isPublic ?: false
)
)
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk())
popDraft()
}
}.exhaustive
@ -389,18 +400,18 @@ class TextComposerViewModel @AssistedInject constructor(
Timber.w("Same message content, do not send edition")
}
}
_viewEvents.post(TextComposerViewEvents.MessageSent)
_viewEvents.post(MessageComposerViewEvents.MessageSent)
popDraft()
}
is SendMode.QUOTE -> {
room.sendQuotedTextMessage(state.sendMode.timelineEvent, action.text.toString(), action.autoMarkdown)
_viewEvents.post(TextComposerViewEvents.MessageSent)
_viewEvents.post(MessageComposerViewEvents.MessageSent)
popDraft()
}
is SendMode.REPLY -> {
state.sendMode.timelineEvent.let {
room.replyToMessage(it, action.text.toString(), action.autoMarkdown)
_viewEvents.post(TextComposerViewEvents.MessageSent)
_viewEvents.post(MessageComposerViewEvents.MessageSent)
popDraft()
}
}
@ -449,7 +460,7 @@ class TextComposerViewModel @AssistedInject constructor(
}
}
private fun handleUserIsTyping(action: TextComposerAction.UserIsTyping) {
private fun handleUserIsTyping(action: MessageComposerAction.UserIsTyping) {
if (vectorPreferences.sendTypingNotifs()) {
if (action.isTyping) {
room.userIsTyping()
@ -477,13 +488,13 @@ class TextComposerViewModel @AssistedInject constructor(
try {
session.joinRoom(command.roomAlias, command.reason, emptyList())
} catch (failure: Throwable) {
_viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure))
_viewEvents.post(MessageComposerViewEvents.SlashCommandResultError(failure))
return@launch
}
session.getRoomSummary(command.roomAlias)
?.roomId
?.let {
_viewEvents.post(TextComposerViewEvents.JoinRoomCommandSuccess(it))
_viewEvents.post(MessageComposerViewEvents.JoinRoomCommandSuccess(it))
}
}
}
@ -630,7 +641,7 @@ class TextComposerViewModel @AssistedInject constructor(
}
private fun handleWhoisSlashCommand(whois: ParsedCommand.ShowUser) {
_viewEvents.post(TextComposerViewEvents.OpenRoomMemberProfile(whois.userId))
_viewEvents.post(MessageComposerViewEvents.OpenRoomMemberProfile(whois.userId))
}
private fun sendPrefixedMessage(prefix: String, message: CharSequence) {
@ -647,7 +658,7 @@ class TextComposerViewModel @AssistedInject constructor(
/**
* Convert a send mode to a draft and save the draft
*/
private fun handleSaveDraft(action: TextComposerAction.SaveDraft) = withState {
private fun handleSaveDraft(action: MessageComposerAction.SaveDraft) = withState {
session.coroutineScope.launch {
when {
it.sendMode is SendMode.REGULAR && !it.sendMode.fromSharing -> {
@ -670,24 +681,88 @@ class TextComposerViewModel @AssistedInject constructor(
}
}
private fun handleStartRecordingVoiceMessage() {
try {
voiceMessageHelper.startRecording()
} catch (failure: Throwable) {
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
}
}
private fun handleEndRecordingVoiceMessage(isCancelled: Boolean) {
voiceMessageHelper.stopPlayback()
if (isCancelled) {
voiceMessageHelper.deleteRecording()
} else {
voiceMessageHelper.stopRecording()?.let { audioType ->
if (audioType.duration > 1000) {
room.sendMedia(audioType.toContentAttachmentData(), false, emptySet())
} else {
voiceMessageHelper.deleteRecording()
}
}
}
}
private fun handlePlayOrPauseVoicePlayback(action: MessageComposerAction.PlayOrPauseVoicePlayback) {
viewModelScope.launch(Dispatchers.IO) {
try {
// Download can fail
val audioFile = session.fileService().downloadFile(action.messageAudioContent)
// Conversion can fail, fallback to the original file in this case and let the player fail for us
val convertedFile = voicePlayerHelper.convertFile(audioFile) ?: audioFile
// Play can fail
voiceMessageHelper.startOrPausePlayback(action.eventId, convertedFile)
} catch (failure: Throwable) {
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
}
}
}
private fun handlePlayOrPauseRecordingPlayback() {
voiceMessageHelper.startOrPauseRecordingPlayback()
}
private fun handleEndAllVoiceActions(deleteRecord: Boolean) {
voiceMessageHelper.stopAllVoiceActions(deleteRecord)
}
private fun handlePauseRecordingVoiceMessage() {
voiceMessageHelper.pauseRecording()
}
private fun launchSlashCommandFlowSuspendable(block: suspend () -> Unit) {
_viewEvents.post(TextComposerViewEvents.SlashCommandLoading)
_viewEvents.post(MessageComposerViewEvents.SlashCommandLoading)
viewModelScope.launch {
val event = try {
block()
popDraft()
TextComposerViewEvents.SlashCommandResultOk()
MessageComposerViewEvents.SlashCommandResultOk()
} catch (failure: Throwable) {
TextComposerViewEvents.SlashCommandResultError(failure)
MessageComposerViewEvents.SlashCommandResultError(failure)
}
_viewEvents.post(event)
}
}
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<TextComposerViewModel, TextComposerViewState> {
override fun create(initialState: TextComposerViewState): TextComposerViewModel
interface Factory {
fun create(initialState: MessageComposerViewState): MessageComposerViewModel
}
companion object : MavericksViewModelFactory<TextComposerViewModel, TextComposerViewState> by hiltMavericksViewModelFactory()
/**
* We're unable to create this ViewModel with `by hiltMavericksViewModelFactory()` due to the
* VoiceMessagePlaybackTracker being ActivityScoped
*
* This factory allows us to provide the ViewModel instance from the Fragment directly
* bypassing the Singleton scope requirement
*/
companion object : MavericksViewModelFactory<MessageComposerViewModel, MessageComposerViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: MessageComposerViewState): MessageComposerViewModel {
val fragment: RoomDetailFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.messageComposerViewModelFactory.create(state)
}
}
}

View File

@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.composer
import com.airbnb.mvrx.MavericksState
import im.vector.app.features.home.room.detail.RoomDetailArgs
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
@ -41,16 +42,30 @@ sealed class SendMode(open val text: String) {
data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
}
data class TextComposerViewState(
data class MessageComposerViewState(
val roomId: String,
val canSendMessage: Boolean = true,
val isVoiceRecording: Boolean = false,
val isSendButtonVisible: Boolean = false,
val sendMode: SendMode = SendMode.REGULAR("", false)
val sendMode: SendMode = SendMode.REGULAR("", false),
val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.None
) : MavericksState {
val isVoiceRecording = when (voiceRecordingUiState) {
VoiceMessageRecorderView.RecordingUiState.None,
VoiceMessageRecorderView.RecordingUiState.Cancelled,
VoiceMessageRecorderView.RecordingUiState.Playback -> false
VoiceMessageRecorderView.RecordingUiState.Locked,
VoiceMessageRecorderView.RecordingUiState.Started -> true
}
val isVoiceMessageIdle = when (voiceRecordingUiState) {
VoiceMessageRecorderView.RecordingUiState.None, VoiceMessageRecorderView.RecordingUiState.Cancelled -> false
else -> true
}
val isComposerVisible = canSendMessage && !isVoiceRecording
val isVoiceMessageRecorderVisible = canSendMessage && !isSendButtonVisible
@Suppress("UNUSED") // needed by mavericks
constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
}

View File

@ -1,31 +0,0 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.composer
import im.vector.app.core.platform.VectorViewModelAction
sealed class TextComposerAction : VectorViewModelAction {
data class SaveDraft(val draft: String) : TextComposerAction()
data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : TextComposerAction()
data class EnterEditMode(val eventId: String, val text: String) : TextComposerAction()
data class EnterQuoteMode(val eventId: String, val text: String) : TextComposerAction()
data class EnterReplyMode(val eventId: String, val text: String) : TextComposerAction()
data class EnterRegularMode(val text: String, val fromSharing: Boolean) : TextComposerAction()
data class UserIsTyping(val isTyping: Boolean) : TextComposerAction()
data class OnTextChanged(val text: CharSequence) : TextComposerAction()
data class OnVoiceRecordingStateChanged(val isRecording: Boolean) : TextComposerAction()
}

View File

@ -1,551 +0,0 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.composer
import android.content.Context
import android.text.format.DateUtils
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.extensions.setAttributeBackground
import im.vector.app.core.extensions.setAttributeTintedBackground
import im.vector.app.core.extensions.setAttributeTintedImageResource
import im.vector.app.core.hardware.vibrate
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber
import kotlin.math.abs
import kotlin.math.floor
/**
* Encapsulates the voice message recording view and animations.
*/
class VoiceMessageRecorderView : ConstraintLayout, VoiceMessagePlaybackTracker.Listener {
interface Callback {
// Return true if the recording is started
fun onVoiceRecordingStarted(): Boolean
fun onVoiceRecordingEnded(isCancelled: Boolean)
fun onVoiceRecordingPlaybackModeOn()
fun onVoicePlaybackButtonClicked()
}
private lateinit var views: ViewVoiceMessageRecorderBinding
var callback: Callback? = null
var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker? = null
set(value) {
field = value
value?.track(VoiceMessagePlaybackTracker.RECORDING_ID, this)
}
private var recordingState: RecordingState = RecordingState.NONE
private var firstX: Float = 0f
private var firstY: Float = 0f
private var lastX: Float = 0f
private var lastY: Float = 0f
private var lastDistanceX: Float = 0f
private var lastDistanceY: Float = 0f
private var recordingTicker: CountUpTimer? = null
private val dimensionConverter = DimensionConverter(context.resources)
private val minimumMove = dimensionConverter.dpToPx(16)
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
private val rtlXMultiplier = context.resources.getInteger(R.integer.rtl_x_multiplier)
// Don't convert to primary constructor.
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : super(context, attrs, defStyleAttr) {
initialize()
}
fun initialize() {
inflate(context, R.layout.view_voice_message_recorder, this)
views = ViewVoiceMessageRecorderBinding.bind(this)
initVoiceRecordingViews()
initListeners()
}
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
// onVisibilityChanged is called by constructor on api 21 and 22.
if (!this::views.isInitialized) return
if (changedView == this && visibility == VISIBLE) {
views.voiceMessageMicButton.contentDescription = context.getString(R.string.a11y_start_voice_message)
} else {
views.voiceMessageMicButton.contentDescription = ""
}
}
fun initVoiceRecordingViews() {
recordingState = RecordingState.NONE
hideRecordingViews(null)
stopRecordingTicker()
views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
}
private fun initListeners() {
views.voiceMessageSendButton.setOnClickListener {
stopRecordingTicker()
hideRecordingViews(isCancelled = false)
views.voiceMessageSendButton.isVisible = false
recordingState = RecordingState.NONE
}
views.voiceMessageDeletePlayback.setOnClickListener {
stopRecordingTicker()
hideRecordingViews(isCancelled = true)
views.voiceMessageSendButton.isVisible = false
recordingState = RecordingState.NONE
}
views.voicePlaybackWaveform.setOnClickListener {
if (recordingState != RecordingState.PLAYBACK) {
recordingState = RecordingState.PLAYBACK
showPlaybackViews()
}
}
views.voicePlaybackControlButton.setOnClickListener {
callback?.onVoicePlaybackButtonClicked()
}
views.voiceMessageMicButton.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
handleMicActionDown(event)
true
}
MotionEvent.ACTION_UP -> {
handleMicActionUp()
true
}
MotionEvent.ACTION_MOVE -> {
if (recordingState == RecordingState.CANCELLED) return@setOnTouchListener false
handleMicActionMove(event)
true
}
else ->
false
}
}
}
private fun handleMicActionDown(event: MotionEvent) {
val recordingStarted = callback?.onVoiceRecordingStarted().orFalse()
if (recordingStarted) {
startRecordingTicker()
renderToast(context.getString(R.string.voice_message_release_to_send_toast))
recordingState = RecordingState.STARTED
showRecordingViews()
firstX = event.rawX
firstY = event.rawY
lastX = firstX
lastY = firstY
lastDistanceX = 0F
lastDistanceY = 0F
}
}
private fun handleMicActionUp() {
if (recordingState != RecordingState.LOCKED && recordingState != RecordingState.NONE) {
stopRecordingTicker()
val isCancelled = recordingState == RecordingState.NONE || recordingState == RecordingState.CANCELLED
recordingState = RecordingState.NONE
hideRecordingViews(isCancelled = isCancelled)
}
}
private fun handleMicActionMove(event: MotionEvent) {
val currentX = event.rawX
val currentY = event.rawY
val distanceX = abs(firstX - currentX)
val distanceY = abs(firstY - currentY)
val isRecordingStateChanged = updateRecordingState(currentX, currentY, distanceX, distanceY)
when (recordingState) {
RecordingState.CANCELLING -> {
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
views.voiceMessageSlideToCancel.alpha = reducedAlpha
views.voiceMessageTimerIndicator.alpha = reducedAlpha
views.voiceMessageTimer.alpha = reducedAlpha
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockArrow.isVisible = false
// Reset Y translations
views.voiceMessageMicButton.translationY = 0F
views.voiceMessageLockArrow.translationY = 0F
}
RecordingState.LOCKING -> {
views.voiceMessageLockImage.setAttributeTintedImageResource(R.drawable.ic_voice_message_locked, R.attr.colorPrimary)
val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
views.voiceMessageMicButton.translationY = translationAmount
views.voiceMessageLockArrow.translationY = translationAmount
views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
// Reset X translations
views.voiceMessageMicButton.translationX = 0F
views.voiceMessageSlideToCancel.translationX = 0F
}
RecordingState.CANCELLED -> {
hideRecordingViews(isCancelled = true)
vibrate(context)
}
RecordingState.LOCKED -> {
if (isRecordingStateChanged) { // Do not update views if it was already in locked state.
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
views.voiceMessageLockImage.postDelayed({
showRecordingLockedViews()
}, 500)
}
}
RecordingState.STARTED -> {
showRecordingViews()
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
}
RecordingState.NONE -> Timber.d("VoiceMessageRecorderView shouldn't be in NONE state while moving.")
RecordingState.PLAYBACK -> Timber.d("VoiceMessageRecorderView shouldn't be in PLAYBACK state while moving.")
}
lastX = currentX
lastY = currentY
lastDistanceX = distanceX
lastDistanceY = distanceY
}
private fun updateRecordingState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): Boolean {
val previousRecordingState = recordingState
if (recordingState == RecordingState.STARTED) {
// Determine if cancelling or locking for the first move action.
if (((currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)) &&
distanceX > distanceY && distanceX > lastDistanceX) {
recordingState = RecordingState.CANCELLING
} else if (currentY < firstY && distanceY > distanceX && distanceY > lastDistanceY) {
recordingState = RecordingState.LOCKING
}
} else if (recordingState == RecordingState.CANCELLING) {
// Check if cancelling conditions met, also check if it should be initial state
if (distanceX < minimumMove && distanceX < lastDistanceX) {
recordingState = RecordingState.STARTED
} else if (shouldCancelRecording(distanceX)) {
recordingState = RecordingState.CANCELLED
}
} else if (recordingState == RecordingState.LOCKING) {
// Check if locking conditions met, also check if it should be initial state
if (distanceY < minimumMove && distanceY < lastDistanceY) {
recordingState = RecordingState.STARTED
} else if (shouldLockRecording(distanceY)) {
recordingState = RecordingState.LOCKED
}
}
return previousRecordingState != recordingState
}
private fun shouldCancelRecording(distanceX: Float): Boolean {
return distanceX >= distanceToCancel
}
private fun shouldLockRecording(distanceY: Float): Boolean {
return distanceY >= distanceToLock
}
private fun startRecordingTicker() {
recordingTicker?.stop()
recordingTicker = CountUpTimer().apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
onRecordingTick(milliseconds)
}
}
resume()
}
onRecordingTick(0L)
}
private fun onRecordingTick(milliseconds: Long) {
renderRecordingTimer(milliseconds / 1_000)
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
if (timeDiffToRecordingLimit <= 0) {
views.voiceMessageRecordingLayout.post {
recordingState = RecordingState.PLAYBACK
showPlaybackViews()
stopRecordingTicker()
}
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
views.voiceMessageRecordingLayout.post {
renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, floor(timeDiffToRecordingLimit / 1000f).toInt()))
vibrate(context)
}
}
}
private fun renderToast(message: String) {
views.voiceMessageToast.removeCallbacks(hideToastRunnable)
views.voiceMessageToast.text = message
views.voiceMessageToast.isVisible = true
views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
}
private fun hideToast() {
views.voiceMessageToast.isVisible = false
}
private val hideToastRunnable = Runnable {
views.voiceMessageToast.isVisible = false
}
private fun renderRecordingTimer(recordingTimeMillis: Long) {
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
if (recordingState == RecordingState.LOCKED) {
views.voicePlaybackTime.apply {
post {
text = formattedTimerText
}
}
} else {
views.voiceMessageTimer.post {
views.voiceMessageTimer.text = formattedTimerText
}
}
}
private fun renderRecordingWaveform(amplitudeList: Array<Int>) {
post {
views.voicePlaybackWaveform.apply {
amplitudeList.iterator().forEach {
update(it)
}
}
}
}
private fun stopRecordingTicker() {
recordingTicker?.stop()
recordingTicker = null
}
private fun showRecordingViews() {
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary)
views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
setMargins(0, 0, 0, 0)
}
views.voiceMessageMicButton.animate().scaleX(1.5f).scaleY(1.5f).setDuration(300).start()
views.voiceMessageLockBackground.isVisible = true
views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockImage.isVisible = true
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
views.voiceMessageLockImage.animate().setDuration(500).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockArrow.isVisible = true
views.voiceMessageLockArrow.alpha = 1f
views.voiceMessageSlideToCancel.isVisible = true
views.voiceMessageTimerIndicator.isVisible = true
views.voiceMessageTimer.isVisible = true
views.voiceMessageSlideToCancel.alpha = 1f
views.voiceMessageTimerIndicator.alpha = 1f
views.voiceMessageTimer.alpha = 1f
views.voiceMessageSendButton.isVisible = false
}
private fun hideRecordingViews(isCancelled: Boolean?) {
// We need to animate the lock image first
if (recordingState != RecordingState.LOCKED || isCancelled.orFalse()) {
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockImage.animate().translationY(0f).start()
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockBackground.animate().translationY(0f).start()
} else {
animateLockImageWithBackground()
}
views.voiceMessageLockArrow.isVisible = false
views.voiceMessageLockArrow.animate().translationY(0f).start()
views.voiceMessageSlideToCancel.isVisible = false
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
views.voiceMessagePlaybackLayout.isVisible = false
if (recordingState != RecordingState.LOCKED) {
views.voiceMessageMicButton
.animate()
.scaleX(1f)
.scaleY(1f)
.translationX(0f)
.translationY(0f)
.setDuration(150)
.withEndAction {
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
resetMicButtonUi()
isCancelled?.let {
callback?.onVoiceRecordingEnded(it)
}
}
.start()
} else {
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
views.voiceMessageMicButton.apply {
scaleX = 1f
scaleY = 1f
translationX = 0f
translationY = 0f
}
isCancelled?.let {
callback?.onVoiceRecordingEnded(it)
}
}
// Hide toasts if user cancelled recording before the timeout of the toast.
if (recordingState == RecordingState.CANCELLED || recordingState == RecordingState.NONE) {
hideToast()
}
}
private fun resetMicButtonUi() {
views.voiceMessageMicButton.isVisible = true
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
views.voiceMessageMicButton.setAttributeBackground(android.R.attr.selectableItemBackgroundBorderless)
views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
if (rtlXMultiplier == -1) {
// RTL
setMargins(dimensionConverter.dpToPx(12), 0, 0, dimensionConverter.dpToPx(12))
} else {
setMargins(0, 0, dimensionConverter.dpToPx(12), dimensionConverter.dpToPx(12))
}
}
}
private fun animateLockImageWithBackground() {
views.voiceMessageLockBackground.updateLayoutParams {
height = dimensionConverter.dpToPx(78)
}
views.voiceMessageLockBackground.apply {
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
updateLayoutParams {
height = dimensionConverter.dpToPx(180)
}
isVisible = false
scaleX = 1f
scaleY = 1f
animate().translationY(0f).start()
}
.start()
}
// Lock image animation
views.voiceMessageMicButton.isInvisible = true
views.voiceMessageLockImage.apply {
isVisible = true
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
isVisible = false
scaleX = 1f
scaleY = 1f
translationY = 0f
resetMicButtonUi()
}
.start()
}
}
private fun showRecordingLockedViews() {
hideRecordingViews(null)
views.voiceMessagePlaybackLayout.isVisible = true
views.voiceMessagePlaybackTimerIndicator.isVisible = true
views.voicePlaybackControlButton.isVisible = false
views.voiceMessageSendButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
renderToast(context.getString(R.string.voice_message_tap_to_stop_toast))
}
private fun showPlaybackViews() {
views.voiceMessagePlaybackTimerIndicator.isVisible = false
views.voicePlaybackControlButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
callback?.onVoiceRecordingPlaybackModeOn()
}
private enum class RecordingState {
NONE,
STARTED,
CANCELLING,
CANCELLED,
LOCKING,
LOCKED,
PLAYBACK
}
/**
* Returns true if the voice message is recording or is in playback mode
*/
fun isActive() = recordingState !in listOf(RecordingState.NONE, RecordingState.CANCELLED)
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
when (state) {
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
renderRecordingWaveform(state.amplitudeList.toTypedArray())
}
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
views.voicePlaybackControlButton.contentDescription = context.getString(R.string.a11y_pause_voice_message)
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
views.voicePlaybackTime.text = formattedTimerText
}
is VoiceMessagePlaybackTracker.Listener.State.Paused,
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
views.voicePlaybackControlButton.contentDescription = context.getString(R.string.a11y_play_voice_message)
}
}
}
}

View File

@ -0,0 +1,101 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.composer.voice
import android.content.res.Resources
import android.view.MotionEvent
import im.vector.app.R
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
class DraggableStateProcessor(
resources: Resources,
dimensionConverter: DimensionConverter,
) {
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
private var firstX: Float = 0f
private var firstY: Float = 0f
private var lastDistanceX: Float = 0f
private var lastDistanceY: Float = 0f
fun initialize(event: MotionEvent) {
firstX = event.rawX
firstY = event.rawY
lastDistanceX = 0F
lastDistanceY = 0F
}
fun process(event: MotionEvent, draggingState: DraggingState): DraggingState {
val currentX = event.rawX
val currentY = event.rawY
val distanceX = firstX - currentX
val distanceY = firstY - currentY
return draggingState.nextDragState(currentX, currentY, distanceX, distanceY).also {
lastDistanceX = distanceX
lastDistanceY = distanceY
}
}
private fun DraggingState.nextDragState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): DraggingState {
return when (this) {
DraggingState.Ready -> {
when {
isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
else -> DraggingState.Ready
}
}
is DraggingState.Cancelling -> {
when {
isDraggingToLock(currentY, distanceX, distanceY) -> DraggingState.Locking(distanceY)
shouldCancelRecording(distanceX) -> DraggingState.Cancel
else -> DraggingState.Cancelling(distanceX)
}
}
is DraggingState.Locking -> {
when {
isDraggingToCancel(currentX, distanceX, distanceY) -> DraggingState.Cancelling(distanceX)
shouldLockRecording(distanceY) -> DraggingState.Lock
else -> DraggingState.Locking(distanceY)
}
}
else -> {
this
}
}
}
private fun isDraggingToLock(currentY: Float, distanceX: Float, distanceY: Float) = (currentY < firstY) &&
distanceY > distanceX && distanceY > lastDistanceY
private fun isDraggingToCancel(currentX: Float, distanceX: Float, distanceY: Float) = isDraggingHorizontal(currentX) &&
distanceX > distanceY && distanceX > lastDistanceX
private fun isDraggingHorizontal(currentX: Float) = (currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)
private fun shouldCancelRecording(distanceX: Float): Boolean {
return distanceX >= distanceToCancel
}
private fun shouldLockRecording(distanceY: Float): Boolean {
return distanceY >= distanceToLock
}
}

View File

@ -0,0 +1,227 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.composer.voice
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.hardware.vibrate
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import kotlin.math.floor
/**
* Encapsulates the voice message recording view and animations.
*/
class VoiceMessageRecorderView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
interface Callback {
fun onVoiceRecordingStarted()
fun onVoiceRecordingEnded()
fun onVoicePlaybackButtonClicked()
fun onVoiceRecordingCancelled()
fun onVoiceRecordingLocked()
fun onSendVoiceMessage()
fun onDeleteVoiceMessage()
fun onRecordingLimitReached()
fun onRecordingWaveformClicked()
}
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
@Suppress("UNNECESSARY_LATEINIT")
private lateinit var voiceMessageViews: VoiceMessageViews
lateinit var callback: Callback
private var recordingTicker: CountUpTimer? = null
private var lastKnownState: RecordingUiState? = null
private var dragState: DraggingState = DraggingState.Ignored
init {
inflate(this.context, R.layout.view_voice_message_recorder, this)
val dimensionConverter = DimensionConverter(this.context.resources)
voiceMessageViews = VoiceMessageViews(
this.context.resources,
ViewVoiceMessageRecorderBinding.bind(this),
dimensionConverter
)
initListeners()
}
private fun initListeners() {
voiceMessageViews.start(object : VoiceMessageViews.Actions {
override fun onRequestRecording() = callback.onVoiceRecordingStarted()
override fun onMicButtonReleased() {
when (dragState) {
DraggingState.Lock -> {
// do nothing,
// onSendVoiceMessage, onDeleteVoiceMessage or onRecordingLimitReached will be triggered instead
}
DraggingState.Cancel -> callback.onVoiceRecordingCancelled()
else -> callback.onVoiceRecordingEnded()
}
}
override fun onSendVoiceMessage() = callback.onSendVoiceMessage()
override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage()
override fun onWaveformClicked() = callback.onRecordingWaveformClicked()
override fun onVoicePlaybackButtonClicked() = callback.onVoicePlaybackButtonClicked()
override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) {
onDrag(dragState, newDragState = nextDragStateCreator(dragState))
}
})
}
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
// onVisibilityChanged is called by constructor on api 21 and 22.
if (!this::voiceMessageViews.isInitialized) return
val parentChanged = changedView == this
voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
}
fun render(recordingState: RecordingUiState) {
if (lastKnownState == recordingState) return
lastKnownState = recordingState
when (recordingState) {
RecordingUiState.None -> {
reset()
}
RecordingUiState.Started -> {
startRecordingTicker()
voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
voiceMessageViews.showRecordingViews()
dragState = DraggingState.Ready
}
RecordingUiState.Cancelled -> {
reset()
vibrate(context)
}
RecordingUiState.Locked -> {
voiceMessageViews.renderLocked()
postDelayed({
voiceMessageViews.showRecordingLockedViews(recordingState)
}, 500)
}
RecordingUiState.Playback -> {
stopRecordingTicker()
voiceMessageViews.showPlaybackViews()
}
}
}
private fun reset() {
stopRecordingTicker()
voiceMessageViews.initViews()
dragState = DraggingState.Ignored
}
private fun onDrag(currentDragState: DraggingState, newDragState: DraggingState) {
when (newDragState) {
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(newDragState.distanceX)
is DraggingState.Locking -> {
if (currentDragState is DraggingState.Cancelling) {
voiceMessageViews.showRecordingViews()
}
voiceMessageViews.renderLocking(newDragState.distanceY)
}
DraggingState.Cancel -> callback.onVoiceRecordingCancelled()
DraggingState.Lock -> callback.onVoiceRecordingLocked()
DraggingState.Ignored,
DraggingState.Ready -> {
// do nothing
}
}.exhaustive
dragState = newDragState
}
private fun startRecordingTicker() {
recordingTicker?.stop()
recordingTicker = CountUpTimer().apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
onRecordingTick(milliseconds)
}
}
resume()
}
onRecordingTick(0L)
}
private fun onRecordingTick(milliseconds: Long) {
val currentState = lastKnownState ?: return
voiceMessageViews.renderRecordingTimer(currentState, milliseconds / 1_000)
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
if (timeDiffToRecordingLimit <= 0) {
post {
callback.onRecordingLimitReached()
}
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
post {
val secondsRemaining = floor(timeDiffToRecordingLimit / 1000f).toInt()
voiceMessageViews.renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, secondsRemaining))
vibrate(context)
}
}
}
private fun stopRecordingTicker() {
recordingTicker?.stop()
recordingTicker = null
}
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
when (state) {
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
voiceMessageViews.renderRecordingWaveform(state.amplitudeList.toTypedArray())
}
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
voiceMessageViews.renderPlaying(state)
}
is VoiceMessagePlaybackTracker.Listener.State.Paused,
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
voiceMessageViews.renderIdle()
}
}
}
sealed interface RecordingUiState {
object None : RecordingUiState
object Started : RecordingUiState
object Cancelled : RecordingUiState
object Locked : RecordingUiState
object Playback : RecordingUiState
}
sealed interface DraggingState {
object Ready : DraggingState
object Ignored : DraggingState
data class Cancelling(val distanceX: Float) : DraggingState
data class Locking(val distanceY: Float) : DraggingState
object Cancel : DraggingState
object Lock : DraggingState
}
}

View File

@ -0,0 +1,349 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.composer.voice
import android.annotation.SuppressLint
import android.content.res.Resources
import android.text.format.DateUtils
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import im.vector.app.R
import im.vector.app.core.extensions.setAttributeBackground
import im.vector.app.core.extensions.setAttributeTintedBackground
import im.vector.app.core.extensions.setAttributeTintedImageResource
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
class VoiceMessageViews(
private val resources: Resources,
private val views: ViewVoiceMessageRecorderBinding,
private val dimensionConverter: DimensionConverter,
) {
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
private val rtlXMultiplier = resources.getInteger(R.integer.rtl_x_multiplier)
fun start(actions: Actions) {
views.voiceMessageSendButton.setOnClickListener {
views.voiceMessageSendButton.isVisible = false
actions.onSendVoiceMessage()
}
views.voiceMessageDeletePlayback.setOnClickListener {
views.voiceMessageSendButton.isVisible = false
actions.onDeleteVoiceMessage()
}
views.voicePlaybackWaveform.setOnClickListener {
actions.onWaveformClicked()
}
views.voicePlaybackControlButton.setOnClickListener {
actions.onVoicePlaybackButtonClicked()
}
observeMicButton(actions)
}
@SuppressLint("ClickableViewAccessibility")
private fun observeMicButton(actions: Actions) {
val draggableStateProcessor = DraggableStateProcessor(resources, dimensionConverter)
views.voiceMessageMicButton.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
draggableStateProcessor.initialize(event)
actions.onRequestRecording()
true
}
MotionEvent.ACTION_UP -> {
actions.onMicButtonReleased()
true
}
MotionEvent.ACTION_MOVE -> {
actions.onMicButtonDrag { currentState -> draggableStateProcessor.process(event, currentState) }
true
}
else -> false
}
}
}
fun renderStarted(distanceX: Float) {
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
}
fun renderLocked() {
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
}
fun renderLocking(distanceY: Float) {
views.voiceMessageLockImage.setAttributeTintedImageResource(R.drawable.ic_voice_message_locked, R.attr.colorPrimary)
val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
views.voiceMessageMicButton.translationY = translationAmount
views.voiceMessageLockArrow.translationY = translationAmount
views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
// Reset X translations
views.voiceMessageMicButton.translationX = 0F
views.voiceMessageSlideToCancel.translationX = 0F
}
fun renderCancelling(distanceX: Float) {
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
views.voiceMessageSlideToCancel.alpha = reducedAlpha
views.voiceMessageTimerIndicator.alpha = reducedAlpha
views.voiceMessageTimer.alpha = reducedAlpha
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockArrow.isVisible = false
views.voiceMessageSlideToCancelDivider.isVisible = true
// Reset Y translations
views.voiceMessageMicButton.translationY = 0F
views.voiceMessageLockArrow.translationY = 0F
}
fun showRecordingViews() {
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary)
views.voiceMessageMicButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
setMargins(0, 0, 0, 0)
}
views.voiceMessageMicButton.animate().scaleX(1.5f).scaleY(1.5f).setDuration(300).start()
views.voiceMessageLockBackground.isVisible = true
views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockImage.isVisible = true
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
views.voiceMessageLockImage.animate().setDuration(500).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
views.voiceMessageLockArrow.isVisible = true
views.voiceMessageLockArrow.alpha = 1f
views.voiceMessageSlideToCancel.isVisible = true
views.voiceMessageTimerIndicator.isVisible = true
views.voiceMessageTimer.isVisible = true
views.voiceMessageSlideToCancel.alpha = 1f
views.voiceMessageTimerIndicator.alpha = 1f
views.voiceMessageTimer.alpha = 1f
views.voiceMessageSendButton.isVisible = false
}
fun hideRecordingViews(recordingState: RecordingUiState) {
// We need to animate the lock image first
if (recordingState != RecordingUiState.Locked) {
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockImage.animate().translationY(0f).start()
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockBackground.animate().translationY(0f).start()
} else {
animateLockImageWithBackground()
}
views.voiceMessageSlideToCancelDivider.isVisible = false
views.voiceMessageLockArrow.isVisible = false
views.voiceMessageLockArrow.animate().translationY(0f).start()
views.voiceMessageSlideToCancel.isVisible = false
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
views.voiceMessagePlaybackLayout.isVisible = false
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
if (recordingState != RecordingUiState.Locked) {
views.voiceMessageMicButton
.animate()
.scaleX(1f)
.scaleY(1f)
.translationX(0f)
.translationY(0f)
.setDuration(150)
.withEndAction {
resetMicButtonUi()
}
.start()
} else {
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
views.voiceMessageMicButton.apply {
scaleX = 1f
scaleY = 1f
translationX = 0f
translationY = 0f
}
}
// Hide toasts if user cancelled recording before the timeout of the toast.
if (recordingState == RecordingUiState.Cancelled || recordingState == RecordingUiState.None) {
hideToast()
}
}
fun animateLockImageWithBackground() {
views.voiceMessageLockBackground.updateLayoutParams {
height = dimensionConverter.dpToPx(78)
}
views.voiceMessageLockBackground.apply {
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
updateLayoutParams {
height = dimensionConverter.dpToPx(180)
}
isVisible = false
scaleX = 1f
scaleY = 1f
animate().translationY(0f).start()
}
.start()
}
// Lock image animation
views.voiceMessageMicButton.isInvisible = true
views.voiceMessageLockImage.apply {
isVisible = true
animate()
.scaleX(0f)
.scaleY(0f)
.setDuration(400L)
.withEndAction {
isVisible = false
scaleX = 1f
scaleY = 1f
translationY = 0f
resetMicButtonUi()
}
.start()
}
}
fun resetMicButtonUi() {
views.voiceMessageMicButton.isVisible = true
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
views.voiceMessageMicButton.setAttributeBackground(android.R.attr.selectableItemBackgroundBorderless)
views.voiceMessageMicButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
if (rtlXMultiplier == -1) {
// RTL
setMargins(dimensionConverter.dpToPx(12), 0, 0, dimensionConverter.dpToPx(12))
} else {
setMargins(0, 0, dimensionConverter.dpToPx(12), dimensionConverter.dpToPx(12))
}
}
}
fun hideToast() {
views.voiceMessageToast.isVisible = false
}
fun showRecordingLockedViews(recordingState: RecordingUiState) {
hideRecordingViews(recordingState)
views.voiceMessagePlaybackLayout.isVisible = true
views.voiceMessagePlaybackTimerIndicator.isVisible = true
views.voicePlaybackControlButton.isVisible = false
views.voiceMessageSendButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
renderToast(resources.getString(R.string.voice_message_tap_to_stop_toast))
}
fun showPlaybackViews() {
views.voiceMessagePlaybackTimerIndicator.isVisible = false
views.voicePlaybackControlButton.isVisible = true
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
fun initViews() {
hideRecordingViews(RecordingUiState.None)
views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
}
fun renderPlaying(state: VoiceMessagePlaybackTracker.Listener.State.Playing) {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_pause_voice_message)
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
views.voicePlaybackTime.text = formattedTimerText
}
fun renderIdle() {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_play_voice_message)
}
fun renderToast(message: String) {
views.voiceMessageToast.removeCallbacks(hideToastRunnable)
views.voiceMessageToast.text = message
views.voiceMessageToast.isVisible = true
views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
}
private val hideToastRunnable = Runnable {
views.voiceMessageToast.isVisible = false
}
fun renderRecordingTimer(recordingState: RecordingUiState, recordingTimeMillis: Long) {
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
if (recordingState == RecordingUiState.Locked) {
views.voicePlaybackTime.apply {
post {
text = formattedTimerText
}
}
} else {
views.voiceMessageTimer.post {
views.voiceMessageTimer.text = formattedTimerText
}
}
}
fun renderRecordingWaveform(amplitudeList: Array<Int>) {
views.voicePlaybackWaveform.post {
views.voicePlaybackWaveform.apply {
amplitudeList.iterator().forEach {
update(it)
}
}
}
}
fun renderVisibilityChanged(parentChanged: Boolean, visibility: Int) {
if (parentChanged && visibility == ConstraintLayout.VISIBLE) {
views.voiceMessageMicButton.contentDescription = resources.getString(R.string.a11y_start_voice_message)
} else {
views.voiceMessageMicButton.contentDescription = ""
}
}
interface Actions {
fun onRequestRecording()
fun onMicButtonReleased()
fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState)
fun onSendVoiceMessage()
fun onDeleteVoiceMessage()
fun onWaveformClicked()
fun onVoicePlaybackButtonClicked()
}
}

View File

@ -21,7 +21,6 @@ import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.args
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
@ -81,12 +80,9 @@ class DisplayReadReceiptsBottomSheet :
companion object {
fun newInstance(readReceipts: List<ReadReceiptData>): DisplayReadReceiptsBottomSheet {
val args = Bundle()
val parcelableArgs = DisplayReadReceiptArgs(
readReceipts
)
args.putParcelable(Mavericks.KEY_ARG, parcelableArgs)
return DisplayReadReceiptsBottomSheet().apply { arguments = args }
return DisplayReadReceiptsBottomSheet().apply {
setArguments(DisplayReadReceiptArgs(readReceipts = readReceipts))
}
}
}
}

View File

@ -19,7 +19,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
@ -68,14 +67,13 @@ class ViewEditHistoryBottomSheet :
companion object {
fun newInstance(roomId: String, informationData: MessageInformationData): ViewEditHistoryBottomSheet {
val args = Bundle()
val parcelableArgs = TimelineEventFragmentArgs(
informationData.eventId,
roomId,
informationData
)
args.putParcelable(Mavericks.KEY_ARG, parcelableArgs)
return ViewEditHistoryBottomSheet().apply { arguments = args }
return ViewEditHistoryBottomSheet().apply {
setArguments(TimelineEventFragmentArgs(
eventId = informationData.eventId,
roomId = roomId,
informationData = informationData
))
}
}
}
}

View File

@ -20,7 +20,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
@ -82,14 +81,13 @@ class ViewReactionsBottomSheet :
companion object {
fun newInstance(roomId: String, informationData: MessageInformationData): ViewReactionsBottomSheet {
val args = Bundle()
val parcelableArgs = TimelineEventFragmentArgs(
informationData.eventId,
roomId,
informationData
)
args.putParcelable(Mavericks.KEY_ARG, parcelableArgs)
return ViewReactionsBottomSheet().apply { arguments = args }
return ViewReactionsBottomSheet().apply {
setArguments(TimelineEventFragmentArgs(
eventId = informationData.eventId,
roomId = roomId,
informationData = informationData
))
}
}
}
}

View File

@ -24,7 +24,6 @@ import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import com.airbnb.mvrx.Incomplete
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -66,15 +65,15 @@ class MatrixToBottomSheet :
override fun invalidate() = withState(viewModel) { state ->
super.invalidate()
when (state.linkType) {
is PermalinkData.RoomLink -> {
is PermalinkData.RoomLink -> {
views.matrixToCardContentLoading.isVisible = state.roomPeekResult is Incomplete
showFragment(MatrixToRoomSpaceFragment::class, Bundle())
}
is PermalinkData.UserLink -> {
is PermalinkData.UserLink -> {
views.matrixToCardContentLoading.isVisible = state.matrixItem is Incomplete
showFragment(MatrixToUserFragment::class, Bundle())
}
is PermalinkData.GroupLink -> {
is PermalinkData.GroupLink -> {
}
is PermalinkData.FallbackLink -> {
}
@ -98,16 +97,16 @@ class MatrixToBottomSheet :
viewModel.observeViewEvents {
when (it) {
is MatrixToViewEvents.NavigateToRoom -> {
is MatrixToViewEvents.NavigateToRoom -> {
interactionListener?.mxToBottomSheetNavigateToRoom(it.roomId)
dismiss()
}
MatrixToViewEvents.Dismiss -> dismiss()
MatrixToViewEvents.Dismiss -> dismiss()
is MatrixToViewEvents.NavigateToSpace -> {
interactionListener?.mxToBottomSheetSwitchToSpace(it.spaceId)
dismiss()
}
is MatrixToViewEvents.ShowModalError -> {
is MatrixToViewEvents.ShowModalError -> {
MaterialAlertDialogBuilder(requireContext())
.setMessage(it.error)
.setPositiveButton(getString(R.string.ok), null)
@ -120,11 +119,7 @@ class MatrixToBottomSheet :
companion object {
fun withLink(matrixToLink: String): MatrixToBottomSheet {
return MatrixToBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(Mavericks.KEY_ARG, MatrixToArgs(
matrixToLink = matrixToLink
))
}
setArguments(MatrixToArgs(matrixToLink = matrixToLink))
}
}
}

View File

@ -29,8 +29,6 @@ import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.util.toMatrixItem
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
import javax.inject.Singleton
@ -40,52 +38,45 @@ import javax.inject.Singleton
* Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning.
*/
@Singleton
class NotificationDrawerManager @Inject constructor(private val context: Context,
private val notificationDisplayer: NotificationDisplayer,
private val vectorPreferences: VectorPreferences,
private val activeSessionDataSource: ActiveSessionDataSource,
private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer) {
class NotificationDrawerManager @Inject constructor(
private val context: Context,
private val notificationDisplayer: NotificationDisplayer,
private val vectorPreferences: VectorPreferences,
private val activeSessionDataSource: ActiveSessionDataSource,
private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer,
private val notificationEventPersistence: NotificationEventPersistence
) {
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
private var backgroundHandler: Handler
// TODO Multi-session: this will have to be improved
private val currentSession: Session?
get() = activeSessionDataSource.currentValue?.orNull()
/**
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events
*/
private val notificationState by lazy { createInitialNotificationState() }
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
private var currentRoomId: String? = null
private val firstThrottler = FirstThrottler(200)
private var useCompleteNotificationFormat = vectorPreferences.useCompleteNotificationFormat()
init {
handlerThread.start()
backgroundHandler = Handler(handlerThread.looper)
}
/**
* The notifiable events to render
* this is our source of truth for notifications, any changes to this list will be rendered as notifications
* when events are removed the previously rendered notifications will be cancelled
* when adding or updating, the notifications will be notified
*
* Events are unique by their properties, we should be careful not to insert multiple events with the same event-id
*/
private val queuedEvents = loadEventInfo()
/**
* The last known rendered notifiable events
* we keep track of them in order to know which events have been removed from the eventList
* allowing us to cancel any notifications previous displayed by now removed events
*/
private var renderedEvents = emptyList<ProcessedEvent<NotifiableEvent>>()
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
private var currentRoomId: String? = null
// TODO Multi-session: this will have to be improved
private val currentSession: Session?
get() = activeSessionDataSource.currentValue?.orNull()
private var useCompleteNotificationFormat = vectorPreferences.useCompleteNotificationFormat()
/**
* An in memory FIFO cache of the seen events.
* Acts as a notification debouncer to stop already dismissed push notifications from
* displaying again when the /sync response is delayed.
*/
private val seenEventIds = CircularCache.create<String>(cacheSize = 25)
private fun createInitialNotificationState(): NotificationState {
val queuedEvents = notificationEventPersistence.loadEvents(currentSession, factory = { rawEvents ->
NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25))
})
val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList()
return NotificationState(queuedEvents, renderedEvents)
}
/**
Should be called as soon as a new event is ready to be displayed.
@ -106,7 +97,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
}
add(notifiableEvent, seenEventIds)
add(notifiableEvent)
}
/**
@ -142,14 +133,12 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
}
fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) {
synchronized(queuedEvents) {
action(this, queuedEvents)
notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
action(queuedEvents)
}
refreshNotificationDrawer()
}
private var firstThrottler = FirstThrottler(200)
private fun refreshNotificationDrawer() {
// Implement last throttler
val canHandle = firstThrottler.canHandle()
@ -171,85 +160,49 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
@WorkerThread
private fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()")
val eventsToRender = synchronized(queuedEvents) {
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, renderedEvents).also {
queuedEvents.clearAndAdd(it.onlyKeptEvents())
}
}
if (renderedEvents == eventsToRender) {
if (notificationState.hasAlreadyRendered(eventsToRender)) {
Timber.d("Skipping notification update due to event list not changing")
} else {
renderedEvents = eventsToRender
notificationState.clearAndAddRenderedEvents(eventsToRender)
val session = currentSession ?: return
val user = session.getUser(session.myUserId)
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId
val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail(
contentUrl = user?.avatarUrl,
width = avatarSize,
height = avatarSize,
method = ContentUrlResolver.ThumbnailMethod.SCALE
)
notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender)
renderEvents(session, eventsToRender)
persistEvents(session)
}
}
private fun persistEvents(session: Session) {
notificationState.queuedEvents { queuedEvents ->
notificationEventPersistence.persistEvents(queuedEvents, session)
}
}
private fun renderEvents(session: Session, eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
val user = session.getUser(session.myUserId)
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId
val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail(
contentUrl = user?.avatarUrl,
width = avatarSize,
height = avatarSize,
method = ContentUrlResolver.ThumbnailMethod.SCALE
)
notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender)
}
fun shouldIgnoreMessageEventInRoom(roomId: String?): Boolean {
return currentRoomId != null && roomId == currentRoomId
}
fun persistInfo() {
synchronized(queuedEvents) {
if (queuedEvents.isEmpty()) {
deleteCachedRoomNotifications()
return
}
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (!file.exists()) file.createNewFile()
FileOutputStream(file).use {
currentSession?.securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it)
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info")
}
}
}
private fun loadEventInfo(): NotificationEventQueue {
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.inputStream().use {
val events: ArrayList<NotifiableEvent>? = currentSession?.loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
if (events != null) {
return NotificationEventQueue(events.toMutableList())
}
}
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to load cached notification info")
}
return NotificationEventQueue()
}
private fun deleteCachedRoomNotifications() {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.delete()
}
}
companion object {
const val SUMMARY_NOTIFICATION_ID = 0
const val ROOM_MESSAGES_NOTIFICATION_ID = 1
const val ROOM_EVENT_NOTIFICATION_ID = 2
const val ROOM_INVITATION_NOTIFICATION_ID = 3
// TODO Mutliaccount
private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache"
private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr"
}
}

View File

@ -0,0 +1,71 @@
/*
* 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.notifications
import android.content.Context
import org.matrix.android.sdk.api.session.Session
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
// TODO Multi-account
private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache"
private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr"
class NotificationEventPersistence @Inject constructor(private val context: Context) {
fun loadEvents(currentSession: Session?, factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue {
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.inputStream().use {
val events: ArrayList<NotifiableEvent>? = currentSession?.loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
if (events != null) {
return factory(events)
}
}
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to load cached notification info")
}
return factory(emptyList())
}
fun persistEvents(queuedEvents: NotificationEventQueue, currentSession: Session) {
if (queuedEvents.isEmpty()) {
deleteCachedRoomNotifications(context)
return
}
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (!file.exists()) file.createNewFile()
FileOutputStream(file).use {
currentSession.securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it)
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info")
}
}
private fun deleteCachedRoomNotifications(context: Context) {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.delete()
}
}
}

View File

@ -18,8 +18,15 @@ package im.vector.app.features.notifications
import timber.log.Timber
class NotificationEventQueue(
private val queue: MutableList<NotifiableEvent> = mutableListOf()
data class NotificationEventQueue(
private val queue: MutableList<NotifiableEvent>,
/**
* An in memory FIFO cache of the seen events.
* Acts as a notification debouncer to stop already dismissed push notifications from
* displaying again when the /sync response is delayed.
*/
private val seenEventIds: CircularCache<String>
) {
fun markRedacted(eventIds: List<String>) {
@ -57,7 +64,7 @@ class NotificationEventQueue(
queue.clear()
}
fun add(notifiableEvent: NotifiableEvent, seenEventIds: CircularCache<String>) {
fun add(notifiableEvent: NotifiableEvent) {
val existing = findExistingById(notifiableEvent)
val edited = findEdited(notifiableEvent)
when {

View File

@ -0,0 +1,58 @@
/*
* 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.notifications
class NotificationState(
/**
* The notifiable events queued for rendering or currently rendered
*
* this is our source of truth for notifications, any changes to this list will be rendered as notifications
* when events are removed the previously rendered notifications will be cancelled
* when adding or updating, the notifications will be notified
*
* Events are unique by their properties, we should be careful not to insert multiple events with the same event-id
*/
private val queuedEvents: NotificationEventQueue,
/**
* The last known rendered notifiable events
* we keep track of them in order to know which events have been removed from the eventList
* allowing us to cancel any notifications previous displayed by now removed events
*/
private val renderedEvents: MutableList<ProcessedEvent<NotifiableEvent>>,
) {
fun <T> updateQueuedEvents(drawerManager: NotificationDrawerManager,
action: NotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T): T {
return synchronized(queuedEvents) {
action(drawerManager, queuedEvents, renderedEvents)
}
}
fun clearAndAddRenderedEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
renderedEvents.clear()
renderedEvents.addAll(eventsToRender)
}
fun hasAlreadyRendered(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) = renderedEvents == eventsToRender
fun queuedEvents(block: (NotificationEventQueue) -> Unit) {
synchronized(queuedEvents) {
block(queuedEvents)
}
}
}

View File

@ -24,7 +24,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
@ -115,9 +114,9 @@ class DeviceListBottomSheet :
companion object {
fun newInstance(userId: String, allowDeviceAction: Boolean = true): DeviceListBottomSheet {
val args = Bundle()
args.putParcelable(Mavericks.KEY_ARG, Args(userId, allowDeviceAction))
return DeviceListBottomSheet().apply { arguments = args }
return DeviceListBottomSheet().apply {
setArguments(Args(userId, allowDeviceAction))
}
}
}
}

View File

@ -19,11 +19,13 @@ package im.vector.app.features.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.extensions.orFalse
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vector_settings")
@ -44,4 +46,17 @@ class VectorDataStore @Inject constructor(
settings[pushCounter] = currentCounterValue + 1
}
}
// For debug only
private val forceDialPadDisplay = booleanPreferencesKey("force_dial_pad_display")
val forceDialPadDisplayFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
preferences[forceDialPadDisplay].orFalse()
}
suspend fun setForceDialPadDisplay(force: Boolean) {
context.dataStore.edit { settings ->
settings[forceDialPadDisplay] = force
}
}
}

View File

@ -21,7 +21,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
@ -76,10 +75,9 @@ class DeviceVerificationInfoBottomSheet :
companion object {
fun newInstance(userId: String, deviceId: String): DeviceVerificationInfoBottomSheet {
val args = Bundle()
val parcelableArgs = DeviceVerificationInfoArgs(userId, deviceId)
args.putParcelable(Mavericks.KEY_ARG, parcelableArgs)
return DeviceVerificationInfoBottomSheet().apply { arguments = args }
return DeviceVerificationInfoBottomSheet().apply {
setArguments(DeviceVerificationInfoArgs(userId, deviceId))
}
}
}

View File

@ -26,7 +26,6 @@ import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.toMvRxBundle
import im.vector.app.core.platform.SimpleFragmentActivity
import im.vector.app.features.spaces.create.ChoosePrivateSpaceTypeFragment
import im.vector.app.features.spaces.create.ChooseSpaceTypeFragment
@ -121,7 +120,7 @@ class SpaceCreationActivity : SimpleFragmentActivity() {
}
private fun navigateToFragment(fragmentClass: Class<out Fragment>) {
val frag = supportFragmentManager.findFragmentByTag(fragmentClass.name) ?: createFragment(fragmentClass, Bundle().toMvRxBundle())
val frag = supportFragmentManager.findFragmentByTag(fragmentClass.name) ?: createFragment(fragmentClass)
supportFragmentManager.beginTransaction()
.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
.replace(R.id.container,

View File

@ -23,12 +23,10 @@ import android.text.style.BulletSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.withArgs
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.databinding.BottomSheetRoomWidgetPermissionBinding
import im.vector.app.features.home.AvatarRenderer
@ -111,8 +109,10 @@ class RoomWidgetPermissionBottomSheet :
companion object {
fun newInstance(widgetArgs: WidgetArgs) = RoomWidgetPermissionBottomSheet().withArgs {
putParcelable(Mavericks.KEY_ARG, widgetArgs)
fun newInstance(widgetArgs: WidgetArgs): RoomWidgetPermissionBottomSheet {
return RoomWidgetPermissionBottomSheet().apply {
setArguments(widgetArgs)
}
}
}
}

View File

@ -35,6 +35,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="3"
android:singleLine="false"
tools:hint="@string/passphrase_enter_passphrase" />

View File

@ -33,6 +33,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="3"
android:singleLine="false"
tools:hint="@string/passphrase_enter_passphrase" />

View File

@ -35,6 +35,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="3"
android:singleLine="false"
tools:hint="@string/keys_backup_restore_key_enter_hint" />

View File

@ -56,6 +56,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/passphrase_enter_passphrase"
android:inputType="textPassword"
android:maxLines="3"
android:singleLine="false" />

View File

@ -58,6 +58,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/passphrase_create_passphrase"
android:inputType="textPassword"
android:maxLines="3" />
</com.google.android.material.textfield.TextInputLayout>
@ -86,6 +87,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/passphrase_confirm_passphrase"
android:inputType="textPassword"
android:maxLines="3" />
</com.google.android.material.textfield.TextInputLayout>

View File

@ -199,7 +199,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<im.vector.app.features.home.room.detail.composer.TextComposerView
<im.vector.app.features.home.room.detail.composer.MessageComposerView
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -212,7 +212,7 @@
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
<im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
<im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
android:id="@+id/voiceMessageRecorderView"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -70,9 +70,9 @@
android:layout_height="wrap_content"
android:hint="@string/passphrase_enter_passphrase"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="3"
android:singleLine="false"
tools:inputType="textPassword" />
android:singleLine="false" />
</com.google.android.material.textfield.TextInputLayout>

View File

@ -95,6 +95,7 @@
<!-- Slide to cancel text should go under this view -->
<View
android:id="@+id/voiceMessageSlideToCancelDivider"
android:layout_width="48dp"
android:layout_height="0dp"
android:background="?android:colorBackground"
@ -135,11 +136,10 @@
android:id="@+id/voiceMessagePlaybackLayout"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="4dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/voiceMessageMicButton"
app:layout_constraintEnd_toStartOf="@id/voiceMessageSendButton"
app:layout_constraintStart_toStartOf="parent"
tools:layout_marginBottom="120dp"
tools:visibility="visible">

View File

@ -126,7 +126,7 @@ class NotificationEventQueueTest {
fun `given no events when adding then adds event`() {
val queue = givenQueue(listOf())
queue.add(aSimpleNotifiableEvent(), seenEventIds = seenIdsCache)
queue.add(aSimpleNotifiableEvent())
queue.rawEvents() shouldBeEqualTo listOf(aSimpleNotifiableEvent())
}
@ -137,7 +137,7 @@ class NotificationEventQueueTest {
val notifiableEvent = aSimpleNotifiableEvent()
seenIdsCache.put(notifiableEvent.eventId)
queue.add(notifiableEvent, seenEventIds = seenIdsCache)
queue.add(notifiableEvent)
queue.rawEvents() shouldBeEqualTo emptyList()
}
@ -148,7 +148,7 @@ class NotificationEventQueueTest {
val updatedEvent = replaceableEvent.copy(title = "updated title")
val queue = givenQueue(listOf(replaceableEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.add(updatedEvent)
queue.rawEvents() shouldBeEqualTo listOf(updatedEvent)
}
@ -159,7 +159,7 @@ class NotificationEventQueueTest {
val updatedEvent = nonReplaceableEvent.copy(title = "updated title")
val queue = givenQueue(listOf(nonReplaceableEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.add(updatedEvent)
queue.rawEvents() shouldBeEqualTo listOf(nonReplaceableEvent)
}
@ -170,7 +170,7 @@ class NotificationEventQueueTest {
val updatedEvent = editedEvent.copy(eventId = "1", editedEventId = "id-to-edit", title = "updated title")
val queue = givenQueue(listOf(editedEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.add(updatedEvent)
queue.rawEvents() shouldBeEqualTo listOf(updatedEvent)
}
@ -181,7 +181,7 @@ class NotificationEventQueueTest {
val updatedEvent = editedEvent.copy(eventId = "1", editedEventId = "id-to-edit", title = "updated title")
val queue = givenQueue(listOf(editedEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.add(updatedEvent)
queue.rawEvents() shouldBeEqualTo listOf(updatedEvent)
}
@ -212,5 +212,5 @@ class NotificationEventQueueTest {
queue.rawEvents() shouldBeEqualTo listOf(anInviteNotifiableEvent(roomId = roomId))
}
private fun givenQueue(events: List<NotifiableEvent>) = NotificationEventQueue(events.toMutableList())
private fun givenQueue(events: List<NotifiableEvent>) = NotificationEventQueue(events.toMutableList(), seenEventIds = seenIdsCache)
}