Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions feature/autofill/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ plugins {
android {
namespace = "de.davis.keygo.feature.autofill"

defaultConfig {
missingDimensionStrategy("store", "playStore")
}

testFixtures {
enable = true
}

flavorDimensions += listOf("store")
productFlavors {
create("playStore") {
dimension = "store"
}

create("fdroid") {
dimension = "store"
}
}
}

dependencies {
Expand All @@ -24,6 +31,8 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.kotlinx.collections.immutable)

"playStoreImplementation"(libs.gms.auth.api.phone)

implementation(projects.core.item)
implementation(projects.core.util)
implementation(projects.core.ui)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package de.davis.keygo.feature.autofill.presentation.sms

import de.davis.keygo.core.util.Result
import org.koin.core.annotation.Single

@Single(binds = [SmsCodeRepository::class])
internal class NoopSmsCodeRepository : SmsCodeRepository {

override suspend fun canOfferSuggestion(targetPackage: String): Boolean = false

override suspend fun retrieveSmsCode(): Result<String, SmsCodeFailure> =
Result.Failure(SmsCodeFailure.Unavailable)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package de.davis.keygo.feature.autofill.data.repository
package de.davis.keygo.feature.autofill.data

import android.content.Context
import android.content.pm.PackageManager
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,21 @@ internal class AutofillDatasetProviderImpl(
request: FillRequest,
form: Form
): List<Dataset> {
val targetPackage = request.fillContexts
.lastOrNull()
?.structure
?.activityComponent
?.packageName
?: ""

if (systemSupportsInlineSuggestions(request))
return inlineDatasetBuilder.buildInlineDatasets(
targetPackage = targetPackage,
specs = request.inlineSuggestionsRequest!!.inlinePresentationSpecs,
form = form
)

return menuDatasetBuilder.buildMenuDatasets(form = form)
return menuDatasetBuilder.buildMenuDatasets(targetPackage = targetPackage, form = form)
}

override fun getFillingDataset(values: List<AutofillValue>) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,30 @@ internal class KeyGoAutofillService : AutofillService() {
Log.d(TAG, "In Compatibility Mode: $inCompatibilityMode")
Log.d(TAG, "Extracted form: $form")

val dataset = datasetProvider.getAutofillDatasets(request, form)
val response = FillResponse.Builder().apply {
dataset.forEach(::addDataset)
applySaveInfo(
val datasets = datasetProvider.getAutofillDatasets(request, form)
val builder = FillResponse.Builder()
datasets.forEach(builder::addDataset)

// A broken save info must never cost us the datasets: without them we cannot fill
// anything at all, while a missing save info only skips a single save prompt.
val appliedSaveInfo = runCatching {
builder.applySaveInfo(
form = form,
clientInfo = request.clientState ?: bundleOf(),
requestId = request.id,
inCompatibilityMode = inCompatibilityMode
)
}.build()
callback.onSuccess(response)
}.onFailure {
Log.w(TAG, "Could not apply save info", it)
}.getOrDefault(false)

if (datasets.isEmpty() && !appliedSaveInfo) {
Log.d(TAG, "Neither datasets nor save info - not filling")
callback.onSuccess(null)
return@launch
}

callback.onSuccess(builder.build())
}

cancellationSignal.setOnCancelListener {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import android.os.Bundle
import android.os.PersistableBundle
import android.service.autofill.Dataset
import android.view.autofill.AutofillManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalClipboard
Expand All @@ -28,6 +30,7 @@ import de.davis.keygo.core.util.onSuccess
import de.davis.keygo.core.util.presentation.ObserveAsEvents
import de.davis.keygo.feature.auth.presentation.AuthRoute
import de.davis.keygo.feature.autofill.presentation.activity.component.AssociationDialog
import de.davis.keygo.feature.autofill.presentation.activity.component.SmsCodePendingDialog
import de.davis.keygo.feature.autofill.presentation.activity.component.SuspicionDialog
import de.davis.keygo.feature.autofill.presentation.activity.model.AssociationDialogVisibility
import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillEvent
Expand All @@ -49,7 +52,6 @@ import org.koin.androidx.compose.koinViewModel
*/
internal class AutofillActivity : FragmentActivity() {

@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

Expand All @@ -68,6 +70,14 @@ internal class AutofillActivity : FragmentActivity() {

val clipboard = LocalClipboard.current

val smsConsentLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
viewModel.onEvent(
AutofillUiEvent.OnSmsConsentResult(result.resultCode == RESULT_OK),
)
}

ObserveAsEvents(viewModel.events) { event ->
when (event) {
AutofillEvent.Abort -> cancel()
Expand All @@ -85,6 +95,11 @@ internal class AutofillActivity : FragmentActivity() {

finishWithResult(event.dataset)
}

is AutofillEvent.RequestSmsConsent ->
smsConsentLauncher.launch(
IntentSenderRequest.Builder(event.intentSender).build(),
)
}
}

Expand Down Expand Up @@ -154,6 +169,11 @@ internal class AutofillActivity : FragmentActivity() {
onGenerated = { viewModel.onEvent(AutofillUiEvent.OnGeneratedPassword(it)) },
onDismiss = { viewModel.onEvent(AutofillUiEvent.OnDismissGeneratePassword) }
)

if (uiState.showSmsPending)
SmsCodePendingDialog(
onCancel = { viewModel.onEvent(AutofillUiEvent.OnCancelSmsCode) }
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformati
import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation
import de.davis.keygo.core.security.domain.model.BiometricAuthError
import de.davis.keygo.core.util.getOrNull
import de.davis.keygo.core.util.onFailure
import de.davis.keygo.core.util.onSuccess
import de.davis.keygo.feature.autofill.domain.usecase.AddRegistrableDomainsToLoginUseCase
import de.davis.keygo.feature.autofill.domain.usecase.DoesItemHaveDomainReferencesUseCase
import de.davis.keygo.feature.autofill.domain.usecase.IsAppLinkedToWebsiteUseCase
Expand All @@ -36,8 +38,11 @@ import de.davis.keygo.feature.autofill.presentation.model.FormType
import de.davis.keygo.feature.autofill.presentation.model.Request
import de.davis.keygo.feature.autofill.presentation.model.RequestData
import de.davis.keygo.feature.autofill.presentation.model.SaveRequestData
import de.davis.keygo.feature.autofill.presentation.sms.SmsCodeFailure
import de.davis.keygo.feature.autofill.presentation.sms.SmsCodeRepository
import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation
import de.davis.keygo.feature.totp.domain.repository.TotpGenerator
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand All @@ -53,6 +58,7 @@ internal class AutofillViewModel(
private val loginRepository: LoginRepository,
private val totpRepository: TotpRepository,
private val itemRepository: ItemRepository,
private val smsCodeRepository: SmsCodeRepository,
private val cryptographicScopeProvider: CryptographicScopeProvider,
private val autofillDatasetProvider: AutofillDatasetProvider,
private val doesItemHaveDomainReferences: DoesItemHaveDomainReferencesUseCase,
Expand All @@ -73,6 +79,7 @@ internal class AutofillViewModel(
private val _uiState = MutableStateFlow(AutofillUiState())
val uiState = _uiState.asStateFlow()

private var smsOtpJob: Job? = null

fun start() {
handleRequestData()
Expand Down Expand Up @@ -137,6 +144,8 @@ internal class AutofillViewModel(
it.copy(showGeneratePassword = true)
}

is FillRequestData.SmsOtp -> handleSmsOtpRequest(requestData)

is FillRequestData.Suggestion -> handleSuggestionRequest(requestData)
}
}
Expand All @@ -150,6 +159,81 @@ internal class AutofillViewModel(
biometricChannel.send(AutofillBiometricRequest.UnlockItem(itemName))
}

private suspend fun handleSmsOtpRequest(smsOtpInfo: FillRequestData.SmsOtp) {
// The extractor already narrowed the form to the focused field's group, so a TOTP form holds
// TOTP fields and nothing else. An empty one means there is nothing to fill.
if (smsOtpInfo.form.fields.isEmpty()) {
eventChannel.send(AutofillEvent.Abort)
return
}

_uiState.update { it.copy(showSmsPending = true) }
startSmsRetrieval()
}

private fun startSmsRetrieval(consentAlreadyRequested: Boolean = false) {
smsOtpJob?.cancel()
smsOtpJob = viewModelScope.launch {
smsCodeRepository.retrieveSmsCode()
.onSuccess { code -> sendSmsFillEvent(code) }
.onFailure { failure ->
when (failure) {
// Asking a second time would mean the consent screen came back OK without
// actually granting anything, so stop rather than spin.
is SmsCodeFailure.ConsentRequired ->
if (consentAlreadyRequested) {
_uiState.update { it.copy(showSmsPending = false) }
eventChannel.send(AutofillEvent.Abort)
} else eventChannel.send(
AutofillEvent.RequestSmsConsent(failure.intentSender),
)

else -> {
_uiState.update { it.copy(showSmsPending = false) }
eventChannel.send(AutofillEvent.Abort)
}
}
}
}
}

private fun onSmsConsentResult(granted: Boolean) {
if (granted) startSmsRetrieval(consentAlreadyRequested = true)
else viewModelScope.launch {
_uiState.update { it.copy(showSmsPending = false) }
eventChannel.send(AutofillEvent.Abort)
}
}

private fun cancelSmsRetrieval() {
smsOtpJob?.cancel()
smsOtpJob = null
_uiState.update { it.copy(showSmsPending = false) }
viewModelScope.launch { eventChannel.send(AutofillEvent.Abort) }
}

private suspend fun sendSmsFillEvent(code: String) {
val fields = (requestData as? FillRequestData.SmsOtp)?.form?.fields
val targetField = fields?.firstOrNull { it.focused } ?: fields?.firstOrNull() ?: run {
eventChannel.send(AutofillEvent.Abort)
return
}

_uiState.update { it.copy(showSmsPending = false) }
eventChannel.send(
AutofillEvent.Fill(
autofillDatasetProvider.getFillingDataset(
listOf(
AutofillValue(
autofillId = targetField.autofillId,
value = code,
),
),
),
),
)
}

fun onBiometricLoginFailed(error: UnlockError) {
viewModelScope.launch {
when(error) {
Expand Down Expand Up @@ -235,6 +319,9 @@ internal class AutofillViewModel(
is AutofillUiEvent.OnGeneratedPassword -> viewModelScope.launch {
sendGeneratedPasswordFillEvent(event.password)
}

is AutofillUiEvent.OnSmsConsentResult -> onSmsConsentResult(event.granted)
AutofillUiEvent.OnCancelSmsCode -> cancelSmsRetrieval()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package de.davis.keygo.feature.autofill.presentation.activity.component

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.davis.keygo.feature.autofill.R

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun SmsCodePendingDialog(
onCancel: () -> Unit,
modifier: Modifier = Modifier
) {
AlertDialog(
onDismissRequest = onCancel,
confirmButton = {
TextButton(
onClick = onCancel
) {
Text(text = stringResource(R.string.cancel))
}
},
icon = {
Icon(painter = painterResource(R.drawable.outline_sms_24), contentDescription = null)
},
title = {
Text(text = stringResource(R.string.waiting_for_sms_code))
},
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(text = stringResource(R.string.waiting_for_sms_code_description))
LoadingIndicator()
}
},
modifier = modifier
)
}

@Preview
@Composable
private fun SmsCodePendingDialogPreview() {
MaterialTheme {
SmsCodePendingDialog(
onCancel = {},
modifier = Modifier.fillMaxWidth()
)
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package de.davis.keygo.feature.autofill.presentation.activity.model

import android.content.IntentSender
import android.service.autofill.Dataset

internal sealed interface AutofillEvent {

data object Abort : AutofillEvent

data class Fill(val dataset: Dataset, val copyToClipboard: String? = null) : AutofillEvent

data class RequestSmsConsent(val intentSender: IntentSender) : AutofillEvent
}
Loading
Loading