Merge tag 'v85.1.1' into upstream-sync
commit
6d4a47921c
@ -0,0 +1,59 @@
|
||||
pull_request_rules:
|
||||
- name: Resolve conflict
|
||||
conditions:
|
||||
- conflict
|
||||
actions:
|
||||
comment:
|
||||
message: This pull request has conflicts when rebasing. Could you fix it @{{author}}? 🙏
|
||||
- name: MickeyMoz - Auto Merge
|
||||
conditions:
|
||||
- author=MickeyMoz
|
||||
- status-success=pr-complete
|
||||
- files~=(Gecko.kt|AndroidComponents.kt)
|
||||
actions:
|
||||
review:
|
||||
type: APPROVE
|
||||
message: MickeyMoz 💪
|
||||
merge:
|
||||
method: rebase
|
||||
strict: smart
|
||||
- name: L10N - Auto Merge
|
||||
conditions:
|
||||
- author=mozilla-l10n-automation-bot
|
||||
- status-success=pr-complete
|
||||
- files~=(strings.xml)
|
||||
actions:
|
||||
review:
|
||||
type: APPROVE
|
||||
message: LGTM 😎
|
||||
merge:
|
||||
method: rebase
|
||||
strict: smart
|
||||
- name: Release automation
|
||||
conditions:
|
||||
- base~=releases/.*
|
||||
- author=github-actions[bot]
|
||||
# Listing checks manually beause we do not have a "push complete" check yet.
|
||||
- check-success=build-android-test-debug
|
||||
- check-success=build-debug
|
||||
- check-success=build-nightly-simulation
|
||||
- check-success=lint-compare-locales
|
||||
- check-success=lint-detekt
|
||||
- check-success=lint-ktlint
|
||||
- check-success=lint-lint
|
||||
- check-success=signing-android-test-debug
|
||||
- check-success=signing-debug
|
||||
- check-success=signing-nightly-simulation
|
||||
- check-success=test-debug
|
||||
# TODO Temporarily disabled - should be renamed to -build
|
||||
#- check-success=ui-test-x86-debug
|
||||
- files~=(AndroidComponents.kt)
|
||||
actions:
|
||||
review:
|
||||
type: APPROVE
|
||||
message: 🚢
|
||||
merge:
|
||||
method: rebase
|
||||
strict: smart
|
||||
delete_head_branch:
|
||||
force: false
|
@ -0,0 +1,42 @@
|
||||
/* 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/. */
|
||||
|
||||
/**
|
||||
* Handles the parsing of the ErrorPages URI and then passes them to injectValues
|
||||
*/
|
||||
function parseQuery(queryString) {
|
||||
if (queryString[0] === '?') {
|
||||
queryString = queryString.substr(1);
|
||||
}
|
||||
const query = Object.fromEntries(new URLSearchParams(queryString).entries());
|
||||
injectValues(query);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the HTML elements based on the queryMap
|
||||
*/
|
||||
function injectValues(queryMap) {
|
||||
// Go through each element and inject the values
|
||||
document.title = queryMap.title;
|
||||
document.getElementById('errorTitleText').innerHTML = queryMap.title;
|
||||
document.getElementById('errorShortDesc').innerHTML = queryMap.description;
|
||||
|
||||
// If no image is passed in, remove the element so as not to leave an empty iframe
|
||||
const errorImage = document.getElementById('errorImage');
|
||||
if (!queryMap.image) {
|
||||
errorImage.remove();
|
||||
} else {
|
||||
errorImage.src = "resource://android/assets/" + queryMap.image;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (window.history.length == 1) {
|
||||
document.getElementById('backButton').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('backButton').addEventListener('click', () => window.history.back() );
|
||||
}
|
||||
});
|
||||
|
||||
parseQuery(document.documentURI);
|
@ -0,0 +1,116 @@
|
||||
/* 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/. */
|
||||
|
||||
/**
|
||||
* Handles the parsing of the ErrorPages URI and then passes them to injectValues
|
||||
*/
|
||||
function parseQuery(queryString) {
|
||||
if (queryString[0] === '?') {
|
||||
queryString = queryString.substr(1);
|
||||
}
|
||||
const query = Object.fromEntries(new URLSearchParams(queryString).entries());
|
||||
injectValues(query);
|
||||
updateShowSSL(query);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the HTML elements based on the queryMap
|
||||
*/
|
||||
function injectValues(queryMap) {
|
||||
// Go through each element and inject the values
|
||||
document.title = queryMap.title;
|
||||
document.getElementById('errorTitleText').innerHTML = queryMap.title;
|
||||
document.getElementById('errorShortDesc').innerHTML = queryMap.description;
|
||||
document.getElementById('errorTryAgain').innerHTML = queryMap.button;
|
||||
document.getElementById('advancedButton').innerHTML = queryMap.badCertAdvanced;
|
||||
document.getElementById('badCertTechnicalInfo').innerHTML = queryMap.badCertTechInfo;
|
||||
document.getElementById('advancedPanelBackButton').innerHTML = queryMap.badCertGoBack;
|
||||
document.getElementById('advancedPanelAcceptButton').innerHTML = queryMap.badCertAcceptTemporary;
|
||||
|
||||
// If no image is passed in, remove the element so as not to leave an empty iframe
|
||||
const errorImage = document.getElementById('errorImage');
|
||||
if (!queryMap.image) {
|
||||
errorImage.remove();
|
||||
} else {
|
||||
errorImage.src = "resource://android/assets/" + queryMap.image;
|
||||
}
|
||||
};
|
||||
|
||||
let advancedVisible = false;
|
||||
|
||||
/**
|
||||
* Used to show or hide the "advanced" button based on the validity of the SSL certificate
|
||||
*/
|
||||
function updateShowSSL(queryMap) {
|
||||
/** @type {'true' | 'false'} */
|
||||
const showSSL = queryMap.showSSL;
|
||||
if (typeof document.addCertException === 'undefined') {
|
||||
document.getElementById('advancedButton').style.display='none';
|
||||
} else {
|
||||
if (showSSL === 'true') {
|
||||
document.getElementById('advancedButton').style.display='block';
|
||||
} else {
|
||||
document.getElementById('advancedButton').style.display='none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to display information about the SSL certificate in `error_pages.html`
|
||||
*/
|
||||
function toggleAdvancedAndScroll() {
|
||||
const advancedPanel = document.getElementById('badCertAdvancedPanel');
|
||||
if (advancedVisible) {
|
||||
advancedPanel.style.display='none';
|
||||
} else {
|
||||
advancedPanel.style.display='block';
|
||||
}
|
||||
advancedVisible = !advancedVisible;
|
||||
|
||||
const horizontalLine = document.getElementById("horizontalLine");
|
||||
const advancedPanelAcceptButton = document.getElementById(
|
||||
"advancedPanelAcceptButton"
|
||||
);
|
||||
const badCertAdvancedPanel = document.getElementById(
|
||||
"badCertAdvancedPanel"
|
||||
);
|
||||
|
||||
// We know that the button is being displayed
|
||||
if (badCertAdvancedPanel.style.display === "block") {
|
||||
horizontalLine.hidden = false;
|
||||
advancedPanelAcceptButton.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
inline: "nearest",
|
||||
});
|
||||
} else {
|
||||
horizontalLine.hidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to bypass an SSL pages in `error_pages.html`
|
||||
*/
|
||||
async function acceptAndContinue(temporary) {
|
||||
try {
|
||||
await document.addCertException(temporary);
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
console.error("Unexpected error: " + error);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (window.history.length == 1) {
|
||||
document.getElementById('advancedPanelBackButton').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('advancedPanelBackButton').addEventListener('click', () => window.history.back());
|
||||
}
|
||||
|
||||
document.getElementById('errorTryAgain').addEventListener('click', () => window.location.reload());
|
||||
document.getElementById('advancedButton').addEventListener('click', toggleAdvancedAndScroll);
|
||||
document.getElementById('advancedPanelAcceptButton').addEventListener('click', () => acceptAndContinue(true));
|
||||
});
|
||||
|
||||
parseQuery(document.documentURI);
|
@ -1,37 +0,0 @@
|
||||
/* 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
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import mozilla.components.browser.search.SearchEngineManager
|
||||
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
|
||||
import org.mozilla.fenix.perf.lazyMonitored
|
||||
import org.mozilla.fenix.utils.Mockable
|
||||
|
||||
/**
|
||||
* Component group for all search engine integration related functionality.
|
||||
*/
|
||||
@Mockable
|
||||
class Search(private val context: Context) {
|
||||
val provider = FenixSearchEngineProvider(context)
|
||||
|
||||
/**
|
||||
* This component provides access to a centralized registry of search engines.
|
||||
*/
|
||||
val searchEngineManager by lazyMonitored {
|
||||
SearchEngineManager(
|
||||
coroutineContext = IO,
|
||||
providers = listOf(provider)
|
||||
).apply {
|
||||
registerForLocaleUpdates(context)
|
||||
GlobalScope.launch {
|
||||
defaultSearchEngine = provider.getDefaultEngine(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
/* 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.search
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import mozilla.components.browser.search.SearchEngineParser
|
||||
import mozilla.components.browser.state.search.SearchEngine
|
||||
import mozilla.components.feature.search.ext.migrate
|
||||
import mozilla.components.feature.search.middleware.SearchMiddleware
|
||||
import org.mozilla.fenix.ext.components
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.IOException
|
||||
|
||||
private const val PREF_FILE_SEARCH_ENGINES = "custom-search-engines"
|
||||
|
||||
private const val PREF_KEY_CUSTOM_SEARCH_ENGINES = "pref_custom_search_engines"
|
||||
private const val PREF_KEY_MIGRATED = "pref_search_migrated"
|
||||
|
||||
/**
|
||||
* Helper class to migrate the search related data in Fenix to the "Android Components" implementation.
|
||||
*/
|
||||
internal class SearchMigration(
|
||||
private val context: Context
|
||||
) : SearchMiddleware.Migration {
|
||||
|
||||
override fun getValuesToMigrate(): SearchMiddleware.Migration.MigrationValues? {
|
||||
val preferences = context.getSharedPreferences(PREF_FILE_SEARCH_ENGINES, Context.MODE_PRIVATE)
|
||||
if (preferences.getBoolean(PREF_KEY_MIGRATED, false)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val values = SearchMiddleware.Migration.MigrationValues(
|
||||
customSearchEngines = loadCustomSearchEngines(preferences),
|
||||
defaultSearchEngineName = context.components.settings.defaultSearchEngineName
|
||||
)
|
||||
|
||||
preferences.edit()
|
||||
.putBoolean(PREF_KEY_MIGRATED, true)
|
||||
.apply()
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
private fun loadCustomSearchEngines(
|
||||
preferences: SharedPreferences
|
||||
): List<SearchEngine> {
|
||||
val ids = preferences.getStringSet(PREF_KEY_CUSTOM_SEARCH_ENGINES, emptySet()) ?: emptySet()
|
||||
|
||||
val parser = SearchEngineParser()
|
||||
|
||||
return ids.mapNotNull { id ->
|
||||
val xml = preferences.getString(id, null)
|
||||
parser.loadSafely(id, xml?.byteInputStream()?.buffered())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SearchEngineParser.loadSafely(id: String, stream: BufferedInputStream?): SearchEngine? {
|
||||
return try {
|
||||
stream?.let { load(id, it).migrate() }
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
} catch (e: XmlPullParserException) {
|
||||
null
|
||||
}
|
||||
}
|
@ -1,129 +0,0 @@
|
||||
/* 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.searchengine
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import mozilla.components.browser.icons.IconRequest
|
||||
import mozilla.components.browser.search.SearchEngine
|
||||
import mozilla.components.browser.search.SearchEngineParser
|
||||
import mozilla.components.browser.search.provider.SearchEngineList
|
||||
import mozilla.components.browser.search.provider.SearchEngineProvider
|
||||
import mozilla.components.support.ktx.android.content.PreferencesHolder
|
||||
import mozilla.components.support.ktx.android.content.stringSetPreference
|
||||
import org.mozilla.fenix.ext.components
|
||||
|
||||
/**
|
||||
* SearchEngineProvider implementation to load user entered custom search engines.
|
||||
*/
|
||||
class CustomSearchEngineProvider : SearchEngineProvider {
|
||||
override suspend fun loadSearchEngines(context: Context): SearchEngineList {
|
||||
return SearchEngineList(CustomSearchEngineStore.loadCustomSearchEngines(context), null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Object to handle storing custom search engines
|
||||
*/
|
||||
object CustomSearchEngineStore {
|
||||
class EngineNameAlreadyExists : Exception()
|
||||
|
||||
/**
|
||||
* Add a search engine to the store.
|
||||
* @param context [Context] used for various Android interactions.
|
||||
* @param engineName The name of the search engine
|
||||
* @param searchQuery The templated search string for the search engine
|
||||
* @throws EngineNameAlreadyExists if you try to add a search engine that already exists
|
||||
*/
|
||||
suspend fun addSearchEngine(context: Context, engineName: String, searchQuery: String) {
|
||||
val storage = engineStorage(context)
|
||||
if (storage.customSearchEngineIds.contains(engineName)) { throw EngineNameAlreadyExists() }
|
||||
|
||||
val icon = context.components.core.icons.loadIcon(IconRequest(searchQuery)).await()
|
||||
val searchEngineXml = SearchEngineWriter.buildSearchEngineXML(engineName, searchQuery, icon.bitmap)
|
||||
val engines = storage.customSearchEngineIds.toMutableSet()
|
||||
engines.add(engineName)
|
||||
storage.customSearchEngineIds = engines
|
||||
storage[engineName] = searchEngineXml
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing search engine.
|
||||
* To prevent duplicate search engines we want to remove the old engine before adding the new one
|
||||
* @param context [Context] used for various Android interactions.
|
||||
* @param oldEngineName the name of the engine you want to replace
|
||||
* @param newEngineName the name of the engine you want to save
|
||||
* @param searchQuery The templated search string for the search engine
|
||||
*/
|
||||
suspend fun updateSearchEngine(
|
||||
context: Context,
|
||||
oldEngineName: String,
|
||||
newEngineName: String,
|
||||
searchQuery: String
|
||||
) {
|
||||
removeSearchEngine(context, oldEngineName)
|
||||
addSearchEngine(context, newEngineName, searchQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a search engine from the store
|
||||
* @param context [Context] used for various Android interactions.
|
||||
* @param engineId the id of the engine you want to remove
|
||||
*/
|
||||
fun removeSearchEngine(context: Context, engineId: String) {
|
||||
val storage = engineStorage(context)
|
||||
val customEngines = storage.customSearchEngineIds
|
||||
storage.customSearchEngineIds = customEngines.filterNot { it == engineId }.toSet()
|
||||
storage[engineId] = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the store to see if it contains a search engine
|
||||
* @param context [Context] used for various Android interactions.
|
||||
* @param engineId The name of the engine to check
|
||||
*/
|
||||
fun isCustomSearchEngine(context: Context, engineId: String): Boolean {
|
||||
val storage = engineStorage(context)
|
||||
return storage.customSearchEngineIds.contains(engineId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a list of [SearchEngine] from the store
|
||||
* @param context [Context] used for various Android interactions.
|
||||
*/
|
||||
fun loadCustomSearchEngines(context: Context): List<SearchEngine> {
|
||||
val storage = engineStorage(context)
|
||||
val parser = SearchEngineParser()
|
||||
val engines = storage.customSearchEngineIds
|
||||
|
||||
return engines.mapNotNull {
|
||||
val engineXml = storage[it] ?: return@mapNotNull null
|
||||
val engineInputStream = engineXml.byteInputStream().buffered()
|
||||
parser.load(it, engineInputStream)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a helper object to help interact with [SharedPreferences]
|
||||
* @param context [Context] used for various Android interactions.
|
||||
*/
|
||||
private fun engineStorage(context: Context) = object : PreferencesHolder {
|
||||
override val preferences: SharedPreferences
|
||||
get() = context.getSharedPreferences(PREF_FILE_SEARCH_ENGINES, Context.MODE_PRIVATE)
|
||||
|
||||
var customSearchEngineIds by stringSetPreference(PREF_KEY_CUSTOM_SEARCH_ENGINES, emptySet())
|
||||
|
||||
operator fun get(engineId: String): String? {
|
||||
return preferences.getString(engineId, null)
|
||||
}
|
||||
|
||||
operator fun set(engineId: String, value: String?) {
|
||||
preferences.edit().putString(engineId, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private const val PREF_KEY_CUSTOM_SEARCH_ENGINES = "pref_custom_search_engines"
|
||||
const val PREF_FILE_SEARCH_ENGINES = "custom-search-engines"
|
||||
}
|
@ -1,84 +0,0 @@
|
||||
/* 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.searchengine
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import org.w3c.dom.Document
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.StringWriter
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import javax.xml.parsers.ParserConfigurationException
|
||||
import javax.xml.transform.OutputKeys
|
||||
import javax.xml.transform.TransformerConfigurationException
|
||||
import javax.xml.transform.TransformerException
|
||||
import javax.xml.transform.TransformerFactory
|
||||
import javax.xml.transform.dom.DOMSource
|
||||
import javax.xml.transform.stream.StreamResult
|
||||
|
||||
private const val BITMAP_COMPRESS_QUALITY = 100
|
||||
private fun Bitmap.toBase64(): String {
|
||||
val stream = ByteArrayOutputStream()
|
||||
compress(Bitmap.CompressFormat.PNG, BITMAP_COMPRESS_QUALITY, stream)
|
||||
val encodedImage = Base64.encodeToString(stream.toByteArray(), Base64.DEFAULT)
|
||||
return "data:image/png;base64,$encodedImage"
|
||||
}
|
||||
|
||||
class SearchEngineWriter {
|
||||
companion object {
|
||||
private const val LOG_TAG = "SearchEngineWriter"
|
||||
|
||||
fun buildSearchEngineXML(engineName: String, searchQuery: String, iconBitmap: Bitmap): String? {
|
||||
try {
|
||||
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
|
||||
val rootElement = document!!.createElement("OpenSearchDescription")
|
||||
rootElement.setAttribute("xmlns", "http://a9.com/-/spec/opensearch/1.1/")
|
||||
document.appendChild(rootElement)
|
||||
|
||||
val shortNameElement = document.createElement("ShortName")
|
||||
shortNameElement.textContent = engineName
|
||||
rootElement.appendChild(shortNameElement)
|
||||
|
||||
val imageElement = document.createElement("Image")
|
||||
imageElement.setAttribute("width", "16")
|
||||
imageElement.setAttribute("height", "16")
|
||||
imageElement.textContent = iconBitmap.toBase64()
|
||||
rootElement.appendChild(imageElement)
|
||||
|
||||
val descriptionElement = document.createElement("Description")
|
||||
descriptionElement.textContent = engineName
|
||||
rootElement.appendChild(descriptionElement)
|
||||
|
||||
val urlElement = document.createElement("Url")
|
||||
urlElement.setAttribute("type", "text/html")
|
||||
|
||||
val templateSearchString = searchQuery.replace("%s", "{searchTerms}")
|
||||
urlElement.setAttribute("template", templateSearchString)
|
||||
rootElement.appendChild(urlElement)
|
||||
|
||||
return xmlToString(document)
|
||||
} catch (e: ParserConfigurationException) {
|
||||
Log.e(LOG_TAG, "Couldn't create new Document for building search engine XML", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun xmlToString(doc: Document): String? {
|
||||
val writer = StringWriter()
|
||||
try {
|
||||
val tf = TransformerFactory.newInstance().newTransformer()
|
||||
tf.setOutputProperty(OutputKeys.ENCODING, "UTF-8")
|
||||
tf.transform(DOMSource(doc), StreamResult(writer))
|
||||
} catch (e: TransformerConfigurationException) {
|
||||
return null
|
||||
} catch (e: TransformerException) {
|
||||
return null
|
||||
}
|
||||
|
||||
return writer.toString()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
/* 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.toolbar
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import mozilla.components.concept.menu.candidate.DividerMenuCandidate
|
||||
import mozilla.components.concept.menu.candidate.MenuCandidate
|
||||
import mozilla.components.ui.tabcounter.TabCounterMenu
|
||||
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
|
||||
|
||||
class FenixTabCounterMenu(
|
||||
context: Context,
|
||||
onItemTapped: (Item) -> Unit,
|
||||
iconColor: Int? = null
|
||||
) : TabCounterMenu(context, onItemTapped, iconColor) {
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun menuItems(showOnly: BrowsingMode): List<MenuCandidate> {
|
||||
return when (showOnly) {
|
||||
BrowsingMode.Normal -> listOf(newTabItem)
|
||||
BrowsingMode.Private -> listOf(newPrivateTabItem)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun menuItems(toolbarPosition: ToolbarPosition): List<MenuCandidate> {
|
||||
val items = listOf(
|
||||
newTabItem,
|
||||
newPrivateTabItem,
|
||||
DividerMenuCandidate(),
|
||||
closeTabItem
|
||||
)
|
||||
|
||||
return when (toolbarPosition) {
|
||||
ToolbarPosition.BOTTOM -> items.reversed()
|
||||
ToolbarPosition.TOP -> items
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the displayed menu items.
|
||||
* @param showOnly Show only the new tab item corresponding to the given [BrowsingMode].
|
||||
*/
|
||||
fun updateMenu(showOnly: BrowsingMode) {
|
||||
val items = menuItems(showOnly)
|
||||
|
||||
menuController.submitList(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the displayed menu items.
|
||||
* @param toolbarPosition Return a list that is ordered based on the given [ToolbarPosition].
|
||||
*/
|
||||
fun updateMenu(toolbarPosition: ToolbarPosition) {
|
||||
menuController.submitList(menuItems(toolbarPosition))
|
||||
}
|
||||
}
|
@ -1,271 +0,0 @@
|
||||
/* 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.toolbar
|
||||
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.core.view.updatePadding
|
||||
import kotlinx.android.synthetic.main.mozac_ui_tabcounter_layout.view.*
|
||||
import org.mozilla.fenix.R
|
||||
import java.text.NumberFormat
|
||||
|
||||
class TabCounter @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyle: Int = 0
|
||||
) : RelativeLayout(context, attrs, defStyle) {
|
||||
|
||||
private val animationSet: AnimatorSet
|
||||
|
||||
init {
|
||||
val inflater = LayoutInflater.from(context)
|
||||
inflater.inflate(R.layout.mozac_ui_tabcounter_layout, this)
|
||||
|
||||
// This is needed because without this counter box will be empty.
|
||||
setCount(INTERNAL_COUNT)
|
||||
|
||||
animationSet = createAnimatorSet()
|
||||
}
|
||||
|
||||
private fun updateContentDescription(count: Int) {
|
||||
counter_root.contentDescription = if (count == 1) {
|
||||
context?.getString(R.string.open_tab_tray_single)
|
||||
} else {
|
||||
String.format(context.getString(R.string.open_tab_tray_plural), count.toString())
|
||||
}
|
||||
}
|
||||
|
||||
fun setCountWithAnimation(count: Int) {
|
||||
setCount(count)
|
||||
|
||||
// No need to animate on these cases.
|
||||
when {
|
||||
INTERNAL_COUNT == 0 -> return // Initial state.
|
||||
INTERNAL_COUNT == count -> return // There isn't any tab added or removed.
|
||||
INTERNAL_COUNT > MAX_VISIBLE_TABS -> return // There are still over MAX_VISIBLE_TABS tabs open.
|
||||
}
|
||||
|
||||
// Cancel previous animations if necessary.
|
||||
if (animationSet.isRunning) {
|
||||
animationSet.cancel()
|
||||
}
|
||||
// Trigger animations.
|
||||
animationSet.start()
|
||||
}
|
||||
|
||||
fun setCount(count: Int) {
|
||||
updateContentDescription(count)
|
||||
adjustTextSize(count)
|
||||
counter_text.text = formatForDisplay(count)
|
||||
INTERNAL_COUNT = count
|
||||
}
|
||||
|
||||
private fun createAnimatorSet(): AnimatorSet {
|
||||
val animatorSet = AnimatorSet()
|
||||
createBoxAnimatorSet(animatorSet)
|
||||
createTextAnimatorSet(animatorSet)
|
||||
return animatorSet
|
||||
}
|
||||
|
||||
private fun createBoxAnimatorSet(animatorSet: AnimatorSet) {
|
||||
// The first animator, fadeout in 33 ms (49~51, 2 frames).
|
||||
val fadeOut = ObjectAnimator.ofFloat(
|
||||
counter_box, "alpha",
|
||||
ANIM_BOX_FADEOUT_FROM, ANIM_BOX_FADEOUT_TO
|
||||
).setDuration(ANIM_BOX_FADEOUT_DURATION)
|
||||
|
||||
// Move up on y-axis, from 0.0 to -5.3 in 50ms, with fadeOut (49~52, 3 frames).
|
||||
val moveUp1 = ObjectAnimator.ofFloat(
|
||||
counter_box, "translationY",
|
||||
ANIM_BOX_MOVEUP1_TO, ANIM_BOX_MOVEUP1_FROM
|
||||
).setDuration(ANIM_BOX_MOVEUP1_DURATION)
|
||||
|
||||
// Move down on y-axis, from -5.3 to -1.0 in 116ms, after moveUp1 (52~59, 7 frames).
|
||||
val moveDown2 = ObjectAnimator.ofFloat(
|
||||
counter_box, "translationY",
|
||||
ANIM_BOX_MOVEDOWN2_FROM, ANIM_BOX_MOVEDOWN2_TO
|
||||
).setDuration(ANIM_BOX_MOVEDOWN2_DURATION)
|
||||
|
||||
// FadeIn in 66ms, with moveDown2 (52~56, 4 frames).
|
||||
val fadeIn = ObjectAnimator.ofFloat(
|
||||
counter_box, "alpha",
|
||||
ANIM_BOX_FADEIN_FROM, ANIM_BOX_FADEIN_TO
|
||||
).setDuration(ANIM_BOX_FADEIN_DURATION)
|
||||
|
||||
// Move down on y-axis, from -1.0 to 2.7 in 116ms, after moveDown2 (59~66, 7 frames).
|
||||
val moveDown3 = ObjectAnimator.ofFloat(
|
||||
counter_box, "translationY",
|
||||
ANIM_BOX_MOVEDOWN3_FROM, ANIM_BOX_MOVEDOWN3_TO
|
||||
).setDuration(ANIM_BOX_MOVEDOWN3_DURATION)
|
||||
|
||||
// Move up on y-axis, from 2.7 to 0 in 133ms, after moveDown3 (66~74, 8 frames).
|
||||
val moveUp4 = ObjectAnimator.ofFloat(
|
||||
counter_box, "translationY",
|
||||
ANIM_BOX_MOVEDOWN4_FROM, ANIM_BOX_MOVEDOWN4_TO
|
||||
).setDuration(ANIM_BOX_MOVEDOWN4_DURATION)
|
||||
|
||||
// Scale up height from 2% to 105% in 100ms, after moveUp1 and delay 16ms (53~59, 6 frames).
|
||||
val scaleUp1 = ObjectAnimator.ofFloat(
|
||||
counter_box, "scaleY",
|
||||
ANIM_BOX_SCALEUP1_FROM, ANIM_BOX_SCALEUP1_TO
|
||||
).setDuration(ANIM_BOX_SCALEUP1_DURATION)
|
||||
scaleUp1.startDelay = ANIM_BOX_SCALEUP1_DELAY // delay 1 frame after moveUp1
|
||||
|
||||
// Scale down height from 105% to 99% in 116ms, after scaleUp1 (59~66, 7 frames).
|
||||
val scaleDown2 = ObjectAnimator.ofFloat(
|
||||
counter_box, "scaleY",
|
||||
ANIM_BOX_SCALEDOWN2_FROM, ANIM_BOX_SCALEDOWN2_TO
|
||||
).setDuration(ANIM_BOX_SCALEDOWN2_DURATION)
|
||||
|
||||
// Scale up height from 99% to 100% in 133ms, after scaleDown2 (66~74, 8 frames).
|
||||
val scaleUp3 = ObjectAnimator.ofFloat(
|
||||
counter_box, "scaleY",
|
||||
ANIM_BOX_SCALEUP3_FROM, ANIM_BOX_SCALEUP3_TO
|
||||
).setDuration(ANIM_BOX_SCALEUP3_DURATION)
|
||||
|
||||
animatorSet.play(fadeOut).with(moveUp1)
|
||||
animatorSet.play(moveUp1).before(moveDown2)
|
||||
animatorSet.play(moveDown2).with(fadeIn)
|
||||
animatorSet.play(moveDown2).before(moveDown3)
|
||||
animatorSet.play(moveDown3).before(moveUp4)
|
||||
|
||||
animatorSet.play(moveUp1).before(scaleUp1)
|
||||
animatorSet.play(scaleUp1).before(scaleDown2)
|
||||
animatorSet.play(scaleDown2).before(scaleUp3)
|
||||
}
|
||||
|
||||
private fun createTextAnimatorSet(animatorSet: AnimatorSet) {
|
||||
val firstAnimator = animatorSet.childAnimations[0]
|
||||
|
||||
// Fadeout in 100ms, with firstAnimator (49~51, 2 frames).
|
||||
val fadeOut = ObjectAnimator.ofFloat(
|
||||
counter_text, "alpha",
|
||||
ANIM_TEXT_FADEOUT_FROM, ANIM_TEXT_FADEOUT_TO
|
||||
).setDuration(ANIM_TEXT_FADEOUT_DURATION)
|
||||
|
||||
// FadeIn in 66 ms, after fadeOut with delay 96ms (57~61, 4 frames).
|
||||
val fadeIn = ObjectAnimator.ofFloat(
|
||||
counter_text, "alpha",
|
||||
ANIM_TEXT_FADEIN_FROM, ANIM_TEXT_FADEIN_TO
|
||||
).setDuration(ANIM_TEXT_FADEIN_DURATION)
|
||||
fadeIn.startDelay = (ANIM_TEXT_FADEIN_DELAY) // delay 6 frames after fadeOut
|
||||
|
||||
// Move down on y-axis, from 0 to 4.4 in 66ms, with fadeIn (57~61, 4 frames).
|
||||
val moveDown = ObjectAnimator.ofFloat(
|
||||
counter_text, "translationY",
|
||||
ANIM_TEXT_MOVEDOWN_FROM, ANIM_TEXT_MOVEDOWN_TO
|
||||
).setDuration(ANIM_TEXT_MOVEDOWN_DURATION)
|
||||
moveDown.startDelay = (ANIM_TEXT_MOVEDOWN_DELAY) // delay 6 frames after fadeOut
|
||||
|
||||
// Move up on y-axis, from 0 to 4.4 in 66ms, after moveDown (61~69, 8 frames).
|
||||
val moveUp = ObjectAnimator.ofFloat(
|
||||
counter_text, "translationY",
|
||||
ANIM_TEXT_MOVEUP_FROM, ANIM_TEXT_MOVEUP_TO
|
||||
).setDuration(ANIM_TEXT_MOVEUP_DURATION)
|
||||
|
||||
animatorSet.play(firstAnimator).with(fadeOut)
|
||||
animatorSet.play(fadeOut).before(fadeIn)
|
||||
animatorSet.play(fadeIn).with(moveDown)
|
||||
animatorSet.play(moveDown).before(moveUp)
|
||||
}
|
||||
|
||||
private fun formatForDisplay(count: Int): String {
|
||||
return if (count > MAX_VISIBLE_TABS) {
|
||||
counter_text.updatePadding(bottom = INFINITE_CHAR_PADDING_BOTTOM)
|
||||
SO_MANY_TABS_OPEN
|
||||
} else NumberFormat.getInstance().format(count.toLong())
|
||||
}
|
||||
|
||||
private fun adjustTextSize(newCount: Int) {
|
||||
val newRatio = if (newCount in TWO_DIGITS_TAB_COUNT_THRESHOLD..MAX_VISIBLE_TABS) {
|
||||
TWO_DIGITS_SIZE_RATIO
|
||||
} else {
|
||||
ONE_DIGIT_SIZE_RATIO
|
||||
}
|
||||
|
||||
val counterBoxWidth =
|
||||
context.resources.getDimensionPixelSize(R.dimen.tab_counter_box_width_height)
|
||||
val textSize = newRatio * counterBoxWidth
|
||||
counter_text.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||
counter_text.setTypeface(null, Typeface.BOLD)
|
||||
counter_text.setPadding(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal var INTERNAL_COUNT = 0
|
||||
|
||||
internal const val MAX_VISIBLE_TABS = 99
|
||||
|
||||
internal const val SO_MANY_TABS_OPEN = "∞"
|
||||
|
||||
internal const val INFINITE_CHAR_PADDING_BOTTOM = 6
|
||||
|
||||
internal const val ONE_DIGIT_SIZE_RATIO = 0.5f
|
||||
internal const val TWO_DIGITS_SIZE_RATIO = 0.4f
|
||||
internal const val TWO_DIGITS_TAB_COUNT_THRESHOLD = 10
|
||||
|
||||
// createBoxAnimatorSet
|
||||
private const val ANIM_BOX_FADEOUT_FROM = 1.0f
|
||||
private const val ANIM_BOX_FADEOUT_TO = 0.0f
|
||||
private const val ANIM_BOX_FADEOUT_DURATION = 33L
|
||||
|
||||
private const val ANIM_BOX_MOVEUP1_FROM = 0.0f
|
||||
private const val ANIM_BOX_MOVEUP1_TO = -5.3f
|
||||
private const val ANIM_BOX_MOVEUP1_DURATION = 50L
|
||||
|
||||
private const val ANIM_BOX_MOVEDOWN2_FROM = -5.3f
|
||||
private const val ANIM_BOX_MOVEDOWN2_TO = -1.0f
|
||||
private const val ANIM_BOX_MOVEDOWN2_DURATION = 167L
|
||||
|
||||
private const val ANIM_BOX_FADEIN_FROM = 0.01f
|
||||
private const val ANIM_BOX_FADEIN_TO = 1.0f
|
||||
private const val ANIM_BOX_FADEIN_DURATION = 66L
|
||||
private const val ANIM_BOX_MOVEDOWN3_FROM = -1.0f
|
||||
private const val ANIM_BOX_MOVEDOWN3_TO = 2.7f
|
||||
private const val ANIM_BOX_MOVEDOWN3_DURATION = 116L
|
||||
|
||||
private const val ANIM_BOX_MOVEDOWN4_FROM = 2.7f
|
||||
private const val ANIM_BOX_MOVEDOWN4_TO = 0.0f
|
||||
private const val ANIM_BOX_MOVEDOWN4_DURATION = 133L
|
||||
|
||||
private const val ANIM_BOX_SCALEUP1_FROM = 0.02f
|
||||
private const val ANIM_BOX_SCALEUP1_TO = 1.05f
|
||||
private const val ANIM_BOX_SCALEUP1_DURATION = 100L
|
||||
private const val ANIM_BOX_SCALEUP1_DELAY = 16L
|
||||
|
||||
private const val ANIM_BOX_SCALEDOWN2_FROM = 1.05f
|
||||
private const val ANIM_BOX_SCALEDOWN2_TO = 0.99f
|
||||
private const val ANIM_BOX_SCALEDOWN2_DURATION = 116L
|
||||
|
||||
private const val ANIM_BOX_SCALEUP3_FROM = 0.99f
|
||||
private const val ANIM_BOX_SCALEUP3_TO = 1.00f
|
||||
private const val ANIM_BOX_SCALEUP3_DURATION = 133L
|
||||
|
||||
// createTextAnimatorSet
|
||||
private const val ANIM_TEXT_FADEOUT_FROM = 1.0f
|
||||
private const val ANIM_TEXT_FADEOUT_TO = 0.0f
|
||||
private const val ANIM_TEXT_FADEOUT_DURATION = 33L
|
||||
|
||||
private const val ANIM_TEXT_FADEIN_FROM = 0.01f
|
||||
private const val ANIM_TEXT_FADEIN_TO = 1.0f
|
||||
private const val ANIM_TEXT_FADEIN_DURATION = 66L
|
||||
private const val ANIM_TEXT_FADEIN_DELAY = 16L * 6
|
||||
|
||||
private const val ANIM_TEXT_MOVEDOWN_FROM = 0.0f
|
||||
private const val ANIM_TEXT_MOVEDOWN_TO = 4.4f
|
||||
private const val ANIM_TEXT_MOVEDOWN_DURATION = 66L
|
||||
private const val ANIM_TEXT_MOVEDOWN_DELAY = 16L * 6
|
||||
|
||||
private const val ANIM_TEXT_MOVEUP_FROM = 4.4f
|
||||
private const val ANIM_TEXT_MOVEUP_TO = 0.0f
|
||||
private const val ANIM_TEXT_MOVEUP_DURATION = 66L
|
||||
}
|
||||
}
|
@ -1,121 +0,0 @@
|
||||
/* 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.toolbar
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import mozilla.components.browser.menu2.BrowserMenuController
|
||||
import mozilla.components.concept.menu.MenuController
|
||||
import mozilla.components.concept.menu.candidate.DividerMenuCandidate
|
||||
import mozilla.components.concept.menu.candidate.DrawableMenuIcon
|
||||
import mozilla.components.concept.menu.candidate.MenuCandidate
|
||||
import mozilla.components.concept.menu.candidate.TextMenuCandidate
|
||||
import mozilla.components.concept.menu.candidate.TextStyle
|
||||
import mozilla.components.support.ktx.android.content.getColorFromAttr
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
|
||||
import org.mozilla.fenix.components.metrics.Event
|
||||
import org.mozilla.fenix.components.metrics.MetricController
|
||||
|
||||
class TabCounterMenu(
|
||||
context: Context,
|
||||
private val metrics: MetricController,
|
||||
private val onItemTapped: (Item) -> Unit
|
||||
) {
|
||||
|
||||
sealed class Item {
|
||||
object CloseTab : Item()
|
||||
data class NewTab(val mode: BrowsingMode) : Item()
|
||||
}
|
||||
|
||||
val menuController: MenuController by lazy { BrowserMenuController() }
|
||||
|
||||
private val newTabItem: TextMenuCandidate
|
||||
private val newPrivateTabItem: TextMenuCandidate
|
||||
private val closeTabItem: TextMenuCandidate
|
||||
|
||||
init {
|
||||
val primaryTextColor = context.getColorFromAttr(R.attr.primaryText)
|
||||
val textStyle = TextStyle(color = primaryTextColor)
|
||||
|
||||
newTabItem = TextMenuCandidate(
|
||||
text = context.getString(R.string.browser_menu_new_tab),
|
||||
start = DrawableMenuIcon(
|
||||
context,
|
||||
R.drawable.ic_new,
|
||||
tint = primaryTextColor
|
||||
),
|
||||
textStyle = textStyle
|
||||
) {
|
||||
metrics.track(Event.TabCounterMenuItemTapped(Event.TabCounterMenuItemTapped.Item.NEW_TAB))
|
||||
onItemTapped(Item.NewTab(BrowsingMode.Normal))
|
||||
}
|
||||
|
||||
newPrivateTabItem = TextMenuCandidate(
|
||||
text = context.getString(R.string.home_screen_shortcut_open_new_private_tab_2),
|
||||
start = DrawableMenuIcon(
|
||||
context,
|
||||
R.drawable.ic_private_browsing,
|
||||
tint = primaryTextColor
|
||||
),
|
||||
textStyle = textStyle
|
||||
) {
|
||||
metrics.track(Event.TabCounterMenuItemTapped(Event.TabCounterMenuItemTapped.Item.NEW_PRIVATE_TAB))
|
||||
onItemTapped(Item.NewTab(BrowsingMode.Private))
|
||||
}
|
||||
|
||||
closeTabItem = TextMenuCandidate(
|
||||
text = context.getString(R.string.close_tab),
|
||||
start = DrawableMenuIcon(
|
||||
context,
|
||||
R.drawable.ic_close,
|
||||
tint = primaryTextColor
|
||||
),
|
||||
textStyle = textStyle
|
||||
) {
|
||||
metrics.track(Event.TabCounterMenuItemTapped(Event.TabCounterMenuItemTapped.Item.CLOSE_TAB))
|
||||
onItemTapped(Item.CloseTab)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun menuItems(showOnly: BrowsingMode): List<MenuCandidate> {
|
||||
return when (showOnly) {
|
||||
BrowsingMode.Normal -> listOf(newTabItem)
|
||||
BrowsingMode.Private -> listOf(newPrivateTabItem)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun menuItems(toolbarPosition: ToolbarPosition): List<MenuCandidate> {
|
||||
val items = listOf(
|
||||
newTabItem,
|
||||
newPrivateTabItem,
|
||||
DividerMenuCandidate(),
|
||||
closeTabItem
|
||||
)
|
||||
|
||||
return when (toolbarPosition) {
|
||||
ToolbarPosition.BOTTOM -> items.reversed()
|
||||
ToolbarPosition.TOP -> items
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the displayed menu items.
|
||||
* @param showOnly Show only the new tab item corresponding to the given [BrowsingMode].
|
||||
*/
|
||||
fun updateMenu(showOnly: BrowsingMode) {
|
||||
menuController.submitList(menuItems(showOnly))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the displayed menu items.
|
||||
* @param toolbarPosition Return a list that is ordered based on the given [ToolbarPosition].
|
||||
*/
|
||||
fun updateMenu(toolbarPosition: ToolbarPosition) {
|
||||
menuController.submitList(menuItems(toolbarPosition))
|
||||
}
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
/* 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.toolbar
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.map
|
||||
import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
|
||||
import mozilla.components.browser.state.selector.selectedTab
|
||||
import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.concept.toolbar.Toolbar
|
||||
import mozilla.components.lib.state.ext.flowScoped
|
||||
import mozilla.components.support.ktx.android.content.res.resolveAttribute
|
||||
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
|
||||
import org.mozilla.fenix.ext.components
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* A [Toolbar.Action] implementation that shows a [TabCounter].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TabCounterToolbarButton(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
private val onItemTapped: (TabCounterMenu.Item) -> Unit = {},
|
||||
private val showTabs: () -> Unit
|
||||
) : Toolbar.Action {
|
||||
|
||||
private var reference: WeakReference<TabCounter> = WeakReference<TabCounter>(null)
|
||||
|
||||
override fun createView(parent: ViewGroup): View {
|
||||
val store = parent.context.components.core.store
|
||||
val metrics = parent.context.components.analytics.metrics
|
||||
val settings = parent.context.components.settings
|
||||
|
||||
store.flowScoped(lifecycleOwner) { flow ->
|
||||
flow.map { state -> state.getNormalOrPrivateTabs(isPrivate(store)).size }
|
||||
.ifChanged()
|
||||
.collect { tabs -> updateCount(tabs) }
|
||||
}
|
||||
|
||||
val menu = TabCounterMenu(parent.context, metrics, onItemTapped)
|
||||
menu.updateMenu(settings.toolbarPosition)
|
||||
|
||||
val view = TabCounter(parent.context).apply {
|
||||
reference = WeakReference(this)
|
||||
setOnClickListener {
|
||||
showTabs.invoke()
|
||||
}
|
||||
|
||||
setOnLongClickListener {
|
||||
menu.menuController.show(anchor = it)
|
||||
true
|
||||
}
|
||||
|
||||
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View?) {
|
||||
setCount(store.state.getNormalOrPrivateTabs(isPrivate(store)).size)
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View?) { /* no-op */ }
|
||||
})
|
||||
}
|
||||
|
||||
// Set selectableItemBackgroundBorderless
|
||||
view.setBackgroundResource(parent.context.theme.resolveAttribute(
|
||||
android.R.attr.selectableItemBackgroundBorderless
|
||||
))
|
||||
return view
|
||||
}
|
||||
|
||||
override fun bind(view: View) = Unit
|
||||
|
||||
private fun updateCount(count: Int) {
|
||||
reference.get()?.setCountWithAnimation(count)
|
||||
}
|
||||
|
||||
private fun isPrivate(store: BrowserStore): Boolean {
|
||||
return store.state.selectedTab?.content?.private ?: false
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
/* 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.experiments
|
||||
|
||||
class Experiments {
|
||||
companion object {
|
||||
const val A_A_NIMBUS_VALIDATION = "fenix-nimbus-validation"
|
||||
const val BOOKMARK_ICON = "fenix-bookmark-list-icon"
|
||||
}
|
||||
}
|
||||
|
||||
class ExperimentBranch {
|
||||
companion object {
|
||||
const val TREATMENT = "treatment"
|
||||
const val CONTROL = "control"
|
||||
const val A1 = "A1"
|
||||
const val A2 = "A2"
|
||||
}
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
/* 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.ext
|
||||
|
||||
import mozilla.components.service.nimbus.NimbusApi
|
||||
import mozilla.components.support.base.log.logger.Logger
|
||||
import org.mozilla.fenix.FeatureFlags
|
||||
|
||||
/**
|
||||
* Gets the branch of the given `experimentId` and transforms it with given closure.
|
||||
*
|
||||
* If we're enrolled in the experiment, the transform is passed the branch id/slug as a `String`.
|
||||
*
|
||||
* If we're not enrolled in the experiment, or the experiment is not valid then the transform
|
||||
* is passed a `null`.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
fun <T> NimbusApi.withExperiment(experimentId: String, transform: (String?) -> T): T {
|
||||
val branch = if (FeatureFlags.nimbusExperiments) {
|
||||
try {
|
||||
getExperimentBranch(experimentId)
|
||||
} catch (e: Throwable) {
|
||||
Logger.error("Failed to getExperimentBranch($experimentId)", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return transform(branch)
|
||||
}
|
||||
|
||||
/**
|
||||
* The degenerate case of `withExperiment(String, (String?) -> T))`, with an identity transform.
|
||||
*
|
||||
* Short-hand for `mozilla.components.service.nimbus.NimbusApi.getExperimentBranch`.
|
||||
*/
|
||||
fun NimbusApi.withExperiment(experimentId: String) =
|
||||
this.withExperiment(experimentId, ::identity)
|
||||
|
||||
private fun <T> identity(value: T) = value
|
@ -1,146 +0,0 @@
|
||||
/* 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.library.bookmarks
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import mozilla.components.concept.storage.BookmarkNodeType
|
||||
import mozilla.components.support.ktx.android.content.getColorFromAttr
|
||||
import mozilla.components.support.ktx.android.content.getDrawableWithTint
|
||||
import mozilla.components.support.ktx.android.util.dpToPx
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.home.sessioncontrol.SwipeToDeleteCallback
|
||||
import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkNodeViewHolder
|
||||
import org.mozilla.fenix.library.bookmarks.viewholders.BookmarkSeparatorViewHolder
|
||||
|
||||
class BookmarkTouchHelper(interactor: BookmarkViewInteractor) :
|
||||
ItemTouchHelper(BookmarkTouchCallback(interactor))
|
||||
|
||||
class BookmarkTouchCallback(
|
||||
private val interactor: BookmarkViewInteractor
|
||||
) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
|
||||
|
||||
override fun getSwipeDirs(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int {
|
||||
// Swiping separators is currently not supported.
|
||||
if (viewHolder is BookmarkSeparatorViewHolder) {
|
||||
return 0
|
||||
}
|
||||
val item = (viewHolder as BookmarkNodeViewHolder).item
|
||||
return if (item?.inRoots() == true) {
|
||||
0
|
||||
} else {
|
||||
super.getSwipeDirs(recyclerView, viewHolder)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the bookmark when swiped.
|
||||
*/
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val item = (viewHolder as BookmarkNodeViewHolder).item
|
||||
item?.let {
|
||||
interactor.onDelete(setOf(it))
|
||||
// We need to notify the adapter of a change if we swipe a folder to prevent
|
||||
// visual bugs when cancelling deletion of a folder
|
||||
if (item.type == BookmarkNodeType.FOLDER) {
|
||||
viewHolder.bindingAdapter?.notifyItemChanged(viewHolder.bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChildDraw(
|
||||
c: Canvas,
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
dX: Float,
|
||||
dY: Float,
|
||||
actionState: Int,
|
||||
isCurrentlyActive: Boolean
|
||||
) {
|
||||
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
|
||||
val icon = recyclerView.context.getDrawableWithTint(
|
||||
R.drawable.ic_delete,
|
||||
recyclerView.context.getColorFromAttr(R.attr.destructive)
|
||||
)!!
|
||||
val background = AppCompatResources.getDrawable(
|
||||
recyclerView.context,
|
||||
R.drawable.swipe_delete_background
|
||||
)!!
|
||||
val margin =
|
||||
SwipeToDeleteCallback.MARGIN.dpToPx(recyclerView.resources.displayMetrics)
|
||||
val cellHeight = viewHolder.itemView.bottom - viewHolder.itemView.top
|
||||
val iconTop = viewHolder.itemView.top + (cellHeight - icon.intrinsicHeight) / 2
|
||||
val iconBottom = iconTop + icon.intrinsicHeight
|
||||
|
||||
when {
|
||||
dX > 0 -> { // Swiping to the right
|
||||
val backgroundBounds = Rect(
|
||||
viewHolder.itemView.left, viewHolder.itemView.top,
|
||||
(viewHolder.itemView.left + dX).toInt() + SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET,
|
||||
viewHolder.itemView.bottom
|
||||
)
|
||||
val iconLeft = viewHolder.itemView.left + margin
|
||||
val iconRight = viewHolder.itemView.left + margin + icon.intrinsicWidth
|
||||
val iconBounds = Rect(iconLeft, iconTop, iconRight, iconBottom)
|
||||
|
||||
setBounds(background, backgroundBounds, icon, iconBounds)
|
||||
draw(background, icon, c)
|
||||
}
|
||||
dX < 0 -> { // Swiping to the left
|
||||
val backgroundBounds = Rect(
|
||||
(viewHolder.itemView.right + dX).toInt() - SwipeToDeleteCallback.BACKGROUND_CORNER_OFFSET,
|
||||
viewHolder.itemView.top, viewHolder.itemView.right, viewHolder.itemView.bottom
|
||||
)
|
||||
val iconLeft = viewHolder.itemView.right - margin - icon.intrinsicWidth
|
||||
val iconRight = viewHolder.itemView.right - margin
|
||||
val iconBounds = Rect(iconLeft, iconTop, iconRight, iconBottom)
|
||||
|
||||
setBounds(background, backgroundBounds, icon, iconBounds)
|
||||
draw(background, icon, c)
|
||||
}
|
||||
else -> { // View not swiped
|
||||
val bounds = Rect(0, 0, 0, 0)
|
||||
setBounds(background, bounds, icon, bounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean = false
|
||||
|
||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
super.onSelectedChanged(viewHolder, actionState)
|
||||
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
|
||||
interactor.onStartSwipingItem()
|
||||
} else {
|
||||
interactor.onStopSwipingItem()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setBounds(
|
||||
background: Drawable,
|
||||
backgroundBounds: Rect,
|
||||
icon: Drawable,
|
||||
iconBounds: Rect
|
||||
) {
|
||||
background.bounds = backgroundBounds
|
||||
icon.bounds = iconBounds
|
||||
}
|
||||
|
||||
private fun draw(background: Drawable, icon: Drawable, c: Canvas) {
|
||||
background.draw(c)
|
||||
icon.draw(c)
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue