Unverified Commit dce16964 authored by Sawyer Blatz's avatar Sawyer Blatz Committed by GitHub
Browse files

For #9208: Adds in-product prompt to homescreen (#9836)

parent c9ea0484
......@@ -1044,6 +1044,50 @@ history:
- fenix-core@mozilla.com
expires: "2020-09-01"
tip:
displayed:
type: event
description: >
The tip was displayed
extra_keys:
identifier:
description: "The identifier of the tip displayed"
bugs:
- https://github.com/mozilla-mobile/fenix/issues/9328
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/9836
notification_emails:
- fenix-core@mozilla.com
expires: "2020-09-01"
pressed:
type: event
description: >
The tip's button was pressed
extra_keys:
identifier:
description: "The identifier of the tip the action was taken on"
bugs:
- https://github.com/mozilla-mobile/fenix/issues/9328
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/9836
notification_emails:
- fenix-core@mozilla.com
expires: "2020-09-01"
closed:
type: event
description: >
The tip was closed
extra_keys:
identifier:
description: "The identifier of the tip closed"
bugs:
- https://github.com/mozilla-mobile/fenix/issues/9328
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/9836
notification_emails:
- fenix-core@mozilla.com
expires: "2020-09-01"
reader_mode:
available:
type: event
......
......@@ -43,4 +43,9 @@ object FeatureFlags {
* Enables picture-in-picture feature
*/
val pictureInPicture = Config.channel.isNightlyOrDebug
/**
* Enables tip feature
*/
val tips = Config.channel.isDebug
}
......@@ -10,16 +10,16 @@ import android.content.Intent
import androidx.core.net.toUri
import mozilla.components.feature.addons.AddonManager
import mozilla.components.feature.addons.amo.AddonCollectionProvider
import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker
import mozilla.components.feature.addons.migration.SupportedAddonsChecker
import mozilla.components.feature.addons.update.AddonUpdater
import mozilla.components.feature.addons.update.DefaultAddonUpdater
import mozilla.components.feature.addons.migration.SupportedAddonsChecker
import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
import mozilla.components.support.migration.state.MigrationStore
import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.utils.Mockable
import org.mozilla.fenix.utils.ClipboardHandler
import org.mozilla.fenix.utils.Mockable
import org.mozilla.fenix.wifi.WifiConnectionMonitor
import java.util.concurrent.TimeUnit
......
......@@ -39,6 +39,7 @@ import org.mozilla.fenix.GleanMetrics.SearchWidget
import org.mozilla.fenix.GleanMetrics.SyncAccount
import org.mozilla.fenix.GleanMetrics.SyncAuth
import org.mozilla.fenix.GleanMetrics.Tab
import org.mozilla.fenix.GleanMetrics.Tip
import org.mozilla.fenix.GleanMetrics.ToolbarSettings
import org.mozilla.fenix.GleanMetrics.TopSites
import org.mozilla.fenix.GleanMetrics.TrackingProtection
......@@ -498,6 +499,18 @@ private val Event.wrapper: EventWrapper<*>?
is Event.AddonsOpenInToolbarMenu -> EventWrapper<NoExtraKeys>(
{ Addons.openAddonInToolbarMenu.record(it) }
)
is Event.TipDisplayed -> EventWrapper(
{ Tip.displayed.record(it) },
{ Tip.displayedKeys.valueOf(it) }
)
is Event.TipPressed -> EventWrapper(
{ Tip.pressed.record(it) },
{ Tip.pressedKeys.valueOf(it) }
)
is Event.TipClosed -> EventWrapper(
{ Tip.closed.record(it) },
{ Tip.closedKeys.valueOf(it) }
)
// Don't record other events in Glean:
is Event.AddBookmark -> null
is Event.OpenedBookmark -> null
......
......@@ -32,6 +32,7 @@ import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.GleanMetrics.Library
import org.mozilla.fenix.GleanMetrics.Logins
import org.mozilla.fenix.GleanMetrics.SearchShortcuts
import org.mozilla.fenix.GleanMetrics.Tip
import org.mozilla.fenix.GleanMetrics.ToolbarSettings
import org.mozilla.fenix.GleanMetrics.TrackingProtection
import org.mozilla.fenix.R
......@@ -193,6 +194,21 @@ sealed class Event {
}
}
data class TipDisplayed(val identifier: String) : Event() {
override val extras: Map<Tip.displayedKeys, String>?
get() = hashMapOf(Tip.displayedKeys.identifier to identifier)
}
data class TipPressed(val identifier: String) : Event() {
override val extras: Map<Tip.pressedKeys, String>?
get() = hashMapOf(Tip.pressedKeys.identifier to identifier)
}
data class TipClosed(val identifier: String) : Event() {
override val extras: Map<Tip.closedKeys, String>?
get() = hashMapOf(Tip.closedKeys.identifier to identifier)
}
data class ToolbarPositionChanged(val position: Position) : Event() {
enum class Position { TOP, BOTTOM }
......
......@@ -12,14 +12,14 @@ object MozillaProductDetector {
enum class MozillaProducts(val productName: String) {
// Browsers
FIREFOX("org.mozilla.firefox"),
FIREFOX_NIGHTLY("org.mozilla.fennec_aurora"),
FIREFOX_BETA("org.mozilla.firefox_beta"),
FIREFOX_AURORA("org.mozilla.fennec_aurora"),
FIREFOX_NIGHTLY("org.mozilla.fennec"),
FIREFOX_FDROID("org.mozilla.fennec_fdroid"),
FIREFOX_LITE("org.mozilla.rocket"),
REFERENCE_BROWSER("org.mozilla.reference.browser"),
REFERENCE_BROWSER_DEBUG("org.mozilla.reference.browser.debug"),
FENIX("org.mozilla.fenix"),
FENIX_NIGHTLY("org.mozilla.fenix.nightly"),
FOCUS("org.mozilla.focus"),
KLAR("org.mozilla.klar"),
......@@ -43,7 +43,7 @@ object MozillaProductDetector {
return mozillaProducts
}
private fun packageIsInstalled(context: Context, packageName: String): Boolean {
fun packageIsInstalled(context: Context, packageName: String): Boolean {
try {
context.packageManager.getPackageInfo(packageName, 0)
} catch (e: PackageManager.NameNotFoundException) {
......
/* 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
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.components.tips
import org.mozilla.fenix.FeatureFlags
sealed class TipType {
data class Button(val text: String, val action: () -> Unit) : TipType()
}
open class Tip(
val type: TipType,
val identifier: String,
val title: String,
val description: String,
val learnMoreURL: String?
)
interface TipProvider {
val tip: Tip?
val shouldDisplay: Boolean
}
interface TipManager {
fun getTip(): Tip?
}
class FenixTipManager(
private val providers: List<TipProvider>
) : TipManager {
override fun getTip(): Tip? {
if (!FeatureFlags.tips) { return null }
return providers
.firstOrNull { it.shouldDisplay }
?.tip
}
}
/* 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
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.components.tips.providers
import android.content.Context
import android.content.Intent
import android.net.Uri
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.MozillaProductDetector
import org.mozilla.fenix.components.metrics.MozillaProductDetector.MozillaProducts.FENIX
import org.mozilla.fenix.components.metrics.MozillaProductDetector.MozillaProducts.FENIX_NIGHTLY
import org.mozilla.fenix.components.metrics.MozillaProductDetector.MozillaProducts.FIREFOX_NIGHTLY
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.components.tips.TipProvider
import org.mozilla.fenix.components.tips.TipType
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.settings.SupportUtils
/**
* Tip explaining to users the migration of Fenix channels
*/
class MigrationTipProvider(private val context: Context) : TipProvider {
override val tip: Tip? =
when (context.packageName) {
FENIX.productName -> firefoxPreviewMovedTip()
FIREFOX_NIGHTLY.productName -> getNightlyMigrationTip()
FENIX_NIGHTLY.productName -> getNightlyMigrationTip()
else -> null
}
override val shouldDisplay: Boolean = context.settings().shouldDisplayFenixMovingTip()
private fun firefoxPreviewMovedTip(): Tip =
Tip(
type = TipType.Button(
text = context.getString(R.string.tip_firefox_preview_moved_button),
action = ::getFirefoxMovedButtonAction
),
identifier = getIdentifier(),
title = context.getString(R.string.tip_firefox_preview_moved_header),
description = context.getString(R.string.tip_firefox_preview_moved_description),
learnMoreURL = SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.FENIX_MOVING)
)
private fun firefoxPreviewMovedPreviewInstalledTip(): Tip =
Tip(
type = TipType.Button(
text = context.getString(R.string.tip_firefox_preview_moved_button_preview_installed),
action = ::getFirefoxMovedButtonAction
),
identifier = getIdentifier(),
title = context.getString(R.string.tip_firefox_preview_moved_header_preview_installed),
description = context.getString(R.string.tip_firefox_preview_moved_description_preview_installed),
learnMoreURL = SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.FENIX_MOVING)
)
private fun firefoxPreviewMovedPreviewNotInstalledTip(): Tip =
Tip(
type = TipType.Button(
text = context.getString(R.string.tip_firefox_preview_moved_button_preview_not_installed),
action = ::getFirefoxMovedButtonAction
),
identifier = getIdentifier(),
title = context.getString(R.string.tip_firefox_preview_moved_header_preview_not_installed),
description = context.getString(R.string.tip_firefox_preview_moved_description_preview_not_installed),
learnMoreURL = SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.FENIX_MOVING)
)
private fun getNightlyMigrationTip(): Tip? {
return if (MozillaProductDetector.packageIsInstalled(context, FENIX.productName)) {
firefoxPreviewMovedPreviewInstalledTip()
} else {
firefoxPreviewMovedPreviewNotInstalledTip()
}
}
private fun getFirefoxMovedButtonAction() {
when (context.packageName) {
FENIX.productName -> context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(SupportUtils.FIREFOX_BETA_PLAY_STORE_URL))
)
FIREFOX_NIGHTLY.productName -> getNightlyMigrationAction()
FENIX_NIGHTLY.productName -> getNightlyMigrationAction()
else -> { }
}
}
private fun getNightlyMigrationAction() {
return if (MozillaProductDetector.packageIsInstalled(context, FENIX.productName)) {
context.startActivity(context.packageManager.getLaunchIntentForPackage(FENIX.productName))
} else {
context.startActivity(Intent(
Intent.ACTION_VIEW, Uri.parse(SupportUtils.FIREFOX_NIGHTLY_PLAY_STORE_URL)
))
}
}
private fun getIdentifier(): String {
return when (context.packageName) {
FENIX.productName -> context.getString(R.string.pref_key_migrating_from_fenix_tip)
FIREFOX_NIGHTLY.productName -> context.getString(R.string.pref_key_migrating_from_firefox_nightly_tip)
FENIX_NIGHTLY.productName -> context.getString(R.string.pref_key_migrating_from_fenix_nightly_tip)
else -> { "" }
}
}
}
......@@ -77,6 +77,8 @@ import org.mozilla.fenix.components.PrivateShortcutCreateManager
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.tips.FenixTipManager
import org.mozilla.fenix.components.tips.providers.MigrationTipProvider
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.hideToolbar
import org.mozilla.fenix.ext.metrics
......@@ -199,7 +201,8 @@ class HomeFragment : Fragment() {
expandedCollections = emptySet(),
mode = currentMode.getCurrentMode(),
tabs = emptyList(),
topSites = requireComponents.core.topSiteStorage.cachedTopSites
topSites = requireComponents.core.topSiteStorage.cachedTopSites,
tip = FenixTipManager(listOf(MigrationTipProvider(requireContext()))).getTip()
)
)
}
......@@ -379,7 +382,8 @@ class HomeFragment : Fragment() {
collections = components.core.tabCollectionStorage.cachedTabCollections,
mode = currentMode.getCurrentMode(),
tabs = getListOfSessions().toTabs(),
topSites = components.core.topSiteStorage.cachedTopSites
topSites = components.core.topSiteStorage.cachedTopSites,
tip = FenixTipManager(listOf(MigrationTipProvider(requireContext()))).getTip()
)
)
......
......@@ -13,6 +13,7 @@ import mozilla.components.feature.top.sites.TopSite
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import org.mozilla.fenix.components.tips.Tip
/**
* The [Store] for holding the [HomeFragmentState] and applying [HomeFragmentAction]s.
......@@ -58,7 +59,8 @@ data class HomeFragmentState(
val expandedCollections: Set<Long>,
val mode: Mode,
val tabs: List<Tab>,
val topSites: List<TopSite>
val topSites: List<TopSite>,
val tip: Tip? = null
) : State
sealed class HomeFragmentAction : Action {
......@@ -66,7 +68,8 @@ sealed class HomeFragmentAction : Action {
val tabs: List<Tab>,
val topSites: List<TopSite>,
val mode: Mode,
val collections: List<TabCollection>
val collections: List<TabCollection>,
val tip: Tip? = null
) :
HomeFragmentAction()
......@@ -77,6 +80,7 @@ sealed class HomeFragmentAction : Action {
data class ModeChange(val mode: Mode, val tabs: List<Tab> = emptyList()) : HomeFragmentAction()
data class TabsChange(val tabs: List<Tab>) : HomeFragmentAction()
data class TopSitesChange(val topSites: List<TopSite>) : HomeFragmentAction()
data class RemoveTip(val tip: Tip) : HomeFragmentAction()
}
private fun homeFragmentStateReducer(
......@@ -88,7 +92,8 @@ private fun homeFragmentStateReducer(
collections = action.collections,
mode = action.mode,
tabs = action.tabs,
topSites = action.topSites
topSites = action.topSites,
tip = action.tip
)
is HomeFragmentAction.CollectionExpanded -> {
val newExpandedCollection = state.expandedCollections.toMutableSet()
......@@ -105,5 +110,6 @@ private fun homeFragmentStateReducer(
is HomeFragmentAction.ModeChange -> state.copy(mode = action.mode, tabs = action.tabs)
is HomeFragmentAction.TabsChange -> state.copy(tabs = action.tabs)
is HomeFragmentAction.TopSitesChange -> state.copy(topSites = action.topSites)
is HomeFragmentAction.RemoveTip -> { state.copy(tip = null) }
}
}
......@@ -17,16 +17,17 @@ import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.tab_list_row.*
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.home.OnboardingState
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionHeaderViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TabInCollectionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.NoContentMessageViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.NoContentMessageWithActionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.PrivateBrowsingDescriptionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.SaveTabGroupViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TabHeaderViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TabInCollectionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TabViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TopSiteHeaderViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TopSiteViewHolder
......@@ -41,10 +42,12 @@ import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingTh
import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingToolbarPositionPickerViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingTrackingProtectionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingWhatsNewViewHolder
import org.mozilla.fenix.home.tips.ButtonTipViewHolder
import mozilla.components.feature.tab.collections.Tab as ComponentTab
sealed class AdapterItem(@LayoutRes val viewType: Int) {
data class TipItem(val tip: Tip) : AdapterItem(
ButtonTipViewHolder.LAYOUT_ID)
data class TabHeader(val isPrivate: Boolean, val hasTabs: Boolean) : AdapterItem(TabHeaderViewHolder.LAYOUT_ID)
data class TabItem(val tab: Tab) : AdapterItem(TabViewHolder.LAYOUT_ID) {
override fun sameAs(other: AdapterItem) = other is TabItem && tab.sessionId == other.tab.sessionId
......@@ -164,6 +167,7 @@ class SessionControlAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return when (viewType) {
ButtonTipViewHolder.LAYOUT_ID -> ButtonTipViewHolder(view, interactor)
TabHeaderViewHolder.LAYOUT_ID -> TabHeaderViewHolder(view, interactor)
TopSiteHeaderViewHolder.LAYOUT_ID -> TopSiteHeaderViewHolder(view)
TabViewHolder.LAYOUT_ID -> TabViewHolder(view, interactor)
......@@ -196,6 +200,10 @@ class SessionControlAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = getItem(position)
when (holder) {
is ButtonTipViewHolder -> {
val tipItem = item as AdapterItem.TipItem
holder.bind(tipItem.tip)
}
is TabHeaderViewHolder -> {
val tabHeader = item as AdapterItem.TabHeader
holder.bind(tabHeader.isPrivate, tabHeader.hasTabs)
......
......@@ -35,6 +35,7 @@ import org.mozilla.fenix.home.HomeFragmentAction
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.HomeFragmentStore
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.settings.SupportUtils
import mozilla.components.feature.tab.collections.Tab as ComponentTab
......@@ -163,6 +164,8 @@ interface SessionControlController {
* @see [TabSessionInteractor.onOpenNewTabClicked]
*/
fun handleonOpenNewTabClicked()
fun handleCloseTip(tip: Tip)
}
@SuppressWarnings("TooManyFunctions", "LargeClass")
......@@ -386,6 +389,10 @@ class DefaultSessionControlController(
openSearchScreen()
}
override fun handleCloseTip(tip: Tip) {
fragmentStore.dispatch(HomeFragmentAction.RemoveTip(tip))
}
private fun showCollectionCreationFragment(
step: SaveCollectionStep,
selectedTabIds: Array<String>? = null,
......
......@@ -8,6 +8,7 @@ import android.view.View
import mozilla.components.feature.tab.collections.Tab
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite
import org.mozilla.fenix.components.tips.Tip
/**
* Interface for collection related actions in the [SessionControlInteractor].
......@@ -104,6 +105,13 @@ interface OnboardingInteractor {
fun onReadPrivacyNoticeClicked()
}
interface TipInteractor {
/**
* Dismisses the tip view adapter
*/
fun onCloseTip(tip: Tip)
}
/**
* Interface for tab related actions in the [SessionControlInteractor].
*/
......@@ -205,7 +213,7 @@ interface TopSiteInteractor {
@SuppressWarnings("TooManyFunctions")
class SessionControlInteractor(
private val controller: SessionControlController
) : CollectionInteractor, OnboardingInteractor, TabSessionInteractor, TopSiteInteractor {
) : CollectionInteractor, OnboardingInteractor, TabSessionInteractor, TopSiteInteractor, TipInteractor {
override fun onCloseTab(sessionId: String) {
controller.handleCloseTab(sessionId)
}
......@@ -301,4 +309,8 @@ class SessionControlInteractor(
override fun onOpenNewTabClicked() {
controller.handleonOpenNewTabClicked()
}
override fun onCloseTip(tip: Tip) {
controller.handleCloseTip(tip)
}
}
......@@ -22,6 +22,7 @@ import org.mozilla.fenix.home.HomeScreenViewModel
import org.mozilla.fenix.home.Mode
import org.mozilla.fenix.home.OnboardingState
import org.mozilla.fenix.home.Tab
import org.mozilla.fenix.components.tips.Tip
val noTabMessage = AdapterItem.NoContentMessageWithAction(
R.string.no_open_tabs_header_2,
......@@ -39,10 +40,13 @@ private fun normalModeAdapterItems(
tabs: List<Tab>,
topSites: List<TopSite>,
collections: List<TabCollection>,
expandedCollections: Set<Long>
expandedCollections: Set<Long>,
tip: Tip?
): List<AdapterItem> {
val items = mutableListOf<AdapterItem>()
tip?.let { items.add(AdapterItem.TipItem(it)) }
if (topSites.isNotEmpty()) {
items.add(AdapterItem.TopSiteHeader)
items.add(AdapterItem.TopSiteList(topSites))
......@@ -150,7 +154,7 @@ private fun onboardingAdapterItems(onboardingState: OnboardingState): List<Adapt
}
private fun HomeFragmentState.toAdapterList(): List<AdapterItem> = when (mode) {
is Mode.Normal -> normalModeAdapterItems(tabs, topSites, collections, expandedCollections)
is Mode.Normal -> normalModeAdapterItems(tabs, topSites, collections, expandedCollections, tip)
is Mode.Private -> privateModeAdapterItems(tabs)
is Mode.Onboarding -> onboardingAdapterItems(mode.state)
}
......
package org.mozilla.fenix.home.tips
import android.text.SpannableString
import android.text.style.UnderlineSpan
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.button_tip_item.view.*
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.components.tips.TipType
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
class ButtonTipViewHolder(
val view: View,
val interactor: SessionControlInteractor
) : RecyclerView.ViewHolder(view) {
var tip: Tip? = null
fun bind(tip: Tip) {
require(tip.type is TipType.Button)
this.tip = tip