Commit a861537a authored by ekager's avatar ekager
Browse files

For #7134 - Adds login selection to prompt feature

parent 3d471434
......@@ -8,17 +8,18 @@ import android.content.Context
import android.net.Uri
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.engine.gecko.GeckoEngineSession
import mozilla.components.concept.storage.Login
import mozilla.components.concept.engine.prompt.Choice
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.concept.engine.prompt.PromptRequest.MenuChoice
import mozilla.components.concept.engine.prompt.PromptRequest.MultipleChoice
import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.storage.Login
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.net.getFileName
import mozilla.components.support.ktx.kotlin.toDate
import org.mozilla.geckoview.AllowOrDeny
import org.mozilla.geckoview.Autocomplete
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSession.PromptDelegate
......@@ -29,7 +30,6 @@ import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.MON
import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.TIME
import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.WEEK
import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse
import org.mozilla.geckoview.Autocomplete
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
......@@ -106,19 +106,25 @@ internal class GeckoPromptDelegate(private val geckoEngineSession: GeckoEngineSe
prompt: PromptDelegate.AutocompleteRequest<Autocomplete.LoginSelectOption>
): GeckoResult<PromptResponse>? {
val geckoResult = GeckoResult<PromptResponse>()
val onConfirmSave: (Login) -> Unit = { login ->
val onConfirmSelect: (Login) -> Unit = { login ->
geckoResult.complete(prompt.confirm(Autocomplete.LoginSelectOption(login.toLoginEntry())))
}
val onDismiss: () -> Unit = {
geckoResult.complete(prompt.dismiss())
}
// Currently no-op will be addressed in https://github.com/mozilla-mobile/android-components/issues/7134
// Exactly one of `httpRealm` and `formSubmitURL` must be present to be a valid login entry.
val loginList = prompt.options.filter { option ->
option.value.formActionOrigin != null || option.value.httpRealm != null
}.map { option ->
option.value.toLogin()
}
geckoEngineSession.notifyObservers {
onPromptRequest(
PromptRequest.SelectLoginPrompt(
logins = listOf(),
onConfirm = onConfirmSave,
logins = loginList,
onConfirm = onConfirmSelect,
onDismiss = onDismiss
)
)
......
......@@ -8,17 +8,18 @@ import android.content.Context
import android.net.Uri
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.engine.gecko.GeckoEngineSession
import mozilla.components.concept.storage.Login
import mozilla.components.concept.engine.prompt.Choice
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.concept.engine.prompt.PromptRequest.MenuChoice
import mozilla.components.concept.engine.prompt.PromptRequest.MultipleChoice
import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.storage.Login
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.net.getFileName
import mozilla.components.support.ktx.kotlin.toDate
import org.mozilla.geckoview.AllowOrDeny
import org.mozilla.geckoview.Autocomplete
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSession.PromptDelegate
......@@ -29,7 +30,6 @@ import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.MON
import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.TIME
import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.WEEK
import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse
import org.mozilla.geckoview.Autocomplete
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
......@@ -106,19 +106,25 @@ internal class GeckoPromptDelegate(private val geckoEngineSession: GeckoEngineSe
prompt: PromptDelegate.AutocompleteRequest<Autocomplete.LoginSelectOption>
): GeckoResult<PromptResponse>? {
val geckoResult = GeckoResult<PromptResponse>()
val onConfirmSave: (Login) -> Unit = { login ->
val onConfirmSelect: (Login) -> Unit = { login ->
geckoResult.complete(prompt.confirm(Autocomplete.LoginSelectOption(login.toLoginEntry())))
}
val onDismiss: () -> Unit = {
geckoResult.complete(prompt.dismiss())
}
// Currently no-op will be addressed in https://github.com/mozilla-mobile/android-components/issues/7134
// Exactly one of `httpRealm` and `formSubmitURL` must be present to be a valid login entry.
val loginList = prompt.options.filter { option ->
option.value.formActionOrigin != null || option.value.httpRealm != null
}.map { option ->
option.value.toLogin()
}
geckoEngineSession.notifyObservers {
onPromptRequest(
PromptRequest.SelectLoginPrompt(
logins = listOf(),
onConfirm = onConfirmSave,
logins = loginList,
onConfirm = onConfirmSelect,
onDismiss = onDismiss
)
)
......
......@@ -12,6 +12,7 @@ import mozilla.components.concept.engine.prompt.Choice
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.concept.engine.prompt.PromptRequest.MultipleChoice
import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice
import mozilla.components.concept.storage.Login
import mozilla.components.support.ktx.kotlin.toDate
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
......@@ -25,9 +26,10 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.spy
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.spy
import org.mozilla.gecko.util.GeckoBundle
import org.mozilla.geckoview.Autocomplete
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.DATE
......@@ -43,6 +45,7 @@ import java.security.InvalidParameterException
import java.util.Calendar
import java.util.Calendar.YEAR
import java.util.Date
typealias GeckoChoice = GeckoSession.PromptDelegate.ChoicePrompt.Choice
typealias GECKO_AUTH_LEVEL = GeckoSession.PromptDelegate.AuthPrompt.AuthOptions.Level
typealias GECKO_PROMPT_CHOICE_TYPE = GeckoSession.PromptDelegate.ChoicePrompt.Type
......@@ -142,11 +145,11 @@ class GeckoPromptDelegateTest {
)
mockSession.register(
object : EngineSession.Observer {
override fun onPromptRequest(promptRequest: PromptRequest) {
promptRequestSingleChoice = promptRequest
}
})
object : EngineSession.Observer {
override fun onPromptRequest(promptRequest: PromptRequest) {
promptRequestSingleChoice = promptRequest
}
})
val geckoResult = gecko.onChoicePrompt(mock(), geckoPrompt)
geckoResult!!.accept {
......@@ -475,7 +478,8 @@ class GeckoPromptDelegateTest {
dateRequest = promptRequest
}
})
val geckoResult = promptDelegate.onDateTimePrompt(mock(), GeckoDateTimePrompt(type = DATETIME_LOCAL))
val geckoResult =
promptDelegate.onDateTimePrompt(mock(), GeckoDateTimePrompt(type = DATETIME_LOCAL))
geckoResult!!.accept {
confirmCalled = true
}
......@@ -618,6 +622,125 @@ class GeckoPromptDelegateTest {
)
}
@Test
fun `Calling onLoginSave must provide an SaveLoginPrompt PromptRequest`() {
val mockSession = GeckoEngineSession(runtime)
var onLoginSaved = false
var onDismissWasCalled = false
var loginSaveRequest: PromptRequest.SaveLoginPrompt = mock()
val promptDelegate = spy(GeckoPromptDelegate(mockSession))
mockSession.register(object : EngineSession.Observer {
override fun onPromptRequest(promptRequest: PromptRequest) {
loginSaveRequest = promptRequest as PromptRequest.SaveLoginPrompt
}
})
val login = createLogin()
val saveOption = Autocomplete.LoginSaveOption(login.toLoginEntry())
var geckoResult =
promptDelegate.onLoginSave(mock(), GeckoLoginSavePrompt(arrayOf(saveOption)))
geckoResult!!.accept {
onDismissWasCalled = true
}
loginSaveRequest.onDismiss()
assertTrue(onDismissWasCalled)
geckoResult = promptDelegate.onLoginSave(mock(), GeckoLoginSavePrompt(arrayOf(saveOption)))
geckoResult!!.accept {
onLoginSaved = true
}
loginSaveRequest.onConfirm(login)
assertTrue(onLoginSaved)
}
@Test
fun `Calling onLoginSelect must provide an SelectLoginPrompt PromptRequest`() {
val mockSession = GeckoEngineSession(runtime)
var onLoginSelected = false
var onDismissWasCalled = false
var loginSelectRequest: PromptRequest.SelectLoginPrompt = mock()
val promptDelegate = spy(GeckoPromptDelegate(mockSession))
mockSession.register(object : EngineSession.Observer {
override fun onPromptRequest(promptRequest: PromptRequest) {
loginSelectRequest = promptRequest as PromptRequest.SelectLoginPrompt
}
})
val login = createLogin()
val loginSelectOption = Autocomplete.LoginSelectOption(login.toLoginEntry())
val secondLogin = createLogin(username = "username2")
val secondLoginSelectOption = Autocomplete.LoginSelectOption(secondLogin.toLoginEntry())
var geckoResult =
promptDelegate.onLoginSelect(
mock(),
GeckoLoginSelectPrompt(arrayOf(loginSelectOption, secondLoginSelectOption))
)
geckoResult!!.accept {
onDismissWasCalled = true
}
loginSelectRequest.onDismiss()
assertTrue(onDismissWasCalled)
geckoResult = promptDelegate.onLoginSelect(
mock(),
GeckoLoginSelectPrompt(arrayOf(loginSelectOption, secondLoginSelectOption))
)
geckoResult!!.accept {
onLoginSelected = true
}
loginSelectRequest.onConfirm(login)
assertTrue(onLoginSelected)
}
fun createLogin(
guid: String = "id",
password: String = "password",
username: String = "username",
origin: String = "https://www.origin.com",
httpRealm: String = "httpRealm",
formActionOrigin: String = "https://www.origin.com",
usernameField: String = "usernameField",
passwordField: String = "passwordField"
) = Login(
guid = guid,
origin = origin,
password = password,
username = username,
httpRealm = httpRealm,
formActionOrigin = formActionOrigin,
usernameField = usernameField,
passwordField = passwordField
)
/**
* Converts an Android Components [Login] to a GeckoView [LoginStorage.LoginEntry]
*/
private fun Login.toLoginEntry() = Autocomplete.LoginEntry.Builder()
.guid(guid)
.origin(origin)
.formActionOrigin(formActionOrigin)
.httpRealm(httpRealm)
.username(username)
.password(password)
.build()
@Test
fun `Calling onAuthPrompt must provide an Authentication PromptRequest`() {
val mockSession = GeckoEngineSession(runtime)
......@@ -979,7 +1102,7 @@ class GeckoPromptDelegateTest {
title: String = "title",
type: Int,
capture: Int = 0,
mimeTypes: Array<out String > = emptyArray()
mimeTypes: Array<out String> = emptyArray()
) : GeckoSession.PromptDelegate.FilePrompt(title, type, capture, mimeTypes)
class GeckoAuthPrompt(
......@@ -1016,6 +1139,14 @@ class GeckoPromptDelegateTest {
message: String = "message"
) : GeckoSession.PromptDelegate.ButtonPrompt(title, message)
class GeckoLoginSelectPrompt(
loginArray: Array<Autocomplete.LoginSelectOption>
) : GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.LoginSelectOption>(loginArray)
class GeckoLoginSavePrompt(
login: Array<Autocomplete.LoginSaveOption>
) : GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.LoginSaveOption>(login)
class GeckoAuthOptions : GeckoSession.PromptDelegate.AuthPrompt.AuthOptions()
private fun GeckoSession.PromptDelegate.BasePrompt.getGeckoResult(): GeckoBundle {
......
......@@ -8,17 +8,18 @@ import android.content.Context
import android.net.Uri
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.engine.gecko.GeckoEngineSession
import mozilla.components.concept.storage.Login
import mozilla.components.concept.engine.prompt.Choice
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.concept.engine.prompt.PromptRequest.MenuChoice
import mozilla.components.concept.engine.prompt.PromptRequest.MultipleChoice
import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.concept.storage.Login
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.net.getFileName
import mozilla.components.support.ktx.kotlin.toDate
import org.mozilla.geckoview.AllowOrDeny
import org.mozilla.geckoview.Autocomplete
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSession.PromptDelegate
......@@ -29,7 +30,6 @@ import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.MON
import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.TIME
import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.WEEK
import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse
import org.mozilla.geckoview.Autocomplete
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
......@@ -106,19 +106,25 @@ internal class GeckoPromptDelegate(private val geckoEngineSession: GeckoEngineSe
prompt: PromptDelegate.AutocompleteRequest<Autocomplete.LoginSelectOption>
): GeckoResult<PromptResponse>? {
val geckoResult = GeckoResult<PromptResponse>()
val onConfirmSave: (Login) -> Unit = { login ->
val onConfirmSelect: (Login) -> Unit = { login ->
geckoResult.complete(prompt.confirm(Autocomplete.LoginSelectOption(login.toLoginEntry())))
}
val onDismiss: () -> Unit = {
geckoResult.complete(prompt.dismiss())
}
// Currently no-op will be addressed in https://github.com/mozilla-mobile/android-components/issues/7134
// Exactly one of `httpRealm` and `formSubmitURL` must be present to be a valid login entry.
val loginList = prompt.options.filter { option ->
option.value.formActionOrigin != null || option.value.httpRealm != null
}.map { option ->
option.value.toLogin()
}
geckoEngineSession.notifyObservers {
onPromptRequest(
PromptRequest.SelectLoginPrompt(
logins = listOf(),
onConfirm = onConfirmSave,
logins = loginList,
onConfirm = onConfirmSelect,
onDismiss = onDismiss
)
)
......
......@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.map
import mozilla.components.feature.logins.exceptions.adapter.LoginExceptionAdapter
import mozilla.components.feature.logins.exceptions.db.LoginExceptionDatabase
import mozilla.components.feature.logins.exceptions.db.LoginExceptionEntity
import mozilla.components.feature.prompts.LoginExceptions
import mozilla.components.feature.prompts.login.LoginExceptions
/**
* A storage implementation for organizing login exceptions.
......
......@@ -50,14 +50,17 @@ import mozilla.components.feature.prompts.dialog.ChoiceDialogFragment.Companion.
import mozilla.components.feature.prompts.dialog.ChoiceDialogFragment.Companion.SINGLE_CHOICE_DIALOG_TYPE
import mozilla.components.feature.prompts.dialog.ColorPickerDialogFragment
import mozilla.components.feature.prompts.dialog.ConfirmDialogFragment
import mozilla.components.feature.prompts.dialog.LoginDialogFragment
import mozilla.components.feature.prompts.dialog.MultiButtonDialogFragment
import mozilla.components.feature.prompts.dialog.PromptAbuserDetector
import mozilla.components.feature.prompts.dialog.PromptDialogFragment
import mozilla.components.feature.prompts.dialog.Prompter
import mozilla.components.feature.prompts.dialog.SaveLoginDialogFragment
import mozilla.components.feature.prompts.dialog.TextPromptDialogFragment
import mozilla.components.feature.prompts.dialog.TimePickerDialogFragment
import mozilla.components.feature.prompts.file.FilePicker
import mozilla.components.feature.prompts.login.LoginExceptions
import mozilla.components.feature.prompts.login.LoginPicker
import mozilla.components.feature.prompts.login.LoginPickerView
import mozilla.components.feature.prompts.share.DefaultShareDelegate
import mozilla.components.feature.prompts.share.ShareDelegate
import mozilla.components.lib.state.ext.flowScoped
......@@ -105,6 +108,9 @@ private const val PROGRESS_ALMOST_COMPLETE = 90
* 'save login'prompts will not be shown.
* @property loginExceptionStorage An implementation of [LoginExceptions] that saves and checks origins
* the user does not want to see a save login dialog for.
* @property loginPickerView The [LoginPickerView] used for [LoginPicker] to display select login options.
* @property onManageLogins A callback invoked when a user selects "manage logins" from the
* select login prompt.
* @property onNeedToRequestPermissions A callback invoked when permissions
* need to be requested before a prompt (e.g. a file picker) can be displayed.
* Once the request is completed, [onPermissionsResult] needs to be invoked.
......@@ -119,11 +125,14 @@ class PromptFeature private constructor(
override val loginValidationDelegate: LoginValidationDelegate? = null,
private val isSaveLoginEnabled: () -> Boolean = { false },
override val loginExceptionStorage: LoginExceptions? = null,
private val loginPickerView: LoginPickerView? = null,
private val onManageLogins: () -> Unit = {},
onNeedToRequestPermissions: OnNeedToRequestPermissions
) : LifecycleAwareFeature, PermissionsFeature, Prompter {
// These two scopes have identical lifetimes. We do not yet have a way of combining scopes
// These three scopes have identical lifetimes. We do not yet have a way of combining scopes
private var handlePromptScope: CoroutineScope? = null
private var dismissPromptScope: CoroutineScope? = null
private var sessionPromptScope: CoroutineScope? = null
private var activePromptRequest: PromptRequest? = null
internal val promptAbuserDetector = PromptAbuserDetector()
......@@ -141,6 +150,8 @@ class PromptFeature private constructor(
loginValidationDelegate: LoginValidationDelegate? = null,
isSaveLoginEnabled: () -> Boolean = { false },
loginExceptionStorage: LoginExceptions? = null,
loginPickerView: LoginPickerView? = null,
onManageLogins: () -> Unit = {},
onNeedToRequestPermissions: OnNeedToRequestPermissions
) : this(
container = PromptContainer.Activity(activity),
......@@ -151,7 +162,9 @@ class PromptFeature private constructor(
loginValidationDelegate = loginValidationDelegate,
isSaveLoginEnabled = isSaveLoginEnabled,
loginExceptionStorage = loginExceptionStorage,
onNeedToRequestPermissions = onNeedToRequestPermissions
onNeedToRequestPermissions = onNeedToRequestPermissions,
loginPickerView = loginPickerView,
onManageLogins = onManageLogins
)
constructor(
......@@ -163,6 +176,8 @@ class PromptFeature private constructor(
loginValidationDelegate: LoginValidationDelegate? = null,
isSaveLoginEnabled: () -> Boolean = { false },
loginExceptionStorage: LoginExceptions? = null,
loginPickerView: LoginPickerView? = null,
onManageLogins: () -> Unit = {},
onNeedToRequestPermissions: OnNeedToRequestPermissions
) : this(
container = PromptContainer.Fragment(fragment),
......@@ -173,7 +188,9 @@ class PromptFeature private constructor(
loginValidationDelegate = loginValidationDelegate,
isSaveLoginEnabled = isSaveLoginEnabled,
loginExceptionStorage = loginExceptionStorage,
onNeedToRequestPermissions = onNeedToRequestPermissions
onNeedToRequestPermissions = onNeedToRequestPermissions,
loginPickerView = loginPickerView,
onManageLogins = onManageLogins
)
@Deprecated("Pass only activity or fragment instead")
......@@ -183,6 +200,8 @@ class PromptFeature private constructor(
store: BrowserStore,
customTabId: String? = null,
fragmentManager: FragmentManager,
loginPickerView: LoginPickerView? = null,
onManageLogins: () -> Unit = {},
onNeedToRequestPermissions: OnNeedToRequestPermissions
) : this(
container = activity?.let { PromptContainer.Activity(it) }
......@@ -196,11 +215,16 @@ class PromptFeature private constructor(
fragmentManager = fragmentManager,
shareDelegate = DefaultShareDelegate(),
loginValidationDelegate = null,
onNeedToRequestPermissions = onNeedToRequestPermissions
onNeedToRequestPermissions = onNeedToRequestPermissions,
loginPickerView = loginPickerView,
onManageLogins = onManageLogins
)
private val filePicker = FilePicker(container, store, customTabId, onNeedToRequestPermissions)
private val loginPicker =
loginPickerView?.let { LoginPicker(store, it, onManageLogins, customTabId) }
override val onNeedToRequestPermissions
get() = filePicker.onNeedToRequestPermissions
......@@ -240,6 +264,20 @@ class PromptFeature private constructor(
prompt.dismiss()
}
activePrompt?.clear()
loginPicker?.dismissCurrentLoginSelect()
}
}
// Dismiss prompts when a new tab is selected.
sessionPromptScope = store.flowScoped { flow ->
flow.ifChanged { browserState -> browserState.selectedTabId }
.collect {
val prompt = activePrompt?.get()
if (prompt?.shouldDismissOnLoad() == true) {
prompt.dismiss()
}
activePrompt?.clear()
loginPicker?.dismissCurrentLoginSelect()
}
}
......@@ -257,6 +295,7 @@ class PromptFeature private constructor(
override fun stop() {
handlePromptScope?.cancel()
dismissPromptScope?.cancel()
sessionPromptScope?.cancel()
}
/**
......@@ -294,6 +333,13 @@ class PromptFeature private constructor(
when (promptRequest) {
is File -> filePicker.handleFileRequest(promptRequest)
is Share -> handleShareRequest(promptRequest, session)
is SelectLoginPrompt -> {
if (promptRequest.logins.isNotEmpty()) {
loginPicker?.handleSelectLoginRequest(
promptRequest
)
}
}
else -> handleDialogsRequest(promptRequest, session)
}
}
......@@ -439,7 +485,7 @@ class PromptFeature private constructor(
return
}
LoginDialogFragment.newInstance(
SaveLoginDialogFragment.newInstance(
sessionId = session.id,
hint = promptRequest.hint,
// For v1, we only handle a single login and drop all others on the floor
......@@ -447,12 +493,6 @@ class PromptFeature private constructor(
)
}
/**
* This feature isn't implemented yet
* see https://github.com/mozilla-mobile/android-components/issues/7134
*/
is SelectLoginPrompt -> return
is SingleChoice -> ChoiceDialogFragment.newInstance(
promptRequest.choices,