Revision control
Copy as Markdown
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
package org.mozilla.focus.activity
import android.Manifest.permission.POST_NOTIFICATIONS
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.util.AttributeSet
import android.view.MenuItem
import android.view.View
import android.view.ViewTreeObserver
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.ActionBar
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.isVisible
import androidx.preference.PreferenceManager
import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.concept.engine.EngineView
import mozilla.components.feature.search.widget.BaseVoiceSearchActivity
import mozilla.components.lib.auth.canUseBiometricFeature
import mozilla.components.lib.crash.Crash
import mozilla.components.service.glean.private.NoExtras
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.ktx.android.view.createWindowInsetsController
import mozilla.components.support.locale.LocaleAwareAppCompatActivity
import mozilla.components.support.utils.SafeIntent
import mozilla.components.support.utils.StatusBarUtils
import org.mozilla.experiments.nimbus.initializeTooling
import org.mozilla.focus.GleanMetrics.AppOpened
import org.mozilla.focus.GleanMetrics.Notifications
import org.mozilla.focus.R
import org.mozilla.focus.appreview.AppReviewUtils
import org.mozilla.focus.databinding.ActivityMainBinding
import org.mozilla.focus.ext.components
import org.mozilla.focus.ext.setNavigationIcon
import org.mozilla.focus.ext.settings
import org.mozilla.focus.ext.updateSecureWindowFlags
import org.mozilla.focus.fragment.BrowserFragment
import org.mozilla.focus.fragment.UrlInputFragment
import org.mozilla.focus.navigation.MainActivityNavigation
import org.mozilla.focus.navigation.Navigator
import org.mozilla.focus.searchwidget.ExternalIntentNavigation
import org.mozilla.focus.session.IntentProcessor
import org.mozilla.focus.session.PrivateNotificationFeature
import org.mozilla.focus.shortcut.HomeScreen
import org.mozilla.focus.state.AppAction
import org.mozilla.focus.state.Screen
import org.mozilla.focus.telemetry.startuptelemetry.StartupPathProvider
import org.mozilla.focus.telemetry.startuptelemetry.StartupTypeTelemetry
import org.mozilla.focus.utils.SupportUtils
private const val REQUEST_TIME_OUT = 2000L
@Suppress("TooManyFunctions", "LargeClass")
open class MainActivity : LocaleAwareAppCompatActivity() {
private var isToolbarInflated = false
private val intentProcessor by lazy {
IntentProcessor(this, components.tabsUseCases, components.customTabsUseCases)
}
private val navigator by lazy { Navigator(components.appStore, MainActivityNavigation(this)) }
private val tabCount: Int
get() = components.store.state.privateTabs.size
private val startupPathProvider = StartupPathProvider()
private lateinit var startupTypeTelemetry: StartupTypeTelemetry
private var _binding: ActivityMainBinding? = null
private val binding get() = _binding!!
private lateinit var privateNotificationFeature: PrivateNotificationFeature
private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
when {
granted -> {
privateNotificationFeature.start()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
components.experiments.initializeTooling(applicationContext, intent)
installSplashScreen()
updateSecureWindowFlags()
super.onCreate(savedInstanceState)
_binding = ActivityMainBinding.inflate(layoutInflater)
// Checks if Activity is currently in PiP mode if launched from external intents, then exits it
checkAndExitPiP()
if (!isTaskRoot) {
if (intent.hasCategory(Intent.CATEGORY_LAUNCHER) && Intent.ACTION_MAIN == intent.action) {
finish()
return
}
}
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
window.statusBarColor = ContextCompat.getColor(this, android.R.color.transparent)
when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
Configuration.UI_MODE_NIGHT_UNDEFINED, // We assume light here per Android doc's recommendation
Configuration.UI_MODE_NIGHT_NO,
-> {
updateLightSystemBars()
}
Configuration.UI_MODE_NIGHT_YES -> {
clearLightSystemBars()
}
}
setContentView(binding.root)
startupPathProvider.attachOnActivityOnCreate(lifecycle, intent)
startupTypeTelemetry = StartupTypeTelemetry(components.startupStateProvider, startupPathProvider).apply {
attachOnMainActivityOnCreate(lifecycle)
}
val safeIntent = SafeIntent(intent)
lifecycle.addObserver(navigator)
if (savedInstanceState == null) {
handleAppNavigation(safeIntent)
}
if (savedInstanceState == null && intent.hasExtra(HomeScreen.ADD_TO_HOMESCREEN_TAG)) {
intentProcessor.handleNewIntent(this, safeIntent)
}
if (safeIntent.isLauncherIntent) {
AppOpened.fromIcons.record(AppOpened.FromIconsExtra(AppOpenType.LAUNCH.type))
}
val launchCount = settings.getAppLaunchCount()
PreferenceManager.getDefaultSharedPreferences(this)
.edit()
.putInt(getString(R.string.app_launch_count), launchCount + 1)
.apply()
AppReviewUtils.showAppReview(this)
privateNotificationFeature = PrivateNotificationFeature(
context = applicationContext,
browserStore = components.store,
permissionRequestHandler = { requestNotificationPermission() },
).also {
it.start()
}
components.notificationsDelegate.bindToActivity(this)
}
private fun requestNotificationPermission() {
privateNotificationFeature.stop()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermission.launch(POST_NOTIFICATIONS)
}
}
private fun setSplashScreenPreDrawListener(safeIntent: SafeIntent) {
val endTime = System.currentTimeMillis() + REQUEST_TIME_OUT
binding.container.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
return if (System.currentTimeMillis() >= endTime) {
ExternalIntentNavigation.handleAppNavigation(
bundle = safeIntent.extras,
context = this@MainActivity,
)
binding.container.viewTreeObserver.removeOnPreDrawListener(this)
true
} else {
false
}
}
},
)
}
private fun checkAndExitPiP() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode && intent != null) {
// Exit PiP mode
moveTaskToBack(false)
startActivity(Intent(this, this::class.java).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT))
}
}
final override fun onUserLeaveHint() {
val browserFragment =
supportFragmentManager.findFragmentByTag(BrowserFragment.FRAGMENT_TAG) as BrowserFragment?
if (browserFragment is UserInteractionHandler && browserFragment.onHomePressed()) {
return
}
super.onUserLeaveHint()
}
override fun onResume() {
super.onResume()
checkBiometricStillValid()
}
override fun onPause() {
val fragmentManager = supportFragmentManager
val browserFragment =
fragmentManager.findFragmentByTag(BrowserFragment.FRAGMENT_TAG) as BrowserFragment?
browserFragment?.cancelAnimation()
val urlInputFragment =
fragmentManager.findFragmentByTag(UrlInputFragment.FRAGMENT_TAG) as UrlInputFragment?
urlInputFragment?.cancelAnimation()
super.onPause()
}
override fun onStop() {
super.onStop()
}
override fun onNewIntent(unsafeIntent: Intent) {
if (Crash.isCrashIntent(unsafeIntent)) {
val browserFragment = supportFragmentManager
.findFragmentByTag(BrowserFragment.FRAGMENT_TAG) as BrowserFragment?
val crash = Crash.fromIntent(unsafeIntent)
browserFragment?.handleTabCrash(crash)
}
startupPathProvider.onIntentReceived(intent)
val intent = SafeIntent(unsafeIntent)
handleAppRestoreFromBackground(intent)
if (intent.dataString.equals(SupportUtils.OPEN_WITH_DEFAULT_BROWSER_URL)) {
components.appStore.dispatch(
AppAction.OpenSettings(
page = Screen.Settings.Page.General,
),
)
super.onNewIntent(unsafeIntent)
return
}
val action = intent.action
if (intent.hasExtra(HomeScreen.ADD_TO_HOMESCREEN_TAG)) {
intentProcessor.handleNewIntent(this, intent)
}
if (ACTION_OPEN == action) {
Notifications.openButtonTapped.record(NoExtras())
}
if (ACTION_ERASE == action) {
processEraseAction(intent)
}
if (intent.isLauncherIntent) {
AppOpened.fromIcons.record(AppOpened.FromIconsExtra(AppOpenType.RESUME.type))
}
super.onNewIntent(unsafeIntent)
}
private fun handleAppRestoreFromBackground(intent: SafeIntent) {
if (!intent.extras?.getString(BaseVoiceSearchActivity.SPEECH_PROCESSING).isNullOrEmpty()) {
handleAppNavigation(intent)
return
}
when (components.appStore.state.screen) {
is Screen.Settings -> components.appStore.dispatch(
AppAction.OpenSettings(
page =
(components.appStore.state.screen as Screen.Settings).page,
),
)
is Screen.SitePermissionOptionsScreen -> components.appStore.dispatch(
AppAction.OpenSitePermissionOptionsScreen(
sitePermission =
(components.appStore.state.screen as Screen.SitePermissionOptionsScreen).sitePermission,
),
)
else -> {
handleAppNavigation(intent)
}
}
}
private fun handleAppNavigation(intent: SafeIntent) {
if (components.appStore.state.screen == Screen.Locked()) {
components.appStore.dispatch(AppAction.Lock(intent.extras))
} else if (settings.getAppLaunchCount() == 0) {
setSplashScreenPreDrawListener(intent)
} else {
ExternalIntentNavigation.handleAppNavigation(
bundle = intent.extras,
context = this,
)
}
}
private fun processEraseAction(intent: SafeIntent) {
val fromNotificationAction = intent.getBooleanExtra(EXTRA_NOTIFICATION, false)
components.tabsUseCases.removeAllTabs()
if (fromNotificationAction) {
Notifications.eraseOpenButtonTapped.record(Notifications.EraseOpenButtonTappedExtra(tabCount))
}
}
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
return if (name == EngineView::class.java.name) {
components.engine.createView(context, attrs).asView()
} else {
super.onCreateView(parent, name, context, attrs)
}
}
override fun onBackPressed() {
val fragmentManager = supportFragmentManager
val urlInputFragment =
fragmentManager.findFragmentByTag(UrlInputFragment.FRAGMENT_TAG) as UrlInputFragment?
if (urlInputFragment != null &&
urlInputFragment.isVisible &&
urlInputFragment.onBackPressed()
) {
// The URL input fragment has handled the back press. It does its own animations so
// we do not try to remove it from outside.
return
}
val browserFragment =
fragmentManager.findFragmentByTag(BrowserFragment.FRAGMENT_TAG) as BrowserFragment?
if (browserFragment != null &&
browserFragment.isVisible &&
browserFragment.onBackPressed()
) {
// The Browser fragment handles back presses on its own because it might just go back
// in the browsing history.
return
}
val appStore = components.appStore
if (appStore.state.screen is Screen.Settings || appStore.state.screen is Screen.SitePermissionOptionsScreen) {
// When on a settings screen we want the same behavior as navigating "up" via the toolbar
// and therefore dispatch the `NavigateUp` action on the app store.
val selectedTabId = components.store.state.selectedTabId
appStore.dispatch(AppAction.NavigateUp(selectedTabId))
return
}
onBackPressedDispatcher.onBackPressed()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
// We forward an up action to the app store with the NavigateUp action to let the reducer
// decide to show a different screen.
val selectedTabId = components.store.state.selectedTabId
components.appStore.dispatch(AppAction.NavigateUp(selectedTabId))
return true
}
return super.onOptionsItemSelected(item)
}
// Handles the edge case of a user removing all enrolled prints while auth was enabled
private fun checkBiometricStillValid() {
// Disable biometrics if the user is no longer eligible due to un-enrolling fingerprints:
if (!canUseBiometricFeature()) {
PreferenceManager.getDefaultSharedPreferences(this)
.edit().putBoolean(
getString(R.string.pref_key_biometric),
false,
).apply()
}
}
private fun updateLightSystemBars() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
window.statusBarColor = getColorFromAttr(android.R.attr.statusBarColor)
window.createWindowInsetsController().isAppearanceLightStatusBars = true
} else {
window.statusBarColor = Color.BLACK
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// API level can display handle light navigation bar color
window.createWindowInsetsController().isAppearanceLightNavigationBars = true
window.navigationBarColor = ContextCompat.getColor(this, android.R.color.transparent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.navigationBarDividerColor =
ContextCompat.getColor(this, android.R.color.transparent)
}
}
}
private fun clearLightSystemBars() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
window.createWindowInsetsController().isAppearanceLightStatusBars = false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// API level can display handle light navigation bar color
window.createWindowInsetsController().isAppearanceLightNavigationBars = false
}
}
fun getToolbar(): ActionBar {
if (!isToolbarInflated) {
val toolbar = binding.toolbar.inflate() as Toolbar
setSupportActionBar(toolbar)
setNavigationIcon(R.drawable.ic_back_button)
isToolbarInflated = true
}
return supportActionBar!!
}
fun customizeStatusBar(backgroundColorId: Int? = null) {
with(binding.statusBarBackground) {
binding.statusBarBackground.isVisible = true
StatusBarUtils.getStatusBarHeight(this) { statusBarHeight ->
layoutParams.height = statusBarHeight
backgroundColorId?.let { color ->
setBackgroundColor(ContextCompat.getColor(context, color))
}
}
}
}
fun hideStatusBarBackground() {
binding.statusBarBackground.isVisible = false
}
override fun onDestroy() {
super.onDestroy()
_binding = null
if (this::privateNotificationFeature.isInitialized) {
privateNotificationFeature.stop()
}
components.notificationsDelegate.unBindActivity(this)
}
enum class AppOpenType(val type: String) {
LAUNCH("Launch"),
RESUME("Resume"),
}
companion object {
const val ACTION_ERASE = "erase"
const val ACTION_OPEN = "open"
const val EXTRA_NOTIFICATION = "notification"
}
}