For #16952 - Https-only mode support.
Default will be disabled with users having the possibility to enable this from a new setting in the `Privacy and security` section. If enabled then by default this force https for all tabs with the option for users to switch to forcing https only on private tabs.upstream-sync
parent
541b2be184
commit
46d757ab54
@ -0,0 +1,119 @@
|
||||
/* 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.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.view.children
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.mozilla.fenix.BrowserDirection
|
||||
import org.mozilla.fenix.HomeActivity
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.databinding.SettingsHttpsOnlyBinding
|
||||
import org.mozilla.fenix.ext.components
|
||||
import org.mozilla.fenix.ext.settings
|
||||
|
||||
// To be replaced with a SUMO link when available. This is the desktop link.
|
||||
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1758066.
|
||||
private const val SUMO_URL = "https://support.mozilla.org/en-US/kb/https-only-prefs"
|
||||
|
||||
/**
|
||||
* Lets the user customize HTTPS-only mode.
|
||||
*/
|
||||
class HttpsOnlyFragment : Fragment() {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val binding = SettingsHttpsOnlyBinding.inflate(inflater)
|
||||
|
||||
val summary = requireContext().getString(R.string.preferences_https_only_summary)
|
||||
val learnMore = requireContext().getString(R.string.preferences_http_only_learn_more)
|
||||
|
||||
binding.httpsOnlySummary.run {
|
||||
text = combineTextWithLink(summary, learnMore).apply {
|
||||
setActionToUrlClick(this)
|
||||
}
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
|
||||
binding.httpsOnlySwitch.run {
|
||||
isChecked = context.settings().shouldUseHttpsOnly
|
||||
setHttpsModes(binding, isChecked)
|
||||
|
||||
setOnCheckedChangeListener { _, isHttpsOnlyEnabled ->
|
||||
context.settings().shouldUseHttpsOnly = isHttpsOnlyEnabled
|
||||
setHttpsModes(binding, isHttpsOnlyEnabled)
|
||||
updateEngineHttpsOnlyMode()
|
||||
}
|
||||
}
|
||||
|
||||
// Since the http-only modes are in a RadioGroup we only need one listener to know of all their changes.
|
||||
binding.httpsOnlyAllTabs.setOnCheckedChangeListener { _, _ ->
|
||||
updateEngineHttpsOnlyMode()
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun setHttpsModes(binding: SettingsHttpsOnlyBinding, isHttpsOnlyEnabled: Boolean) {
|
||||
if (!isHttpsOnlyEnabled) {
|
||||
binding.httpsOnlyModes.apply {
|
||||
clearCheck()
|
||||
children.forEach { it.isEnabled = false }
|
||||
}
|
||||
} else {
|
||||
binding.httpsOnlyModes.children.forEach { it.isEnabled = true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateEngineHttpsOnlyMode() {
|
||||
requireContext().components.core.engine.settings.httpsOnlyMode =
|
||||
requireContext().settings().getHttpsOnlyMode()
|
||||
}
|
||||
|
||||
private fun combineTextWithLink(
|
||||
text: String,
|
||||
linkTitle: String
|
||||
): SpannableStringBuilder {
|
||||
val rawTextWithLink = HtmlCompat.fromHtml(
|
||||
"$text <a href=\"\">$linkTitle</a>",
|
||||
HtmlCompat.FROM_HTML_MODE_COMPACT
|
||||
)
|
||||
|
||||
return SpannableStringBuilder(rawTextWithLink)
|
||||
}
|
||||
|
||||
private fun setActionToUrlClick(
|
||||
spannableStringBuilder: SpannableStringBuilder,
|
||||
) {
|
||||
val link = spannableStringBuilder.getSpans<URLSpan>()[0]
|
||||
val linkStart = spannableStringBuilder.getSpanStart(link)
|
||||
val linkEnd = spannableStringBuilder.getSpanEnd(link)
|
||||
val linkFlags = spannableStringBuilder.getSpanFlags(link)
|
||||
val linkClickListener: ClickableSpan = object : ClickableSpan() {
|
||||
override fun onClick(view: View) {
|
||||
view.setOnClickListener {
|
||||
(activity as HomeActivity).openToBrowserAndLoad(
|
||||
searchTermOrURL = SUMO_URL,
|
||||
newTab = true,
|
||||
from = BrowserDirection.FromHttpsOnlyMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
spannableStringBuilder.setSpan(linkClickListener, linkStart, linkEnd, linkFlags)
|
||||
spannableStringBuilder.removeSpan(link)
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
/* 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.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.widget.AppCompatRadioButton
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ext.settings
|
||||
|
||||
/**
|
||||
* [AppCompatRadioButton] backed by a boolean `SharedPreference`.
|
||||
*
|
||||
* Whenever this button is initialized it will attempt to set itself as checked with the
|
||||
* current value of `R.styleable.PreferenceBackedRadioButton_preferenceKey` defaulting to
|
||||
* `R.styleable.PreferenceBackedRadioButton_preferenceKeyDefaultValue` if there is no value set
|
||||
* for the indicated `SharedPreference`.
|
||||
*
|
||||
* Whenever the radio button is enabled or disabled this will be persisted in a `SharedPreference`
|
||||
* with the name indicated in `R.styleable.PreferenceBackedRadioButton_preferenceKey` .
|
||||
*/
|
||||
class PreferenceBackedRadioButton @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.radioButtonStyle,
|
||||
) : AppCompatRadioButton(context, attrs, defStyleAttr) {
|
||||
@VisibleForTesting
|
||||
internal var externalOnCheckedChangeListener: OnCheckedChangeListener? = null
|
||||
@VisibleForTesting
|
||||
internal var backingPreferenceName: String? = null
|
||||
@VisibleForTesting
|
||||
internal var backingPreferenceDefaultValue: Boolean = false
|
||||
|
||||
private val internalOnCheckedChangeListener = OnCheckedChangeListener { buttonView, isChecked ->
|
||||
backingPreferenceName?.let {
|
||||
context.settings().preferences.edit().putBoolean(it, isChecked).apply()
|
||||
}
|
||||
|
||||
externalOnCheckedChangeListener?.onCheckedChanged(buttonView, isChecked)
|
||||
}
|
||||
|
||||
init {
|
||||
context.withStyledAttributes(attrs, R.styleable.PreferenceBackedRadioButton, defStyleAttr, 0) {
|
||||
backingPreferenceName = this.getString(R.styleable.PreferenceBackedRadioButton_preferenceKey)
|
||||
backingPreferenceDefaultValue = getBoolean(
|
||||
R.styleable.PreferenceBackedRadioButton_preferenceKeyDefaultValue, false
|
||||
)
|
||||
}
|
||||
|
||||
isChecked = context.settings().preferences.getBoolean(backingPreferenceName, backingPreferenceDefaultValue)
|
||||
|
||||
super.setOnCheckedChangeListener(internalOnCheckedChangeListener)
|
||||
}
|
||||
|
||||
override fun setOnCheckedChangeListener(listener: OnCheckedChangeListener?) {
|
||||
externalOnCheckedChangeListener = listener
|
||||
}
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
super.setEnabled(enabled)
|
||||
|
||||
if (enabled) {
|
||||
isChecked = context.settings().preferences.getBoolean(backingPreferenceName, backingPreferenceDefaultValue)
|
||||
} else {
|
||||
context.settings().preferences.edit().remove(backingPreferenceName).apply()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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/. -->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/https_only_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:clickable="false"
|
||||
android:lineHeight="24.sp"
|
||||
android:text="@string/preferences_https_only_title"
|
||||
android:textAppearance="@style/ListItemTextStyle"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintBottom_toTopOf="@id/https_only_summary"
|
||||
app:layout_constraintEnd_toStartOf="@id/https_only_switch"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:targetApi="p" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/https_only_summary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:lineHeight="16.sp"
|
||||
android:textColor="?attr/textSecondary"
|
||||
android:textColorLink="?attr/textSecondary"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintBottom_toTopOf="@id/https_only_modes"
|
||||
app:layout_constraintEnd_toEndOf="@id/https_only_title"
|
||||
app:layout_constraintStart_toStartOf="@id/https_only_title"
|
||||
app:layout_constraintTop_toBottomOf="@id/https_only_title"
|
||||
tools:targetApi="p"
|
||||
tools:text="@string/preferences_https_only_summary" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/https_only_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="48dp"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:textColor="@color/state_list_text_color"
|
||||
android:textOff="@string/studies_off"
|
||||
android:textOn="@string/studies_on"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/https_only_title" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/https_only_modes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/https_only_summary">
|
||||
|
||||
<org.mozilla.fenix.settings.PreferenceBackedRadioButton
|
||||
android:id="@+id/https_only_all_tabs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:button="@null"
|
||||
android:drawablePadding="@dimen/radio_button_preference_drawable_padding"
|
||||
android:paddingStart="@dimen/radio_button_preference_horizontal"
|
||||
android:paddingTop="@dimen/radio_button_preference_vertical"
|
||||
android:paddingEnd="@dimen/radio_button_preference_horizontal"
|
||||
android:paddingBottom="@dimen/radio_button_preference_vertical"
|
||||
android:text="@string/preferences_https_only_in_all_tabs"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem"
|
||||
android:textSize="16sp"
|
||||
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle"
|
||||
app:preferenceKey="@string/pref_key_https_only_in_all_tabs"
|
||||
app:preferenceKeyDefaultValue="true" />
|
||||
|
||||
<org.mozilla.fenix.settings.PreferenceBackedRadioButton
|
||||
android:id="@+id/https_only_private_tabs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:button="@null"
|
||||
android:drawablePadding="@dimen/radio_button_preference_drawable_padding"
|
||||
android:paddingStart="@dimen/radio_button_preference_horizontal"
|
||||
android:paddingTop="@dimen/radio_button_preference_vertical"
|
||||
android:paddingEnd="@dimen/radio_button_preference_horizontal"
|
||||
android:paddingBottom="@dimen/radio_button_preference_vertical"
|
||||
android:text="@string/preferences_https_only_in_private_tabs"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem"
|
||||
android:textSize="16sp"
|
||||
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle"
|
||||
app:preferenceKey="@string/pref_key_https_only_in_private_tabs"
|
||||
app:preferenceKeyDefaultValue="false" />
|
||||
</RadioGroup>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -0,0 +1,147 @@
|
||||
/* 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.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences.Editor
|
||||
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import io.mockk.verifyOrder
|
||||
import mozilla.components.support.test.robolectric.testContext
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.ext.settings
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
import org.mozilla.fenix.utils.Settings
|
||||
import org.robolectric.Robolectric
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class PreferenceBackedRadioButtonTest {
|
||||
@Test
|
||||
fun `GIVEN a preference key is provided WHEN initialized THEN cache the value`() {
|
||||
val attributes = Robolectric.buildAttributeSet()
|
||||
.addAttribute(R.attr.preferenceKey, "test")
|
||||
.build()
|
||||
every { testContext.settings().preferences.getBoolean(any(), any()) } returns Random.nextBoolean()
|
||||
|
||||
val button = PreferenceBackedRadioButton(testContext, attributes)
|
||||
|
||||
assertEquals("test", button.backingPreferenceName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a default preference value is provided WHEN initialized THEN cache the value`() {
|
||||
val attributes = Robolectric.buildAttributeSet()
|
||||
.addAttribute(R.attr.preferenceKeyDefaultValue, "true")
|
||||
.build()
|
||||
every { testContext.settings().preferences.getBoolean(any(), any()) } returns Random.nextBoolean()
|
||||
|
||||
val button = PreferenceBackedRadioButton(testContext, attributes)
|
||||
|
||||
assertTrue(button.backingPreferenceDefaultValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a default preference value is not provided WHEN initialized THEN remember the default value as false`() {
|
||||
val attributes = Robolectric.buildAttributeSet().build()
|
||||
every { testContext.settings().preferences.getBoolean(any(), any()) } returns Random.nextBoolean()
|
||||
|
||||
val button = PreferenceBackedRadioButton(testContext, attributes)
|
||||
|
||||
assertFalse(button.backingPreferenceDefaultValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN the backing preference doesn't have a value set WHEN initialized THEN set if checked the default value`() {
|
||||
val attributes = Robolectric.buildAttributeSet()
|
||||
.addAttribute(R.attr.preferenceKeyDefaultValue, "true")
|
||||
.build()
|
||||
|
||||
mockkStatic("org.mozilla.fenix.ext.ContextKt") {
|
||||
every { any<Context>().settings() } returns Settings(testContext)
|
||||
|
||||
val button = PreferenceBackedRadioButton(testContext, attributes)
|
||||
|
||||
assertTrue(button.isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN there is no backing preference or default value set vaWHEN initialized THEN set if checked as false`() {
|
||||
val attributes = Robolectric.buildAttributeSet().build()
|
||||
|
||||
mockkStatic("org.mozilla.fenix.ext.ContextKt") {
|
||||
every { any<Context>().settings() } returns Settings(testContext)
|
||||
|
||||
val button = PreferenceBackedRadioButton(testContext, attributes)
|
||||
|
||||
assertFalse(button.isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN the backing preference does have a value set WHEN initialized THEN set if checked the value from the preference`() {
|
||||
val attributes = Robolectric.buildAttributeSet()
|
||||
.addAttribute(R.attr.preferenceKey, "test")
|
||||
.build()
|
||||
every { testContext.settings().preferences.getBoolean(eq("test"), any()) } returns true
|
||||
|
||||
val button = PreferenceBackedRadioButton(testContext, attributes)
|
||||
|
||||
assertTrue(button.isChecked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN a OnCheckedChangeListener is set THEN cache it internally`() {
|
||||
every { testContext.settings().preferences.getBoolean(any(), any()) } returns Random.nextBoolean()
|
||||
val button = PreferenceBackedRadioButton(testContext)
|
||||
val testListener: OnCheckedChangeListener = mockk()
|
||||
|
||||
button.setOnCheckedChangeListener(testListener)
|
||||
|
||||
assertSame(testListener, button.externalOnCheckedChangeListener)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a OnCheckedChangeListener is set WHEN the checked status changes THEN update the backing preference and then inform the listener`() {
|
||||
val editor: Editor = mockk(relaxed = true)
|
||||
every { testContext.settings().preferences.edit() } returns editor
|
||||
// set the button initially as not checked
|
||||
every { testContext.settings().preferences.getBoolean(any(), any()) } returns false
|
||||
val button = PreferenceBackedRadioButton(testContext)
|
||||
button.backingPreferenceName = "test"
|
||||
val testListener: OnCheckedChangeListener = mockk(relaxed = true)
|
||||
button.externalOnCheckedChangeListener = testListener
|
||||
|
||||
button.isChecked = true
|
||||
|
||||
verifyOrder {
|
||||
editor.putBoolean("test", true)
|
||||
testListener.onCheckedChanged(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN the button gets enabled THEN set isChecked based on the value from the backing preference`() {
|
||||
every { testContext.settings().preferences.getBoolean(any(), any()) } returns true
|
||||
val button = spyk(PreferenceBackedRadioButton(testContext))
|
||||
|
||||
button.isEnabled = true
|
||||
|
||||
verify(exactly = 1) { // first "isChecked" from init happens before we can count it
|
||||
button.isChecked = true
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue