element-android/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt

664 lines
25 KiB
Kotlin

/*
* 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.core.platform
import android.app.Activity
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.WindowInsetsController
import android.view.WindowManager
import android.widget.TextView
import androidx.annotation.CallSuper
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.MultiWindowModeChangedInfo
import androidx.core.content.ContextCompat
import androidx.core.util.Consumer
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.preference.PreferenceManager
import androidx.viewbinding.ViewBinding
import com.airbnb.mvrx.MavericksView
import com.bumptech.glide.util.Util
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.EntryPointAccessors
import im.vector.app.R
import im.vector.app.core.debug.DebugReceiver
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.ActivityEntryPoint
import im.vector.app.core.dialogs.DialogLocker
import im.vector.app.core.dialogs.UnrecognizedCertificateDialog
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.error.fatalError
import im.vector.app.core.extensions.observeEvent
import im.vector.app.core.extensions.observeNotNull
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.resources.BuildMeta
import im.vector.app.core.utils.AndroidSystemSettingsProvider
import im.vector.app.core.utils.ToolbarConfig
import im.vector.app.core.utils.toast
import im.vector.app.features.MainActivity
import im.vector.app.features.MainActivityArgs
import im.vector.app.features.VectorFeatures
import im.vector.app.features.analytics.AnalyticsTracker
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.configuration.VectorConfiguration
import im.vector.app.features.consent.ConsentNotGivenHelper
import im.vector.app.features.mdm.MdmService
import im.vector.app.features.navigation.Navigator
import im.vector.app.features.pin.PinLocker
import im.vector.app.features.pin.PinMode
import im.vector.app.features.pin.UnlockedActivity
import im.vector.app.features.rageshake.BugReportActivity
import im.vector.app.features.rageshake.BugReporter
import im.vector.app.features.rageshake.RageShake
import im.vector.app.features.session.SessionListener
import im.vector.app.features.settings.FontScalePreferences
import im.vector.app.features.settings.FontScalePreferencesImpl
import im.vector.app.features.settings.VectorLocaleProvider
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.themes.ActivityOtherThemes
import im.vector.app.features.themes.ThemeUtils
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.GlobalError
import org.matrix.android.sdk.api.failure.InitialSyncRequestReason
import reactivecircus.flowbinding.android.view.clicks
import timber.log.Timber
import javax.inject.Inject
abstract class VectorBaseActivity<VB : ViewBinding> : AppCompatActivity(), MavericksView {
/* ==========================================================================================
* Analytics
* ========================================================================================== */
protected var analyticsScreenName: MobileScreen.ScreenName? = null
@Inject lateinit var analyticsTracker: AnalyticsTracker
/* ==========================================================================================
* View
* ========================================================================================== */
protected lateinit var views: VB
/* ==========================================================================================
* View model
* ========================================================================================== */
private lateinit var viewModelFactory: ViewModelProvider.Factory
protected val viewModelProvider
get() = ViewModelProvider(this, viewModelFactory)
fun <T : VectorViewEvents> VectorViewModel<*, *, T>.observeViewEvents(
observer: (T) -> Unit,
) {
val tag = this@VectorBaseActivity::class.simpleName.toString()
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewEvents
.stream(tag)
.collect {
hideWaitingView()
observer(it)
}
}
}
}
var toolbar: ToolbarConfig? = null
/* ==========================================================================================
* Views
* ========================================================================================== */
protected fun View.debouncedClicks(onClicked: () -> Unit) {
clicks()
.onEach { onClicked() }
.launchIn(lifecycleScope)
}
/* ==========================================================================================
* DATA
* ========================================================================================== */
private lateinit var configurationViewModel: ConfigurationViewModel
@Inject lateinit var sessionListener: SessionListener
@Inject lateinit var bugReporter: BugReporter
@Inject lateinit var pinLocker: PinLocker
@Inject lateinit var rageShake: RageShake
@Inject lateinit var buildMeta: BuildMeta
@Inject lateinit var fontScalePreferences: FontScalePreferences
@Inject lateinit var vectorLocale: VectorLocaleProvider
@Inject lateinit var vectorFeatures: VectorFeatures
@Inject lateinit var navigator: Navigator
@Inject lateinit var activeSessionHolder: ActiveSessionHolder
@Inject lateinit var vectorPreferences: VectorPreferences
@Inject lateinit var errorFormatter: ErrorFormatter
@Inject lateinit var mdmService: MdmService
// For debug only
@Inject lateinit var debugReceiver: DebugReceiver
// Filter for multiple invalid token error
private var mainActivityStarted = false
private var savedInstanceState: Bundle? = null
private val restorables = ArrayList<Restorable>()
override fun attachBaseContext(base: Context) {
val preferences = PreferenceManager.getDefaultSharedPreferences(base)
val fontScalePreferences = FontScalePreferencesImpl(preferences, AndroidSystemSettingsProvider(base))
val vectorLocaleProvider = VectorLocaleProvider(preferences)
val vectorConfiguration = VectorConfiguration(this, fontScalePreferences, vectorLocaleProvider)
super.attachBaseContext(vectorConfiguration.getLocalisedContext(base))
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
restorables.forEach { it.onSaveInstanceState(outState) }
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
restorables.forEach { it.onRestoreInstanceState(savedInstanceState) }
super.onRestoreInstanceState(savedInstanceState)
}
@MainThread
protected fun <T : Restorable> T.register(): T {
Util.assertMainThread()
restorables.add(this)
return this
}
@CallSuper
override fun onCreate(savedInstanceState: Bundle?) {
Timber.i("onCreate Activity ${javaClass.simpleName}")
val activityEntryPoint = EntryPointAccessors.fromActivity(this, ActivityEntryPoint::class.java)
ThemeUtils.setActivityTheme(this, getOtherThemes())
viewModelFactory = activityEntryPoint.viewModelFactory()
super.onCreate(savedInstanceState)
addOnMultiWindowModeChangedListener(onMultiWindowModeChangedListener)
setupMenu()
configurationViewModel = viewModelProvider.get(ConfigurationViewModel::class.java)
configurationViewModel.activityRestarter.observe(this) {
if (!it.hasBeenHandled) {
// Recreate the Activity because configuration has changed
restart()
}
}
pinLocker.getLiveState().observeNotNull(this) {
if (this@VectorBaseActivity !is UnlockedActivity && it == PinLocker.State.LOCKED) {
navigator.openPinCode(this, pinStartForActivityResult, PinMode.AUTH)
}
}
sessionListener.globalErrorLiveData.observeEvent(this) {
handleGlobalError(it)
}
// Set flag FLAG_SECURE
if (vectorPreferences.useFlagSecure()) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
doBeforeSetContentView()
// Hack for font size
applyFontSize()
views = getBinding()
setContentView(views.root)
this.savedInstanceState = savedInstanceState
initUiAndData()
if (vectorPreferences.isNewAppLayoutEnabled()) {
tryOrNull { // Add to XML theme when feature flag is removed
val toolbarBackground = MaterialColors.getColor(views.root, R.attr.vctr_toolbar_background)
window.statusBarColor = toolbarBackground
window.navigationBarColor = toolbarBackground
}
}
val titleRes = getTitleRes()
if (titleRes != -1) {
supportActionBar?.let {
it.setTitle(titleRes)
} ?: run {
setTitle(titleRes)
}
}
}
private fun setupMenu() {
// Always add a MenuProvider to handle the back action from the Toolbar
val vectorMenuProvider = this as? VectorMenuProvider
addMenuProvider(
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
vectorMenuProvider?.let {
menuInflater.inflate(it.getMenuRes(), menu)
it.handlePostCreateMenu(menu)
}
}
override fun onPrepareMenu(menu: Menu) {
vectorMenuProvider?.handlePrepareMenu(menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return vectorMenuProvider?.handleMenuItemSelected(menuItem).orFalse() ||
handleMenuItemHome(menuItem)
}
},
this,
Lifecycle.State.RESUMED
)
}
/**
* This method has to be called for the font size setting be supported correctly.
*/
private fun applyFontSize() {
resources.configuration.fontScale = fontScalePreferences.getResolvedFontScaleValue().scale
@Suppress("DEPRECATION")
resources.updateConfiguration(resources.configuration, resources.displayMetrics)
}
private fun handleGlobalError(globalError: GlobalError) {
when (globalError) {
is GlobalError.InvalidToken -> handleInvalidToken(globalError)
is GlobalError.ConsentNotGivenError -> displayConsentNotGivenDialog(globalError)
is GlobalError.CertificateError -> handleCertificateError(globalError)
GlobalError.ExpiredAccount -> Unit // TODO Handle account expiration
is GlobalError.InitialSyncRequest -> handleInitialSyncRequest(globalError)
}
}
private fun displayConsentNotGivenDialog(globalError: GlobalError.ConsentNotGivenError) {
consentNotGivenHelper.displayDialog(globalError.consentUri, activeSessionHolder.getActiveSession().sessionParams.homeServerHost ?: "")
}
private fun handleInitialSyncRequest(initialSyncRequest: GlobalError.InitialSyncRequest) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.initial_sync_request_title)
.setMessage(
getString(
R.string.initial_sync_request_content, getString(
when (initialSyncRequest.reason) {
InitialSyncRequestReason.IGNORED_USERS_LIST_CHANGE -> R.string.initial_sync_request_reason_unignored_users
}
)
)
)
.setPositiveButton(R.string.ok) { _, _ ->
MainActivity.restartApp(this, MainActivityArgs(clearCache = true))
}
.setNegativeButton(R.string.later, null)
.show()
}
private fun handleCertificateError(certificateError: GlobalError.CertificateError) {
singletonEntryPoint()
.unrecognizedCertificateDialog()
.show(this,
certificateError.fingerprint,
object : UnrecognizedCertificateDialog.Callback {
override fun onAccept() {
// TODO Support certificate error once logged
}
override fun onIgnore() {
// TODO Support certificate error once logged
}
override fun onReject() {
// TODO Support certificate error once logged
}
}
)
}
protected open fun handleInvalidToken(globalError: GlobalError.InvalidToken) {
Timber.w("Invalid token event received")
if (mainActivityStarted) {
return
}
mainActivityStarted = true
MainActivity.restartApp(
this,
MainActivityArgs(
clearCredentials = !globalError.softLogout,
isUserLoggedOut = true,
isSoftLogout = globalError.softLogout
)
)
}
override fun onDestroy() {
removeOnMultiWindowModeChangedListener(onMultiWindowModeChangedListener)
super.onDestroy()
Timber.i("onDestroy Activity ${javaClass.simpleName}")
}
private val pinStartForActivityResult = registerStartForActivityResult { activityResult ->
when (activityResult.resultCode) {
Activity.RESULT_OK -> {
Timber.v("Pin ok, unlock app")
pinLocker.unlock()
// Cancel any new started PinActivity, after a screen rotation for instance
// FIXME I cannot use this anymore :/
// finishActivity(PinActivity.PIN_REQUEST_CODE)
}
else -> {
if (pinLocker.getLiveState().value != PinLocker.State.UNLOCKED) {
// Remove the task, to be sure that PIN code will be requested when resumed
finishAndRemoveTask()
}
}
}
}
override fun onResume() {
super.onResume()
Timber.i("onResume Activity ${javaClass.simpleName}")
analyticsScreenName?.let {
analyticsTracker.screen(MobileScreen(screenName = it))
}
configurationViewModel.onActivityResumed()
if (this !is BugReportActivity && vectorPreferences.useRageshake()) {
rageShake.start()
}
debugReceiver.register(this)
mdmService.registerListener(this) {
// Just log that a change occurred.
Timber.w("MDM data has been updated")
}
}
private val postResumeScheduledActions = mutableListOf<() -> Unit>()
/**
* Schedule action to be done in the next call of onPostResume().
* It fixes bug observed on Android 6 (API 23).
*/
protected fun doOnPostResume(action: () -> Unit) {
synchronized(postResumeScheduledActions) {
postResumeScheduledActions.add(action)
}
}
override fun onPostResume() {
super.onPostResume()
synchronized(postResumeScheduledActions) {
postResumeScheduledActions.forEach {
tryOrNull { it.invoke() }
}
postResumeScheduledActions.clear()
}
}
override fun onPause() {
super.onPause()
Timber.i("onPause Activity ${javaClass.simpleName}")
rageShake.stop()
debugReceiver.unregister(this)
mdmService.unregisterListener(this)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus && displayInFullscreen()) {
setFullScreen()
}
}
private val onMultiWindowModeChangedListener = Consumer<MultiWindowModeChangedInfo> {
Timber.w("onMultiWindowModeChanged. isInMultiWindowMode: ${it.isInMultiWindowMode}")
bugReporter.inMultiWindowMode = it.isInMultiWindowMode
}
/* ==========================================================================================
* PRIVATE METHODS
* ========================================================================================== */
/**
* Force to render the activity in fullscreen.
*/
private fun setFullScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// New API instead of SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN and SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
window.setDecorFitsSystemWindows(false)
// New API instead of SYSTEM_UI_FLAG_IMMERSIVE
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// New API instead of FLAG_TRANSLUCENT_STATUS
window.statusBarColor = ContextCompat.getColor(this, im.vector.lib.attachmentviewer.R.color.half_transparent_status_bar)
// New API instead of FLAG_TRANSLUCENT_NAVIGATION
window.navigationBarColor = ContextCompat.getColor(this, im.vector.lib.attachmentviewer.R.color.half_transparent_status_bar)
} else {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
}
}
private fun handleMenuItemHome(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
onBackPressed(true)
true
}
else -> false
}
}
override fun onBackPressed() {
onBackPressed(false)
}
private fun onBackPressed(fromToolbar: Boolean) {
val handled = recursivelyDispatchOnBackPressed(supportFragmentManager, fromToolbar)
if (!handled) {
@Suppress("DEPRECATION")
super.onBackPressed()
}
}
private fun recursivelyDispatchOnBackPressed(fm: FragmentManager, fromToolbar: Boolean): Boolean {
val reverseOrder = fm.fragments.filterIsInstance<VectorBaseFragment<*>>().reversed()
for (f in reverseOrder) {
val handledByChildFragments = recursivelyDispatchOnBackPressed(f.childFragmentManager, fromToolbar)
if (handledByChildFragments) {
return true
}
if (f is OnBackPressed && f.onBackPressed(fromToolbar)) {
return true
}
}
return false
}
/* ==========================================================================================
* PROTECTED METHODS
* ========================================================================================== */
/**
* Get the saved instance state.
* Ensure {@link isFirstCreation()} returns false before calling this
*
* @return
*/
protected fun getSavedInstanceState(): Bundle {
return savedInstanceState!!
}
/**
* Is first creation.
*
* @return true if Activity is created for the first time (and not restored by the system)
*/
protected fun isFirstCreation() = savedInstanceState == null
// ==============================================================================================
// Handle loading view (also called waiting view or spinner view)
// ==============================================================================================
var waitingView: View? = null
set(value) {
field = value
// Ensure this view is clickable to catch UI events
value?.isClickable = true
}
/**
* Tells if the waiting view is currently displayed.
*
* @return true if the waiting view is displayed
*/
fun isWaitingViewVisible() = waitingView?.isVisible == true
/**
* Show the waiting view, and set text if not null.
*/
open fun showWaitingView(text: String? = null) {
waitingView?.isVisible = true
if (text != null) {
waitingView?.findViewById<TextView>(R.id.waitingStatusText)?.setTextOrHide(text)
}
}
/**
* Hide the waiting view.
*/
open fun hideWaitingView() {
waitingView?.isVisible = false
}
/* ==========================================================================================
* OPEN METHODS
* ========================================================================================== */
abstract fun getBinding(): VB
open fun displayInFullscreen() = false
open fun doBeforeSetContentView() = Unit
open fun initUiAndData() = Unit
// Note: does not seem to be called
final override fun invalidate() = Unit
@StringRes
open fun getTitleRes() = -1
/**
* Return a object containing other themes for this activity.
*/
open fun getOtherThemes(): ActivityOtherThemes = ActivityOtherThemes.Default
/* ==========================================================================================
* PUBLIC METHODS
* ========================================================================================== */
fun showSnackbar(message: String) {
getCoordinatorLayout()?.showOptimizedSnackbar(message)
}
fun showSnackbar(message: String, @StringRes withActionTitle: Int?, action: (() -> Unit)?) {
val coordinatorLayout = getCoordinatorLayout()
if (coordinatorLayout != null) {
Snackbar.make(coordinatorLayout, message, Snackbar.LENGTH_LONG).apply {
withActionTitle?.let {
setAction(withActionTitle) { action?.invoke() }
}
}.show()
} else {
fatalError("No CoordinatorLayout to display this snackbar!", vectorPreferences.failFast())
}
}
open fun getCoordinatorLayout(): CoordinatorLayout? = null
/* ==========================================================================================
* User Consent
* ========================================================================================== */
private val consentNotGivenHelper by lazy {
ConsentNotGivenHelper(this, DialogLocker(savedInstanceState))
.apply { restorables.add(this) }
}
/* ==========================================================================================
* Temporary method
* ========================================================================================== */
fun notImplemented(message: String = "") {
if (message.isNotBlank()) {
toast(getString(R.string.not_implemented) + ": $message")
} else {
toast(getString(R.string.not_implemented))
}
}
/**
* Sets toolbar as actionBar.
*
* @return Instance of [ToolbarConfig] with set of helper methods to configure toolbar
* */
fun setupToolbar(toolbar: MaterialToolbar) = ToolbarConfig(this, toolbar).also {
this.toolbar = it.setup()
}
}