Merge branch 'develop' of github.com:vector-im/element-android into feature/dla/fix_reply_and_quote_newlines
This commit is contained in:
commit
460596d6b3
1
changelog.d/4515.misc
Normal file
1
changelog.d/4515.misc
Normal 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
1
changelog.d/4520.bugfix
Normal file
@ -0,0 +1 @@
|
||||
Fix a crash when displaying the bootstrap bottom sheet
|
113
library/ui-styles/src/debug/res/layout/debug_social_login.xml
Normal file
113
library/ui-styles/src/debug/res/layout/debug_social_login.xml
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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<*, *>
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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"
|
||||
|
@ -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>
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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 {}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
/* ==========================================================================================
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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 ->
|
||||
|
@ -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()
|
||||
}
|
@ -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) {
|
@ -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()
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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()
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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" />
|
||||
|
@ -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" />
|
||||
|
@ -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" />
|
||||
|
@ -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" />
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user