refactor: Clean-up Android autofill service

This commit is contained in:
Artem Chepurnoy 2024-06-11 22:33:08 +03:00
parent 42e1056c69
commit 075a628499
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
3 changed files with 407 additions and 352 deletions

View File

@ -4,13 +4,11 @@ import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentSender
import android.graphics.BlendMode import android.graphics.BlendMode
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.os.Build import android.os.Build
import android.os.CancellationSignal import android.os.CancellationSignal
import android.service.autofill.* import android.service.autofill.*
import android.util.Log
import android.view.autofill.AutofillId import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue import android.view.autofill.AutofillValue
import android.widget.RemoteViews import android.widget.RemoteViews
@ -27,6 +25,8 @@ import com.artemchep.keyguard.android.MainActivity
import com.artemchep.keyguard.common.R import com.artemchep.keyguard.common.R
import com.artemchep.keyguard.common.io.* import com.artemchep.keyguard.common.io.*
import com.artemchep.keyguard.common.model.* import com.artemchep.keyguard.common.model.*
import com.artemchep.keyguard.common.service.logging.LogRepository
import com.artemchep.keyguard.common.service.logging.postDebug
import com.artemchep.keyguard.common.usecase.* import com.artemchep.keyguard.common.usecase.*
import com.artemchep.keyguard.feature.home.vault.component.FormatCardGroupLength import com.artemchep.keyguard.feature.home.vault.component.FormatCardGroupLength
import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.res.Res
@ -36,7 +36,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.StringResource
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.android.closestDI import org.kodein.di.android.closestDI
import org.kodein.di.direct import org.kodein.di.direct
@ -48,6 +48,8 @@ class KeyguardAutofillService : AutofillService(), DIAware {
private const val TAG = "AFService" private const val TAG = "AFService"
const val KEEP_CIPHERS_IN_MEMORY_FOR = 10_000L const val KEEP_CIPHERS_IN_MEMORY_FOR = 10_000L
const val SUGGESTIONS_MAX_COUNT = 10
} }
private val job = Job() private val job = Job()
@ -91,13 +93,17 @@ class KeyguardAutofillService : AutofillService(), DIAware {
.shareIn(scope, SharingStarted.WhileSubscribed(KEEP_CIPHERS_IN_MEMORY_FOR), replay = 1) .shareIn(scope, SharingStarted.WhileSubscribed(KEEP_CIPHERS_IN_MEMORY_FOR), replay = 1)
} }
private val logRepository: LogRepository by lazy {
di.direct.instance()
}
private val getTotpCode: GetTotpCode by lazy { private val getTotpCode: GetTotpCode by lazy {
di.direct.instance() di.direct.instance()
} }
private val getSuggestions by lazy { private val getSuggestions by lazy {
val model: GetSuggestions<Any?> by di.instance() val model: GetSuggestions<Any?> by di.instance()
model GetCipherSuggestions(model)
} }
private val prefInlineSuggestionsFlow by lazy { private val prefInlineSuggestionsFlow by lazy {
@ -127,328 +133,351 @@ class KeyguardAutofillService : AutofillService(), DIAware {
private val autofillStructureParser = AutofillStructureParser() private val autofillStructureParser = AutofillStructureParser()
private class GetCipherSuggestions(
private val parent: GetSuggestions<Any?>,
) : GetSuggestions<DSecret> {
override fun invoke(
ciphers: List<DSecret>,
getter: Getter<DSecret, DSecret>,
target: AutofillTarget,
): IO<List<DSecret>> = parent
.invoke(
ciphers,
Getter { it as DSecret },
target,
) as IO<List<DSecret>>
}
private class AbortAutofillException(
message: String,
) : RuntimeException(message)
@SuppressLint("NewApi") @SuppressLint("NewApi")
override fun onFillRequest( override fun onFillRequest(
request: FillRequest, request: FillRequest,
cancellationSignal: CancellationSignal, cancellationSignal: CancellationSignal,
callback: FillCallback, callback: FillCallback,
) { ) {
val autofillStructure = kotlin.runCatching { getAutofillStructureIo(request)
val structureLatest = request.fillContexts .flatMap { autofillStructure ->
.map { it.structure } if (autofillStructure.items.isEmpty()) {
.lastOrNull() throw AbortAutofillException("Nothing to autofill.")
// If the structure is missing, then abort auto-filling
// process.
?: throw IllegalStateException("No structures to fill.")
val respectAutofillOff = prefRespectAutofillOffFlow.toIO().bindBlocking()
autofillStructureParser.parse(
structureLatest,
respectAutofillOff,
)
}
// .flatMap {
// val targetApplicationId = it.applicationId
// val keyguardApplicationId = packageName
// if (targetApplicationId == keyguardApplicationId) {
// val response = FillResponse.Builder()
// .disableAutofill(1000L)
// .build()
// callback.onSuccess(response)
// return
// } else {
// Result.success(it)
// }
// }
.fold(
onSuccess = ::identity,
onFailure = {
callback.onFailure("Failed to parse structures: ${it.message}")
return
},
)
if (autofillStructure.items.isEmpty()) {
callback.onFailure("Nothing to autofill.")
return
}
val job = ciphersFlow
.onStart {
Log.e("LOL", "on start v2")
}
.toIO()
.effectMap(Dispatchers.Default) { state ->
state.map { secrets ->
val autofillTarget = AutofillTarget(
links = listOfNotNull(
// application id
autofillStructure.applicationId?.let {
LinkInfoPlatform.Android(
packageName = it,
)
},
// website
autofillStructure.webDomain?.let {
val url = Url("https://$it")
LinkInfoPlatform.Web(
url = url,
frontPageUrl = url,
)
},
),
hints = autofillStructure.items.map { it.hint },
)
getSuggestions(
secrets,
Getter { it as DSecret },
autofillTarget,
).bind()
.take(10) as List<DSecret>
} }
getAutofillResponseIo(
request = request,
autofillStructure = autofillStructure,
)
} }
.biEffectTap( .effectTap { response ->
ifException = { callback.onSuccess(response)
// If the request is canceled, then it does not expect any }
// feedback. .handleError { e ->
if (!cancellationSignal.isCanceled) { logRepository.postDebug(TAG) {
callback.onFailure("Failed to get the secrets from the database!") "Fill request: aborted because '$e'"
} }
},
ifSuccess = { r ->
val canInlineSuggestions = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
prefInlineSuggestionsFlow.toIO()
.attempt().bind().exists { it }
val forceHideManualSelection = kotlin.run { if (cancellationSignal.isCanceled) {
val targetApplicationId = autofillStructure.applicationId return@handleError
val keyguardApplicationId = packageName }
targetApplicationId == keyguardApplicationId
}
// build a response val msg = e.message ?: "Something went wrong"
val responseBuilder = FillResponse.Builder() callback.onFailure(msg)
when (r) { }
is Either.Left -> { .dispatchOn(Dispatchers.Main.immediate)
if (forceHideManualSelection) {
callback.onFailure("Can not autofill own app password.")
return@biEffectTap
}
// Database is locked, create a generic
// sign in with option.
responseBuilder.buildAuthentication(
type = FooBar.UNLOCK,
result2 = autofillStructure,
request = request,
canInlineSuggestions = canInlineSuggestions,
)
}
is Either.Right -> if (r.value.isEmpty()) {
if (forceHideManualSelection) {
callback.onFailure("No match found.")
return@biEffectTap
}
// No match found, create a generic option.
responseBuilder.buildAuthentication(
type = FooBar.SELECT,
result2 = autofillStructure,
request = request,
canInlineSuggestions = canInlineSuggestions,
)
} else {
val manualSelection = prefManualSelectionFlow.toIO().bind() &&
!forceHideManualSelection
val totalInlineSuggestionsMaxCount = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
canInlineSuggestions
) {
request.inlineSuggestionsRequest?.maxSuggestionCount
?: 0 // no suggestions allowed
} else {
0
}
val secretInlineSuggestionsMaxCount =
if (manualSelection) totalInlineSuggestionsMaxCount - 1 else totalInlineSuggestionsMaxCount
var index = 0
r.value.forEach { secret ->
var f = false
val dataset =
tryBuildDataset(index, this, secret, autofillStructure) {
if (index < secretInlineSuggestionsMaxCount) {
val inline =
tryBuildSecretInlinePresentation(
request,
index,
secret,
)
if (inline != null) {
setInlinePresentation(inline)
f = true
}
}
}
if (dataset != null && (!canInlineSuggestions || f)) {
responseBuilder.addDataset(dataset)
index += 1
}
}
if (manualSelection) {
val intent = AutofillActivity.getIntent(
context = this@KeyguardAutofillService,
args = AutofillActivity.Args(
applicationId = autofillStructure.applicationId,
webDomain = autofillStructure.webDomain,
webScheme = autofillStructure.webScheme,
autofillStructure2 = autofillStructure,
),
)
val flags =
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val pi = PendingIntent.getActivity(this, 1010, intent, flags)
val manualSelectionView = AutofillViews
.buildPopupEntryManual(this)
autofillStructure.items.forEach {
val autofillId = it.id
Log.e("SuggestionsTest", "autofill_id=$autofillId")
val builder = Dataset.Builder(manualSelectionView)
if (totalInlineSuggestionsMaxCount > 0 && canInlineSuggestions && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val inlinePresentation =
tryBuildManualSelectionInlinePresentation(
request,
index,
intent = intent,
)
inlinePresentation?.let {
builder.setInlinePresentation(it)
}
Log.e(
"SuggestionsTest",
"adding inline=$inlinePresentation",
)
}
builder.setValue(autofillId, null)
builder.setAuthentication(pi.intentSender)
responseBuilder.addDataset(builder.build())
}
}
}
}
val shouldSaveRequest = prefSaveRequestFlow.first() &&
!forceHideManualSelection
if (shouldSaveRequest) {
class SaveItem(
val flag: Int,
val item: AutofillStructure2.Item,
)
val items = mutableListOf<SaveItem>()
autofillStructure.items
.distinctBy { it.hint }
.forEach { item ->
val flag = when (item.hint) {
AutofillHint.PASSWORD -> SaveInfo.SAVE_DATA_TYPE_PASSWORD
AutofillHint.EMAIL_ADDRESS -> SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS
AutofillHint.USERNAME -> SaveInfo.SAVE_DATA_TYPE_USERNAME
else -> return@forEach
}
items += SaveItem(
flag = flag,
item = item,
)
}
val hints = autofillStructure
.items
.asSequence()
.map { it.hint }
.toSet()
val hintsIncludeUsername = AutofillHint.USERNAME in hints ||
AutofillHint.NEW_USERNAME in hints ||
AutofillHint.PHONE_NUMBER in hints ||
AutofillHint.EMAIL_ADDRESS in hints
val hintsIncludePassword = AutofillHint.PASSWORD in hints ||
AutofillHint.NEW_PASSWORD in hints
if (
hintsIncludeUsername &&
hintsIncludePassword &&
items.isNotEmpty()
) {
val saveInfoBuilder = SaveInfo.Builder(
items.fold(0) { y, x -> y or x.flag },
items
.map { it.item.id }
.toTypedArray(),
)
val saveInfo = saveInfoBuilder.build()
responseBuilder.setSaveInfo(saveInfo)
}
}
try {
val response = responseBuilder
.build()
callback.onSuccess(response)
} catch (e: Exception) {
callback.onFailure("Failed to build response ${e.localizedMessage}")
}
},
)
.dispatchOn(Dispatchers.Main)
.launchIn(scope) .launchIn(scope)
cancellationSignal.setOnCancelListener { cancellationSignal.setOnCancelListener {
job.cancel() job.cancel()
} }
} }
private enum class FooBar { private fun getAutofillStructureIo(
request: FillRequest,
) = ioEffect {
val assistStructureLatest = request.fillContexts
.map { it.structure }
.lastOrNull()
if (assistStructureLatest == null) {
throw AbortAutofillException("No structures to fill.")
}
val respectAutofillOff = prefRespectAutofillOffFlow.first()
autofillStructureParser.parse(
assistStructureLatest,
respectAutofillOff,
)
}
private fun getSaveStructureIo(
request: SaveRequest,
) = ioEffect {
val assistStructureLatest = request.fillContexts
.map { it.structure }
.lastOrNull()
if (assistStructureLatest == null) {
throw AbortAutofillException("No structures to save.")
}
val respectAutofillOff = prefRespectAutofillOffFlow.first()
autofillStructureParser.parse(
assistStructureLatest,
respectAutofillOff,
)
}
private fun getAutofillResponseIo(
request: FillRequest,
autofillStructure: AutofillStructure2,
) = ciphersFlow
.toIO()
.effectMap(Dispatchers.Default) { state ->
val autofillTarget = autofillStructure.toAutofillTarget()
state.map { secrets ->
getSuggestions(
secrets,
Getter { it },
autofillTarget,
).bind().take(SUGGESTIONS_MAX_COUNT)
}
}
.effectMap { r ->
val shouldInlineSuggestions = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
prefInlineSuggestionsFlow.toIO()
.attempt().bind().isRight { it }
val forceHideManualSelection = kotlin.run {
val targetApplicationId = autofillStructure.applicationId
val keyguardApplicationId = packageName
targetApplicationId == keyguardApplicationId
}
// build a response
val responseBuilder = FillResponse.Builder()
when (r) {
is Either.Left -> {
if (forceHideManualSelection) {
throw AbortAutofillException("Can not autofill own app password.")
}
// Database is locked, create a generic
// sign in with option.
responseBuilder.buildAuthentication(
type = AuthenticationType.UNLOCK,
struct = autofillStructure,
request = request,
canInlineSuggestions = shouldInlineSuggestions,
)
}
is Either.Right -> if (r.value.isEmpty()) {
if (forceHideManualSelection) {
throw AbortAutofillException("No match found.")
}
// No match found, create a generic option.
responseBuilder.buildAuthentication(
type = AuthenticationType.SELECT,
struct = autofillStructure,
request = request,
canInlineSuggestions = shouldInlineSuggestions,
)
} else {
val manualSelection = prefManualSelectionFlow.toIO().bind() &&
!forceHideManualSelection
val totalInlineSuggestionsMaxCount = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
shouldInlineSuggestions
) {
request.inlineSuggestionsRequest?.maxSuggestionCount
?: 0 // no suggestions allowed
} else {
0
}
val secretInlineSuggestionsMaxCount =
if (manualSelection) totalInlineSuggestionsMaxCount - 1 else totalInlineSuggestionsMaxCount
var index = 0
r.value.forEach { secret ->
var datasetHasInlinePresentation = false
val dataset =
tryBuildDataset(index, this, secret, autofillStructure) {
if (index < secretInlineSuggestionsMaxCount) {
val inlinePresentation =
tryBuildSecretInlinePresentation(
request,
index,
secret,
)
if (inlinePresentation != null) {
setInlinePresentation(inlinePresentation)
datasetHasInlinePresentation = true
}
}
}
if (dataset != null && (!shouldInlineSuggestions || datasetHasInlinePresentation)) {
responseBuilder.addDataset(dataset)
index += 1
}
}
if (manualSelection) {
val intent = AutofillActivity.getIntent(
context = this@KeyguardAutofillService,
args = AutofillActivity.Args(
applicationId = autofillStructure.applicationId,
webDomain = autofillStructure.webDomain,
webScheme = autofillStructure.webScheme,
autofillStructure2 = autofillStructure,
),
)
val flags =
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val pi = PendingIntent.getActivity(this, 1010, intent, flags)
val manualSelectionView = AutofillViews
.buildPopupEntryManual(this)
autofillStructure.items.forEach {
val autofillId = it.id
val builder = Dataset.Builder(manualSelectionView)
if (totalInlineSuggestionsMaxCount > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val inlinePresentation =
tryBuildManualSelectionInlinePresentation(
request,
index,
intent = intent,
)
inlinePresentation?.let {
builder.setInlinePresentation(it)
}
}
builder.setValue(autofillId, null)
builder.setAuthentication(pi.intentSender)
responseBuilder.addDataset(builder.build())
}
}
}
}
val shouldSaveRequest = prefSaveRequestFlow.first() &&
!forceHideManualSelection
if (shouldSaveRequest) {
class SaveItem(
val flag: Int,
val item: AutofillStructure2.Item,
)
val saveItems = mutableListOf<SaveItem>()
autofillStructure.items
.distinctBy { it.hint }
.forEach { item ->
val flag = when (item.hint) {
AutofillHint.PASSWORD -> SaveInfo.SAVE_DATA_TYPE_PASSWORD
AutofillHint.EMAIL_ADDRESS -> SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS
AutofillHint.USERNAME -> SaveInfo.SAVE_DATA_TYPE_USERNAME
else -> return@forEach
}
saveItems += SaveItem(
flag = flag,
item = item,
)
}
val hints = autofillStructure
.items
.asSequence()
.map { it.hint }
.toSet()
val hintsIncludeUsername = AutofillHint.USERNAME in hints ||
AutofillHint.NEW_USERNAME in hints ||
AutofillHint.PHONE_NUMBER in hints ||
AutofillHint.EMAIL_ADDRESS in hints
val hintsIncludePassword = AutofillHint.PASSWORD in hints ||
AutofillHint.NEW_PASSWORD in hints
if (
hintsIncludeUsername &&
hintsIncludePassword &&
saveItems.isNotEmpty()
) {
val saveInfoBuilder = SaveInfo.Builder(
saveItems.fold(0) { y, x -> y or x.flag },
saveItems
.map { it.item.id }
.toTypedArray(),
)
val saveInfo = saveInfoBuilder.build()
responseBuilder.setSaveInfo(saveInfo)
}
}
responseBuilder
.build()
}
private fun AutofillStructure2.toAutofillTarget(
) = AutofillTarget(
links = listOfNotNull(
// application id
applicationId?.let {
LinkInfoPlatform.Android(
packageName = it,
)
},
// website
webDomain?.let {
val schema = webScheme ?: "https"
val url = Url("$schema://$it")
LinkInfoPlatform.Web(
url = url,
frontPageUrl = url,
)
},
),
hints = items.map { it.hint },
maxCount = SUGGESTIONS_MAX_COUNT,
)
private enum class AuthenticationType {
UNLOCK, UNLOCK,
SELECT, SELECT,
} }
private suspend fun FillResponse.Builder.buildAuthentication( private suspend fun FillResponse.Builder.buildAuthentication(
type: FooBar, type: AuthenticationType,
result2: AutofillStructure2, struct: AutofillStructure2,
request: FillRequest, request: FillRequest,
canInlineSuggestions: Boolean, canInlineSuggestions: Boolean,
) { ) {
val remoteViewsUnlock: RemoteViews = when (type) { val remoteViews: RemoteViews = when (type) {
FooBar.UNLOCK -> AutofillViews.buildPopupKeyguardUnlock( AuthenticationType.UNLOCK -> AutofillViews.buildPopupKeyguardUnlock(
this@KeyguardAutofillService, this@KeyguardAutofillService,
result2.webDomain, struct.webDomain,
result2.applicationId, struct.applicationId,
) )
FooBar.SELECT -> AutofillViews.buildPopupKeyguardOpen( AuthenticationType.SELECT -> AutofillViews.buildPopupKeyguardOpen(
this@KeyguardAutofillService, this@KeyguardAutofillService,
result2.webDomain, struct.webDomain,
result2.applicationId, struct.applicationId,
) )
} }
result2.items.forEach { val authIntent = AutofillActivity.getIntent(
val autofillId = it.id context = this@KeyguardAutofillService,
val authIntent = AutofillActivity.getIntent( args = AutofillActivity.Args(
context = this@KeyguardAutofillService, applicationId = struct.applicationId,
args = AutofillActivity.Args( webDomain = struct.webDomain,
applicationId = result2.applicationId, webScheme = struct.webScheme,
webDomain = result2.webDomain, autofillStructure2 = struct,
webScheme = result2.webScheme, ),
autofillStructure2 = result2, )
), val authIntentSender = PendingIntent.getActivity(
) this@KeyguardAutofillService,
val intentSender: IntentSender = PendingIntent.getActivity( 1001,
this@KeyguardAutofillService, authIntent,
1001, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
authIntent, ).intentSender
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
).intentSender
val builder = Dataset.Builder(remoteViewsUnlock) struct.items.forEach { item ->
val builder = Dataset.Builder(remoteViews)
if (canInlineSuggestions && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (canInlineSuggestions && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val inlinePresentation = val inlinePresentation =
tryCreateAuthenticationInlinePresentation( tryCreateAuthenticationInlinePresentation(
@ -460,13 +489,9 @@ class KeyguardAutofillService : AutofillService(), DIAware {
inlinePresentation?.let { inlinePresentation?.let {
builder.setInlinePresentation(it) builder.setInlinePresentation(it)
} }
Log.e(
"SuggestionsTest",
"adding inline=$inlinePresentation",
)
} }
builder.setValue(autofillId, null) builder.setValue(item.id, null)
builder.setAuthentication(intentSender) builder.setAuthentication(authIntentSender)
addDataset(builder.build()) addDataset(builder.build())
} }
} }
@ -620,7 +645,7 @@ class KeyguardAutofillService : AutofillService(), DIAware {
PendingIntent.getActivity(this, 1010, intent, flags) PendingIntent.getActivity(this, 1010, intent, flags)
}, },
content = { content = {
val title = org.jetbrains.compose.resources.getString(Res.string.autofill_open_keyguard) val title = getString(Res.string.autofill_open_keyguard)
setContentDescription(title) setContentDescription(title)
setTitle(title) setTitle(title)
setStartIcon(createAppIcon()) setStartIcon(createAppIcon())
@ -630,7 +655,7 @@ class KeyguardAutofillService : AutofillService(), DIAware {
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
private suspend fun tryCreateAuthenticationInlinePresentation( private suspend fun tryCreateAuthenticationInlinePresentation(
type: FooBar, type: AuthenticationType,
request: FillRequest, request: FillRequest,
index: Int, index: Int,
intent: Intent, intent: Intent,
@ -643,8 +668,8 @@ class KeyguardAutofillService : AutofillService(), DIAware {
}, },
content = { content = {
val text = when (type) { val text = when (type) {
FooBar.UNLOCK -> org.jetbrains.compose.resources.getString(Res.string.autofill_unlock_keyguard) AuthenticationType.UNLOCK -> getString(Res.string.autofill_unlock_keyguard)
FooBar.SELECT -> org.jetbrains.compose.resources.getString(Res.string.autofill_open_keyguard) AuthenticationType.SELECT -> getString(Res.string.autofill_open_keyguard)
} }
setContentDescription(text) setContentDescription(text)
setTitle(text) setTitle(text)
@ -689,26 +714,49 @@ class KeyguardAutofillService : AutofillService(), DIAware {
} }
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
val autofillStructure = kotlin.runCatching { getSaveStructureIo(request)
val structureLatest = request.fillContexts .flatMap { autofillStructure ->
.map { it.structure } if (autofillStructure.items.isEmpty()) {
.lastOrNull() throw AbortAutofillException("Nothing to autofill.")
// If the structure is missing, then abort auto-filling }
// process.
?: throw IllegalStateException("No structures to fill.")
val respectAutofillOff = prefRespectAutofillOffFlow.toIO().bindBlocking() getSaveResponseIo(
autofillStructureParser.parse( request = request,
structureLatest, autofillStructure = autofillStructure,
respectAutofillOff, )
) }
}.fold( .effectTap { intent ->
onSuccess = ::identity, if (Build.VERSION.SDK_INT >= 28) {
onFailure = { val flags =
callback.onFailure("Failed to parse structures: ${it.message}") PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
return val pi = PendingIntent.getActivity(this, 10120, intent, flags)
}, callback.onSuccess(pi.intentSender)
) } else {
try {
startActivity(intent)
} catch (e: Exception) {
callback.onFailure(e.message)
return@effectTap
}
callback.onSuccess()
}
}
.handleError { e ->
logRepository.postDebug(TAG) {
"Save request: aborted because '$e'"
}
val msg = e.message ?: "Something went wrong"
callback.onFailure(msg)
}
.dispatchOn(Dispatchers.Main.immediate)
.launchIn(scope)
}
private fun getSaveResponseIo(
request: SaveRequest,
autofillStructure: AutofillStructure2,
) = ioEffect {
val hints = autofillStructure val hints = autofillStructure
.items .items
.asSequence() .asSequence()
@ -724,11 +772,10 @@ class KeyguardAutofillService : AutofillService(), DIAware {
!hintsIncludeUsername || !hintsIncludeUsername ||
!hintsIncludePassword !hintsIncludePassword
) { ) {
callback.onFailure("Can only save login data.") throw AbortAutofillException("Can only save login data.")
return
} }
val intent = AutofillSaveActivity.getIntent( AutofillSaveActivity.getIntent(
context = this@KeyguardAutofillService, context = this@KeyguardAutofillService,
args = AutofillSaveActivity.Args( args = AutofillSaveActivity.Args(
applicationId = autofillStructure.applicationId, applicationId = autofillStructure.applicationId,
@ -737,24 +784,13 @@ class KeyguardAutofillService : AutofillService(), DIAware {
autofillStructure2 = autofillStructure, autofillStructure2 = autofillStructure,
), ),
) )
if (Build.VERSION.SDK_INT >= 28) {
val flags =
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
val pi = PendingIntent.getActivity(this, 10120, intent, flags)
callback.onSuccess(pi.intentSender)
} else {
try {
startActivity(intent)
} catch (e: Exception) {
callback.onFailure(e.message)
return
}
callback.onSuccess()
}
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
job.cancel() job.cancel()
} }
private suspend fun getString(res: StringResource) =
org.jetbrains.compose.resources.getString(res)
} }

View File

@ -3,4 +3,5 @@ package com.artemchep.keyguard.common.model
data class AutofillTarget( data class AutofillTarget(
val links: List<LinkInfoPlatform>, val links: List<LinkInfoPlatform>,
val hints: List<AutofillHint>, val hints: List<AutofillHint>,
val maxCount: Int = -1,
) )

View File

@ -477,17 +477,28 @@ fun DSecret.contains(hint: AutofillHint) = when (hint) {
AutofillHint.POSTAL_ADDRESS_EXTENDED_POSTAL_CODE -> false // not supported AutofillHint.POSTAL_ADDRESS_EXTENDED_POSTAL_CODE -> false // not supported
AutofillHint.POSTAL_ADDRESS_APT_NUMBER -> identity?.address1 != null AutofillHint.POSTAL_ADDRESS_APT_NUMBER -> identity?.address1 != null
AutofillHint.POSTAL_ADDRESS_DEPENDENT_LOCALITY -> false // not supported AutofillHint.POSTAL_ADDRESS_DEPENDENT_LOCALITY -> false // not supported
AutofillHint.PERSON_NAME -> identity?.firstName != null AutofillHint.PERSON_NAME -> identity?.firstName != null ||
AutofillHint.PERSON_NAME_GIVEN -> false // not supported identity?.middleName != null ||
identity?.lastName != null
AutofillHint.PERSON_NAME_GIVEN -> identity?.firstName != null
AutofillHint.PERSON_NAME_FAMILY -> identity?.lastName != null AutofillHint.PERSON_NAME_FAMILY -> identity?.lastName != null
AutofillHint.PERSON_NAME_MIDDLE -> identity?.middleName != null AutofillHint.PERSON_NAME_MIDDLE -> identity?.middleName != null
AutofillHint.PERSON_NAME_MIDDLE_INITIAL -> identity?.middleName != null AutofillHint.PERSON_NAME_MIDDLE_INITIAL -> identity?.middleName != null
AutofillHint.PERSON_NAME_PREFIX -> false // not supported AutofillHint.PERSON_NAME_PREFIX -> false // not supported
AutofillHint.PERSON_NAME_SUFFIX -> false // not supported AutofillHint.PERSON_NAME_SUFFIX -> false // not supported
AutofillHint.PHONE_NUMBER -> identity?.phone != null AutofillHint.PHONE_NUMBER -> identity?.phone != null ||
AutofillHint.PHONE_NUMBER_DEVICE -> false // not supported login?.username?.takeIf { REGEX_PHONE_NUMBER.matches(it) } != null
AutofillHint.PHONE_COUNTRY_CODE -> identity?.phone != null // TODO: Extract country code
AutofillHint.PHONE_NATIONAL -> false // not supported AutofillHint.PHONE_NUMBER_DEVICE -> identity?.phone != null ||
login?.username?.takeIf { REGEX_PHONE_NUMBER.matches(it) } != null
AutofillHint.PHONE_COUNTRY_CODE -> identity?.phone != null ||
login?.username?.takeIf { REGEX_PHONE_NUMBER.matches(it) } != null
AutofillHint.PHONE_NATIONAL -> identity?.phone != null ||
login?.username?.takeIf { REGEX_PHONE_NUMBER.matches(it) } != null
AutofillHint.NEW_USERNAME -> false // not supported AutofillHint.NEW_USERNAME -> false // not supported
AutofillHint.NEW_PASSWORD -> false // not supported AutofillHint.NEW_PASSWORD -> false // not supported
AutofillHint.GENDER -> false // not supported AutofillHint.GENDER -> false // not supported
@ -529,8 +540,13 @@ fun DSecret.get(
AutofillHint.POSTAL_ADDRESS_EXTENDED_POSTAL_CODE -> null AutofillHint.POSTAL_ADDRESS_EXTENDED_POSTAL_CODE -> null
AutofillHint.POSTAL_ADDRESS_APT_NUMBER -> identity?.address1 AutofillHint.POSTAL_ADDRESS_APT_NUMBER -> identity?.address1
AutofillHint.POSTAL_ADDRESS_DEPENDENT_LOCALITY -> null AutofillHint.POSTAL_ADDRESS_DEPENDENT_LOCALITY -> null
AutofillHint.PERSON_NAME -> identity?.firstName AutofillHint.PERSON_NAME -> listOfNotNull(
AutofillHint.PERSON_NAME_GIVEN -> null identity?.firstName,
identity?.middleName,
identity?.lastName,
).joinToString(separator = " ").takeIf { it.isNotEmpty() }
AutofillHint.PERSON_NAME_GIVEN -> identity?.firstName
AutofillHint.PERSON_NAME_FAMILY -> identity?.lastName AutofillHint.PERSON_NAME_FAMILY -> identity?.lastName
AutofillHint.PERSON_NAME_MIDDLE -> identity?.middleName AutofillHint.PERSON_NAME_MIDDLE -> identity?.middleName
AutofillHint.PERSON_NAME_MIDDLE_INITIAL -> identity?.middleName AutofillHint.PERSON_NAME_MIDDLE_INITIAL -> identity?.middleName
@ -541,7 +557,9 @@ fun DSecret.get(
AutofillHint.PHONE_NUMBER_DEVICE -> null AutofillHint.PHONE_NUMBER_DEVICE -> null
AutofillHint.PHONE_COUNTRY_CODE -> identity?.phone // TODO: Extract country code AutofillHint.PHONE_COUNTRY_CODE -> identity?.phone // TODO: Extract country code
AutofillHint.PHONE_NATIONAL -> null AutofillHint.PHONE_NATIONAL -> identity?.phone
?: login?.username?.takeIf { REGEX_PHONE_NUMBER.matches(it) }
AutofillHint.NEW_USERNAME -> null AutofillHint.NEW_USERNAME -> null
AutofillHint.NEW_PASSWORD -> null AutofillHint.NEW_PASSWORD -> null
AutofillHint.GENDER -> null AutofillHint.GENDER -> null
@ -592,9 +610,9 @@ fun DSecret.gett(
val variant = variants.removeFirst() val variant = variants.removeFirst()
val value = get( val value = get(
hint = variant.hint, hint = variant.hint,
getTotpCode = getTotpCode, getTotpCode = getTotpCode,
).attempt().bind().getOrNull() ).attempt().bind().getOrNull()
if (value.isNullOrEmpty()) { if (value.isNullOrEmpty()) {
shouldLoop = shouldLoop || variants.isNotEmpty() shouldLoop = shouldLoop || variants.isNotEmpty()
return@forEach return@forEach