From 6321b275993b3e4653d02c6f1dc0fe924ca83218 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Thu, 7 Aug 2025 18:26:49 +0200 Subject: [PATCH 01/91] [Autofill] Add extraction --- app/src/main/AndroidManifest.xml | 9 ++ .../de/davis/keygo/app/KeyGoApplication.kt | 12 +- .../main/kotlin/de/davis/keygo/app/di/Koin.kt | 2 + .../davis/keygo/autofill/di/AutofillModule.kt | 8 ++ .../keygo/autofill/domain/alias/Handle.kt | 5 + .../autofill/domain/model/FieldFeatures.kt | 7 + .../keygo/autofill/domain/model/FieldType.kt | 13 ++ .../domain/usecase/ClassificationUseCase.kt | 86 ++++++++++++ .../keygo/autofill/presentation/Extractor.kt | 122 ++++++++++++++++++ .../presentation/KeyGoAutofillService.kt | 78 +++++++++++ .../mapper/ViewNodeToFieldFeaturesMapper.kt | 31 +++++ .../presentation/model/ExtractedField.kt | 13 ++ .../autofill/presentation/model/Extraction.kt | 10 ++ 13 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/domain/alias/Handle.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldFeatures.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/ExtractedField.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1ea35187..a9072121 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,15 @@ android:scheme="otpauth" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt b/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt index fca42191..3b837392 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt @@ -1,5 +1,15 @@ package de.davis.keygo.app import android.app.Application +import de.davis.keygo.app.di.init +import org.koin.core.context.startKoin -class KeyGoApplication : Application() \ No newline at end of file +class KeyGoApplication : Application() { + + override fun onCreate() { + super.onCreate() + startKoin { + init(this@KeyGoApplication) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/app/di/Koin.kt b/app/src/main/kotlin/de/davis/keygo/app/di/Koin.kt index 4e7342f4..21920efe 100644 --- a/app/src/main/kotlin/de/davis/keygo/app/di/Koin.kt +++ b/app/src/main/kotlin/de/davis/keygo/app/di/Koin.kt @@ -2,6 +2,7 @@ package de.davis.keygo.app.di import android.content.Context import de.davis.keygo.auth.di.AuthModule +import de.davis.keygo.autofill.di.AutofillModule import de.davis.keygo.core.data.local.datasource.KeyGoDatabase import de.davis.keygo.core.di.CoreModule import de.davis.keygo.dashboard.di.DashboardModule @@ -23,6 +24,7 @@ fun KoinApplication.init(androidContext: Context) { DashboardModule.module, ItemModule.module, TotpModule.module, + AutofillModule.module, MigrationCreateAccessModule.module ) diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt b/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt new file mode 100644 index 00000000..cf129497 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.autofill.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module + +@Module +@ComponentScan("de.davis.keygo.autofill.**") +object AutofillModule \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/alias/Handle.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/alias/Handle.kt new file mode 100644 index 00000000..0ad947c5 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/alias/Handle.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.autofill.domain.alias + +import java.util.UUID + +typealias Handle = UUID \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldFeatures.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldFeatures.kt new file mode 100644 index 00000000..4733bdeb --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldFeatures.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.autofill.domain.model + +data class FieldFeatures( + val autofillHints: Set, + val htmlAttributes: Map, + val tokens: Set, +) \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt new file mode 100644 index 00000000..1c8da252 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.autofill.domain.model + +sealed interface FieldType { + sealed interface Identifier : FieldType { + data object Username : Identifier + data object EMail : Identifier + data object Phone : Identifier + } + + data object Password : FieldType + + data object Undefined : FieldType +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt new file mode 100644 index 00000000..4c99af72 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt @@ -0,0 +1,86 @@ +package de.davis.keygo.autofill.domain.usecase + +import android.view.View +import de.davis.keygo.autofill.domain.model.FieldFeatures +import de.davis.keygo.autofill.domain.model.FieldType +import org.koin.core.annotation.Single + +@Single +internal class ClassificationUseCase { + + operator fun invoke(features: FieldFeatures): FieldType { + var type = classifyTokens(features.autofillHints) + if (type !is FieldType.Undefined) return type + + type = classifyHtmlAttributes(features.htmlAttributes) + if (type !is FieldType.Undefined) return type + + //TODO: handle input types + + type = classifyTokens(features.tokens) + if (type !is FieldType.Undefined) return type + + return type + } + + private fun classifyHtmlAttributes(htmlAttributes: Map): FieldType { + // TODO: handle computed-autofill-hints (only Chromium) --> components/autofill/core/browser/field_types.h + val type = htmlAttributes["type"] + if (type == null) return FieldType.Undefined + + when (type) { + "password" -> return FieldType.Password + "email" -> return FieldType.Identifier.EMail + } + + return FieldType.Undefined + } + + private fun classifyTokens(tokens: Set): FieldType { + tokens.forEach { + when (val type = classifyToken(it)) { + FieldType.Undefined -> {} + else -> return type + } + } + + return FieldType.Undefined + } + + private fun classifyToken(token: String): FieldType { + val type = when (token) { + View.AUTOFILL_HINT_USERNAME -> FieldType.Identifier.Username + View.AUTOFILL_HINT_PHONE -> FieldType.Identifier.Phone + View.AUTOFILL_HINT_EMAIL_ADDRESS -> FieldType.Identifier.EMail + + View.AUTOFILL_HINT_PASSWORD -> FieldType.Password + + else -> FieldType.Undefined + } + + if (type !is FieldType.Undefined) + return type + + if (USERNAME_REGEX.containsMatchIn(token)) return FieldType.Identifier.Username + if (EMAIL_REGEX.containsMatchIn(token)) return FieldType.Identifier.EMail + + return FieldType.Undefined + } + + companion object { + // TODO: use more sophisticated regexes + val USERNAME_REGEX = Regex( + "\\b(username|login|user|usuario|nombredeusuario|nomd'utilisateur|" + + "benutzername|nomeusuario|nomeutente|imyapolzovatelya|yonghuming|" + + "yūzāmei|ismalmustakhdim)" + + "(\\b|$)", RegexOption.IGNORE_CASE + ) + + val EMAIL_REGEX = Regex( + "\\b(email|account|correo|cuenta|adressemail|compte|" + + "emailadresse|konto|conta|uchetnayazapis|elektronnayapochta|" + + "zhanghu|dianziyoujian|akaunto|mēruadoresu|alhisab|albaridal(')?iliktruni)" + + "(\\b|$)", RegexOption.IGNORE_CASE + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt new file mode 100644 index 00000000..628d22a3 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt @@ -0,0 +1,122 @@ +package de.davis.keygo.autofill.presentation + +import android.app.assist.AssistStructure +import android.os.Build +import android.util.Log +import android.view.View +import android.widget.AutoCompleteTextView +import android.widget.EditText +import android.widget.MultiAutoCompleteTextView +import android.widget.TextView +import de.davis.keygo.autofill.domain.alias.Handle +import de.davis.keygo.autofill.domain.model.FieldType +import de.davis.keygo.autofill.domain.usecase.ClassificationUseCase +import de.davis.keygo.autofill.presentation.mapper.toFieldFeatures +import de.davis.keygo.autofill.presentation.model.ExtractedField +import de.davis.keygo.autofill.presentation.model.Extraction +import org.koin.core.annotation.Single + +@Single +internal class Extractor(private val classificationUseCase: ClassificationUseCase) { + + fun extractRelevant( + node: AssistStructure.ViewNode, + manualRequest: Boolean + ): Extraction { + val result = mutableListOf() + val urls = mutableSetOf() + traverse(node, manualRequest, urls, result) + + return Extraction(urls = urls, fields = result) + } + + private fun traverse( + node: AssistStructure.ViewNode, + manualRequest: Boolean, + outUrls: MutableSet, + outFields: MutableList + ) { + node.getUrl()?.let { + outUrls += it + } + + if (isSignalLeaf(node)) { + if (node.autofillId == null) + return + + val isImportant = node.isImportantForAutofill() || manualRequest + if (!isImportant && !node.isEditableView()) { + Log.d( + TAG, + "Skipping node [neither important nor editable]: ${node.className}" + ) + return + } + + val features = node.toFieldFeatures() + val type = classificationUseCase(features) + if (type is FieldType.Undefined) { + Log.d( + TAG, + "Skipping node [undefined type]: ${node.className} with $features" + ) + return + } + + outFields += ExtractedField( + handle = Handle.randomUUID(), + autofillId = node.autofillId!!, + features = features, + type = type, + ) + return + } + + (0 until node.childCount).forEach { + traverse(node.getChildAt(it), manualRequest, outUrls, outFields) + } + } + + private fun isSignalLeaf(node: AssistStructure.ViewNode): Boolean { + val noChildren = node.childCount == 0 + val onlyCosmeticChild = node.childCount == 1 + && node.getChildAt(0).className == TextView::class.qualifiedName + && node.getChildAt(0).childCount == 0 + && node.getChildAt(0).text.isNullOrBlank() + && node.getChildAt(0).autofillHints?.all { it.isNullOrBlank() } == true + + return noChildren || onlyCosmeticChild + } + + private fun AssistStructure.ViewNode.isImportantForAutofill(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + when (importantForAutofill) { + View.IMPORTANT_FOR_AUTOFILL_AUTO, + View.IMPORTANT_FOR_AUTOFILL_YES, + View.IMPORTANT_FOR_AUTOFILL_YES_EXCLUDE_DESCENDANTS -> true + + else -> false + } + else true + + + private fun AssistStructure.ViewNode.isEditableView(): Boolean = isEnabled && when (className) { + EditText::class.qualifiedName, + MultiAutoCompleteTextView::class.qualifiedName, + AutoCompleteTextView::class.qualifiedName -> true + + else -> false + } + + private fun AssistStructure.ViewNode.getUrl() = webDomain + ?.takeIf { it.isNotBlank() } + ?.let { + val scheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) webScheme ?: "https" + else "https" + "$scheme://$it" + } + + companion object { + private const val TAG = "Extractor" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt new file mode 100644 index 00000000..08139fe6 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt @@ -0,0 +1,78 @@ +package de.davis.keygo.autofill.presentation + +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import android.util.Log +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject + + +class KeyGoAutofillService : AutofillService() { + + private val extractor by inject() + + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + val structure = request.fillContexts.lastOrNull()?.structure + if (structure == null) { + callback.onSuccess(null) + return + } + + val windowNode = (0 until structure.windowNodeCount).mapNotNull { + structure.getWindowNodeAt(it) + }.lastOrNull() + if (windowNode == null) { + callback.onSuccess(null) + return + } + + if (windowNode.title.split("/").first() == packageName) { + callback.onSuccess(null) + return + } + + val handler = CoroutineExceptionHandler { _, exception -> + Log.w(TAG, "Error during autofill extraction", exception) + callback.onSuccess(null) + } + + val job = CoroutineScope(Dispatchers.IO + handler).launch { + val extraction = + extractor.extractRelevant(windowNode.rootViewNode, manualRequest = false) + + if (!extraction.hasFields()) { + callback.onSuccess(null) + return@launch + } + + + + Log.d(TAG, "Extracted fields: $extraction") + callback.onSuccess(null) + } + + cancellationSignal.setOnCancelListener { + job.cancel() + } + } + + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + callback.onFailure("[Saving] Not supported yet.") + } + + companion object { + private const val TAG = "KeyGoAutofillService" + } +} + diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt new file mode 100644 index 00000000..f7b7ba4e --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.autofill.presentation.mapper + +import android.app.assist.AssistStructure +import de.davis.keygo.autofill.domain.model.FieldFeatures + +internal fun AssistStructure.ViewNode.toFieldFeatures(): FieldFeatures { + val autofillHints = autofillHints + ?.filterNot { it.isNullOrBlank() } + ?.toSet() + ?: emptySet() + + val htmlAttributes = htmlInfo + ?.attributes + ?.filterNot { it.first.isNullOrBlank() || it.second.isNullOrBlank() } + ?.associate { it.first to it.second } + ?: emptyMap() + + val tokens = listOfNotNull( + idEntry?.ifBlank { null }, + hint?.ifBlank { null }, + text?.ifBlank { null }, + ).map(CharSequence::toString) + .map(String::lowercase).toSet() + + + return FieldFeatures( + autofillHints = autofillHints, + htmlAttributes = htmlAttributes, + tokens = tokens, + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/ExtractedField.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/ExtractedField.kt new file mode 100644 index 00000000..20b89a34 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/ExtractedField.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.autofill.presentation.model + +import android.view.autofill.AutofillId +import de.davis.keygo.autofill.domain.alias.Handle +import de.davis.keygo.autofill.domain.model.FieldFeatures +import de.davis.keygo.autofill.domain.model.FieldType + +data class ExtractedField( + val handle: Handle, + val autofillId: AutofillId, + val features: FieldFeatures, + val type: FieldType, +) \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt new file mode 100644 index 00000000..fa331619 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.autofill.presentation.model + +data class Extraction( + val fields: List, + val urls: Set +) { + fun hasFields(): Boolean { + return fields.isNotEmpty() + } +} \ No newline at end of file From 6c891082511b2af23c71f64713e136f8991cdc74 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Fri, 8 Aug 2025 22:43:45 +0200 Subject: [PATCH 02/91] [Autofill] Add inline suggestions --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 3 + .../presentation/AutofillDatasetProvider.kt | 35 ++++++ .../presentation/KeyGoAutofillService.kt | 10 +- .../autofill/presentation/PendingIntentExt.kt | 22 ++++ .../presentation/dataset/DatasetBuilder.kt | 14 +++ .../dataset/DatasetBuilderApi33Impl.kt | 40 ++++++ .../dataset/inline/InlineDatasetBuilder.kt | 117 ++++++++++++++++++ .../dataset/inline/InlineSuggestionFactory.kt | 72 +++++++++++ .../keygo/core/data/local/dao/PasswordDao.kt | 17 ++- .../data/repository/PasswordRepositoryImpl.kt | 12 ++ .../domain/repository/PasswordRepository.kt | 2 + .../main/res/xml/keygo_autofill_service.xml | 3 + gradle/libs.versions.toml | 3 + 14 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt create mode 100644 app/src/main/res/xml/keygo_autofill_service.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 97b59f1e..808e03fa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,8 @@ dependencies { implementation(libs.offrange.passgen) implementation(libs.argon2kt) + implementation(libs.androidx.autofill) + // Use GMS ML Kit for barcode scanning on the Play Store to minimize app size; use ZXing for F-Droid builds. "playStoreImplementation"(libs.gms.mlkit.barcode.scanning) "fdroidImplementation"(libs.zxing.barcode.scanning) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a9072121..f8d87e7c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,6 +48,9 @@ android:name=".autofill.presentation.KeyGoAutofillService" android:exported="true" android:permission="android.permission.BIND_AUTOFILL_SERVICE"> + diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt new file mode 100644 index 00000000..600a01c4 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.autofill.presentation + +import android.os.Build +import android.service.autofill.Dataset +import android.service.autofill.FillRequest +import androidx.annotation.ChecksSdkIntAtLeast +import de.davis.keygo.autofill.presentation.dataset.inline.InlineDatasetBuilder +import de.davis.keygo.autofill.presentation.model.Extraction +import org.koin.core.annotation.Single + +@Single +internal class AutofillDatasetProvider( + private val inlineDatasetBuilder: InlineDatasetBuilder, +) { + + suspend fun getAutofillDataset(request: FillRequest, extraction: Extraction): List { + if (systemSupportsInlineSuggestions(request)) + return inlineDatasetBuilder.buildInlineDatasets( + specs = request.inlineSuggestionsRequest!!.inlinePresentationSpecs, + extraction = extraction + ) + + TODO() + } + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R) + private fun systemSupportsInlineSuggestions(request: FillRequest): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + request.inlineSuggestionsRequest?.let { + val maxSuggestion = it.maxSuggestionCount + val specCount = it.inlinePresentationSpecs.count() + maxSuggestion > 0 && specCount > 0 + } ?: false + else false +} diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt index 08139fe6..d2de83fd 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt @@ -4,6 +4,7 @@ import android.os.CancellationSignal import android.service.autofill.AutofillService import android.service.autofill.FillCallback import android.service.autofill.FillRequest +import android.service.autofill.FillResponse import android.service.autofill.SaveCallback import android.service.autofill.SaveRequest import android.util.Log @@ -17,6 +18,7 @@ import org.koin.android.ext.android.inject class KeyGoAutofillService : AutofillService() { private val extractor by inject() + private val datasetProvider by inject() override fun onFillRequest( request: FillRequest, @@ -56,10 +58,14 @@ class KeyGoAutofillService : AutofillService() { return@launch } + Log.d(TAG, "Extracted fields: $extraction") + val dataset = datasetProvider.getAutofillDataset(request, extraction) + val response = FillResponse.Builder().apply { + dataset.forEach(::addDataset) + }.build() + callback.onSuccess(response) - Log.d(TAG, "Extracted fields: $extraction") - callback.onSuccess(null) } cancellationSignal.setOnCancelListener { diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt new file mode 100644 index 00000000..14a7f972 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt @@ -0,0 +1,22 @@ +package de.davis.keygo.autofill.presentation + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent + +private const val AUTOFILL_PENDING_INTENT_FLAGS = + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT + +internal fun Context.getSelectionPendingIntent() = PendingIntent.getActivity( + this, + 1001, + packageManager.getLaunchIntentForPackage(packageName), + AUTOFILL_PENDING_INTENT_FLAGS +) + +internal fun Context.getOnLongClickPendingIntent() = PendingIntent.getService( + this, + 0, + Intent(), + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE +) \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt new file mode 100644 index 00000000..22444a37 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.autofill.presentation.dataset + +import android.content.IntentSender +import android.service.autofill.Dataset +import android.service.autofill.InlinePresentation +import de.davis.keygo.autofill.presentation.model.Extraction + +internal interface DatasetBuilder { + fun buildDataset( + inlinePresentation: InlinePresentation, + intentSender: IntentSender, + extraction: Extraction + ): Dataset +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt new file mode 100644 index 00000000..cf6fa26d --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.autofill.presentation.dataset + +import android.content.IntentSender +import android.os.Build +import android.service.autofill.Dataset +import android.service.autofill.Field +import android.service.autofill.InlinePresentation +import android.service.autofill.Presentations +import androidx.annotation.RequiresApi +import de.davis.keygo.autofill.presentation.model.Extraction +import org.koin.core.annotation.Single + +@Single +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal class DatasetBuilderApi33Impl : DatasetBuilder { + + override fun buildDataset( + inlinePresentation: InlinePresentation, + intentSender: IntentSender, + extraction: Extraction + ): Dataset { + val presentation = Presentations.Builder().apply { + setInlinePresentation(inlinePresentation) + }.build() + + return Dataset.Builder(presentation).apply { + setAuthentication(intentSender) + applyExtraction(extraction) + }.build() + } + + private fun Dataset.Builder.applyExtraction(extraction: Extraction) { + extraction.fields.forEach { + setField( + it.autofillId, + Field.Builder().build() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt new file mode 100644 index 00000000..16e63f4d --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt @@ -0,0 +1,117 @@ +package de.davis.keygo.autofill.presentation.dataset.inline + +import android.content.Context +import android.graphics.BlendMode +import android.graphics.drawable.Icon +import android.os.Build +import android.service.autofill.Dataset +import android.service.autofill.InlinePresentation +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.RequiresApi +import de.davis.keygo.R +import de.davis.keygo.autofill.presentation.dataset.DatasetBuilder +import de.davis.keygo.autofill.presentation.getOnLongClickPendingIntent +import de.davis.keygo.autofill.presentation.getSelectionPendingIntent +import de.davis.keygo.autofill.presentation.model.Extraction +import de.davis.keygo.core.domain.model.Password +import de.davis.keygo.core.domain.repository.PasswordRepository +import org.koin.core.annotation.Single + +@Single // TODO handle sdk api +@RequiresApi(Build.VERSION_CODES.R) +internal class InlineDatasetBuilder( + private val inlineSuggestionFactory: InlineSuggestionFactory, + private val datasetBuilder: DatasetBuilder, + private val passwordRepository: PasswordRepository, + private val context: Context, +) { + + suspend fun buildInlineDatasets( + specs: List, + extraction: Extraction + ): List = when (specs.size) { + 0 -> emptyList() + 1 -> listOf(buildPinnedInlineSuggestionDataset(specs.first(), extraction)) + else -> { + val suggestions = findPasswordsSuggestions(extraction, count = specs.size - 2) + + suggestions.mapIndexed { index, suggestion -> + buildInlineSuggestionDataset( + spec = specs[index], + extraction = extraction, + suggestion = suggestion + ) + } + listOf( + buildAppInlineSuggestionDataset( + spec = specs.dropLast(1).last(), + extraction = extraction, + ), + buildPinnedInlineSuggestionDataset(spec = specs.last(), extraction = extraction) + ) + } + } + + private fun buildPinnedInlineSuggestionDataset( + spec: InlinePresentationSpec, + extraction: Extraction + ): Dataset { + val presentation = inlineSuggestionFactory.buildPinnedPresentation( + spec = spec, + pendingIntent = context.getOnLongClickPendingIntent(), + icon = appIcon() + ) + + return presentation.buildDataset(extraction) + } + + private fun buildAppInlineSuggestionDataset( + spec: InlinePresentationSpec, + extraction: Extraction + ): Dataset { + val presentation = inlineSuggestionFactory.buildPresentation( + spec = spec, + pendingIntent = context.getOnLongClickPendingIntent(), + icon = appIcon(), + title = context.getString(R.string.app_name) + ) + + return presentation.buildDataset(extraction) + } + + private fun buildInlineSuggestionDataset( + spec: InlinePresentationSpec, + extraction: Extraction, + suggestion: Password + ): Dataset { + val presentation = inlineSuggestionFactory.buildPresentation( + spec = spec, + pendingIntent = context.getOnLongClickPendingIntent(), + title = suggestion.name, + subtitle = suggestion.username ?: "----", + ) + + return presentation.buildDataset(extraction) + } + + private fun InlinePresentation.buildDataset(extraction: Extraction) = + datasetBuilder.buildDataset( + inlinePresentation = this, + intentSender = context.getSelectionPendingIntent().intentSender, + extraction = extraction + ) + + private suspend fun findPasswordsSuggestions( + extraction: Extraction, + count: Int + ): List { + if (count == 0) return emptyList() + return extraction.urls.flatMap { + passwordRepository.findVaultPasswordsByUrl(url = it) + }.take(count) + } + + private fun appIcon(): Icon = + Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply { + setTintBlendMode(BlendMode.DST) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt new file mode 100644 index 00000000..6e453e5e --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt @@ -0,0 +1,72 @@ +package de.davis.keygo.autofill.presentation.dataset.inline + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.graphics.drawable.Icon +import android.os.Build +import android.service.autofill.InlinePresentation +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.RequiresApi +import androidx.autofill.inline.v1.InlineSuggestionUi +import org.koin.core.annotation.Single + +@Single +@RequiresApi(Build.VERSION_CODES.R) +class InlineSuggestionFactory { + + fun buildPresentation( + spec: InlinePresentationSpec, + pendingIntent: PendingIntent, + title: String, + subtitle: String? = null, + icon: Icon? = null + ): InlinePresentation = buildUniversalPresentation( + spec = spec, + pendingIntent = pendingIntent, + title = title, + subtitle = subtitle, + icon = icon + ) + + fun buildPinnedPresentation( + spec: InlinePresentationSpec, + pendingIntent: PendingIntent, + icon: Icon? = null + ): InlinePresentation = buildUniversalPresentation( + spec = spec, + pendingIntent = pendingIntent, + icon = icon, + pinned = true + ) + + @SuppressLint("RestrictedApi") + private fun buildUniversalPresentation( + spec: InlinePresentationSpec, + pendingIntent: PendingIntent, + title: String? = null, + subtitle: String? = null, + icon: Icon? = null, + pinned: Boolean = false + ): InlinePresentation { + val content = InlineSuggestionUi.newContentBuilder(pendingIntent).apply { + title?.let { + setContentDescription(it) + setTitle(it) + } + + subtitle?.let { + setSubtitle(it) + } + + icon?.let { + setStartIcon(it) + } + }.build() + + return InlinePresentation( + content.slice, + spec, + pinned + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/PasswordDao.kt b/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/PasswordDao.kt index 1b4b5592..2f0d61bc 100644 --- a/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/PasswordDao.kt +++ b/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/PasswordDao.kt @@ -21,10 +21,25 @@ internal interface PasswordDao { @Query("SELECT * FROM VaultItemEntity WHERE vault_item_id = :vaultId") fun observeVaultPassword(vaultId: ItemId): Flow + /** + * Finds all [VaultPassword] pairs where the username or website + * contains the given substrings (case-insensitive). + */ // TODO: provide a lighter entity for search results - @Query("SELECT * FROM VaultItemEntity WHERE vault_item_id IN (SELECT vault_item_id FROM PasswordEntity WHERE username LIKE '%' || :username || '%' OR website LIKE '%' || :website || '%')") + // Item (this) - only requires name, username, website, id + // Autofill (findVaultPasswordsByUrl) - only requires name, username, id + // handle lowercase - maybe use a separate lowercase column? + @Query("SELECT * FROM VaultItemEntity WHERE vault_item_id IN (SELECT vault_item_id FROM PasswordEntity WHERE username LIKE '%' || :username || '%' OR website LIKE '%' || :website || '%')") suspend fun searchVaultPassword(username: String?, website: String?): List + /** + * Retrieves all [VaultPassword]s where the stored website + * appears anywhere inside the provided URL (case-insensitive). + */ + @Transaction + @Query("SELECT * FROM VaultItemEntity AS vault JOIN PasswordEntity AS pass ON vault.vault_item_id == pass.vault_item_id WHERE :url LIKE '%' || website || '%' COLLATE NOCASE") + suspend fun findVaultPasswordsByUrl(url: String): List + @Transaction @Query("SELECT * FROM VaultItemEntity WHERE vault_item_id = :vaultId") suspend fun getVaultPassword(vaultId: ItemId): VaultPassword? diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt index 17c068ec..a349d938 100644 --- a/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt +++ b/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt @@ -24,12 +24,24 @@ internal class PasswordRepositoryImpl( vaultPassword.map { it.toDomain() } } + /** + * Finds all [Password] pairs where the username or website + * contains the given **substrings** (case-insensitive). + */ override suspend fun searchVaultPasswords( username: String?, website: String? ): List = passwordDao.searchVaultPassword(username, website) .map(VaultPassword::toDomain) + /** + * Retrieves all [Password]s where the stored website + * appears **anywhere inside** the provided URL (case-insensitive). + */ + override suspend fun findVaultPasswordsByUrl(url: String): List = + passwordDao.findVaultPasswordsByUrl(url) + .map(VaultPassword::toDomain) + override suspend fun getVaultPasswordById(vaultId: ItemId): Password? = passwordDao.getVaultPassword(vaultId)?.toDomain() diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt index a92e68b3..ba03f477 100644 --- a/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt +++ b/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt @@ -14,6 +14,8 @@ interface PasswordRepository { website: String? = null ): List + suspend fun findVaultPasswordsByUrl(url: String): List + suspend fun getVaultPasswordById(vaultId: ItemId): Password? fun observeVaultPasswordById(vaultId: ItemId): Flow } \ No newline at end of file diff --git a/app/src/main/res/xml/keygo_autofill_service.xml b/app/src/main/res/xml/keygo_autofill_service.xml new file mode 100644 index 00000000..eca857d9 --- /dev/null +++ b/app/src/main/res/xml/keygo_autofill_service.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6c8c32a..3f1bc494 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.11.1" argon2kt = "1.6.0" +autofill = "1.3.0" camera = "1.5.0-beta01" gmsMlkitBarcodeScanning = "18.3.1" zxingBarcodeScanning = "3.5.3" @@ -61,6 +62,8 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } +androidx-autofill = { group = "androidx.autofill", name = "autofill", version.ref = "autofill" } + androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 1ffef0cf92019c4651a52abc0417e23e32d47ad3 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 9 Aug 2025 13:34:21 +0200 Subject: [PATCH 03/91] [Autofill] Add menu suggestions for API<30 --- .../davis/keygo/autofill/di/AutofillModule.kt | 13 +++- .../presentation/AutofillDatasetProvider.kt | 4 +- .../presentation/KeyGoAutofillService.kt | 3 +- .../presentation/dataset/DatasetBuilder.kt | 6 +- .../dataset/DatasetBuilderApi33Impl.kt | 15 +++-- .../dataset/DatasetBuilderLegacyImpl.kt | 43 +++++++++++++ .../presentation/dataset/SuggestionFinder.kt | 22 +++++++ .../dataset/inline/InlineDatasetBuilder.kt | 24 +++---- .../dataset/inline/InlineSuggestionFactory.kt | 4 +- .../dataset/menu/MenuDatasetBuilder.kt | 63 +++++++++++++++++++ .../dataset/menu/MenuSuggestionFactory.kt | 27 ++++++++ app/src/main/res/layout/autofill_menu.xml | 32 ++++++++++ app/src/main/res/values/strings.xml | 2 + 13 files changed, 231 insertions(+), 27 deletions(-) create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderLegacyImpl.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SuggestionFinder.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuSuggestionFactory.kt create mode 100644 app/src/main/res/layout/autofill_menu.xml diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt b/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt index cf129497..5b68562e 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt @@ -1,8 +1,19 @@ package de.davis.keygo.autofill.di +import de.davis.keygo.autofill.presentation.dataset.DatasetBuilderApi33Impl +import de.davis.keygo.autofill.presentation.dataset.DatasetBuilderLegacyImpl import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +import org.koin.core.annotation.Single @Module @ComponentScan("de.davis.keygo.autofill.**") -object AutofillModule \ No newline at end of file +object AutofillModule { + + @Single + internal fun provideDatasetBuilder() = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) + DatasetBuilderApi33Impl() + else + DatasetBuilderLegacyImpl() +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt index 600a01c4..765a5f6d 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt @@ -5,12 +5,14 @@ import android.service.autofill.Dataset import android.service.autofill.FillRequest import androidx.annotation.ChecksSdkIntAtLeast import de.davis.keygo.autofill.presentation.dataset.inline.InlineDatasetBuilder +import de.davis.keygo.autofill.presentation.dataset.menu.MenuDatasetBuilder import de.davis.keygo.autofill.presentation.model.Extraction import org.koin.core.annotation.Single @Single internal class AutofillDatasetProvider( private val inlineDatasetBuilder: InlineDatasetBuilder, + private val menuDatasetBuilder: MenuDatasetBuilder, ) { suspend fun getAutofillDataset(request: FillRequest, extraction: Extraction): List { @@ -20,7 +22,7 @@ internal class AutofillDatasetProvider( extraction = extraction ) - TODO() + return menuDatasetBuilder.buildMenuDatasets(extraction = extraction) } @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R) diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt index d2de83fd..ed60187f 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt @@ -14,7 +14,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.android.ext.android.inject - +// TODO: handle authentication settings +// implement smart authentication --> use Digital Asset Links to verify a app - fail should warn user class KeyGoAutofillService : AutofillService() { private val extractor by inject() diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt index 22444a37..ecb195f6 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt @@ -3,12 +3,14 @@ package de.davis.keygo.autofill.presentation.dataset import android.content.IntentSender import android.service.autofill.Dataset import android.service.autofill.InlinePresentation +import android.widget.RemoteViews import de.davis.keygo.autofill.presentation.model.Extraction internal interface DatasetBuilder { fun buildDataset( - inlinePresentation: InlinePresentation, intentSender: IntentSender, - extraction: Extraction + extraction: Extraction, + inlinePresentation: InlinePresentation? = null, + remoteViews: RemoteViews? = null, ): Dataset } \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt index cf6fa26d..324b9337 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt @@ -6,21 +6,23 @@ import android.service.autofill.Dataset import android.service.autofill.Field import android.service.autofill.InlinePresentation import android.service.autofill.Presentations +import android.widget.RemoteViews import androidx.annotation.RequiresApi import de.davis.keygo.autofill.presentation.model.Extraction -import org.koin.core.annotation.Single -@Single +// No Single annotation here, as this class is being provided by the AutofillModule itself @RequiresApi(Build.VERSION_CODES.TIRAMISU) internal class DatasetBuilderApi33Impl : DatasetBuilder { override fun buildDataset( - inlinePresentation: InlinePresentation, intentSender: IntentSender, - extraction: Extraction + extraction: Extraction, + inlinePresentation: InlinePresentation?, + remoteViews: RemoteViews?, ): Dataset { val presentation = Presentations.Builder().apply { - setInlinePresentation(inlinePresentation) + inlinePresentation?.let { setInlinePresentation(it) } + remoteViews?.let { setMenuPresentation(it) } }.build() return Dataset.Builder(presentation).apply { @@ -37,4 +39,5 @@ internal class DatasetBuilderApi33Impl : DatasetBuilder { ) } } -} \ No newline at end of file +} + diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderLegacyImpl.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderLegacyImpl.kt new file mode 100644 index 00000000..1b97cab8 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderLegacyImpl.kt @@ -0,0 +1,43 @@ +package de.davis.keygo.autofill.presentation.dataset + +import android.content.IntentSender +import android.os.Build +import android.service.autofill.Dataset +import android.service.autofill.InlinePresentation +import android.widget.RemoteViews +import androidx.annotation.DeprecatedSinceApi +import de.davis.keygo.autofill.presentation.model.Extraction + +@Suppress("DEPRECATION") +@DeprecatedSinceApi(Build.VERSION_CODES.TIRAMISU) +internal class DatasetBuilderLegacyImpl : DatasetBuilder { + + override fun buildDataset( + intentSender: IntentSender, + extraction: Extraction, + inlinePresentation: InlinePresentation?, + remoteViews: RemoteViews? + ): Dataset { + val builder = remoteViews?.let { + Dataset.Builder(it) + } ?: Dataset.Builder() + + return builder.apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + inlinePresentation?.let { setInlinePresentation(it) } + + setAuthentication(intentSender) + applyExtraction(extraction) + }.build() + } + + + private fun Dataset.Builder.applyExtraction(extraction: Extraction) { + extraction.fields.forEach { + setValue( + it.autofillId, + null + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SuggestionFinder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SuggestionFinder.kt new file mode 100644 index 00000000..c9bf1abf --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SuggestionFinder.kt @@ -0,0 +1,22 @@ +package de.davis.keygo.autofill.presentation.dataset + +import de.davis.keygo.autofill.presentation.model.Extraction +import de.davis.keygo.core.domain.model.Password +import de.davis.keygo.core.domain.repository.PasswordRepository +import org.koin.core.annotation.Single + +@Single +internal class SuggestionFinder( + private val passwordRepository: PasswordRepository, +) { + + internal suspend fun findPasswordsSuggestions( + extraction: Extraction, + count: Int + ): List { + if (count == 0) return emptyList() + return extraction.urls.flatMap { + passwordRepository.findVaultPasswordsByUrl(url = it) + }.take(count) + } +} diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt index 16e63f4d..c90599e8 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt @@ -10,22 +10,22 @@ import android.widget.inline.InlinePresentationSpec import androidx.annotation.RequiresApi import de.davis.keygo.R import de.davis.keygo.autofill.presentation.dataset.DatasetBuilder +import de.davis.keygo.autofill.presentation.dataset.SuggestionFinder import de.davis.keygo.autofill.presentation.getOnLongClickPendingIntent import de.davis.keygo.autofill.presentation.getSelectionPendingIntent import de.davis.keygo.autofill.presentation.model.Extraction import de.davis.keygo.core.domain.model.Password -import de.davis.keygo.core.domain.repository.PasswordRepository import org.koin.core.annotation.Single -@Single // TODO handle sdk api -@RequiresApi(Build.VERSION_CODES.R) +@Single internal class InlineDatasetBuilder( private val inlineSuggestionFactory: InlineSuggestionFactory, private val datasetBuilder: DatasetBuilder, - private val passwordRepository: PasswordRepository, + private val suggestionFinder: SuggestionFinder, private val context: Context, ) { + @RequiresApi(Build.VERSION_CODES.R) suspend fun buildInlineDatasets( specs: List, extraction: Extraction @@ -33,7 +33,8 @@ internal class InlineDatasetBuilder( 0 -> emptyList() 1 -> listOf(buildPinnedInlineSuggestionDataset(specs.first(), extraction)) else -> { - val suggestions = findPasswordsSuggestions(extraction, count = specs.size - 2) + val suggestions = + suggestionFinder.findPasswordsSuggestions(extraction, count = specs.size - 2) suggestions.mapIndexed { index, suggestion -> buildInlineSuggestionDataset( @@ -51,6 +52,7 @@ internal class InlineDatasetBuilder( } } + @RequiresApi(Build.VERSION_CODES.R) private fun buildPinnedInlineSuggestionDataset( spec: InlinePresentationSpec, extraction: Extraction @@ -64,6 +66,7 @@ internal class InlineDatasetBuilder( return presentation.buildDataset(extraction) } + @RequiresApi(Build.VERSION_CODES.R) private fun buildAppInlineSuggestionDataset( spec: InlinePresentationSpec, extraction: Extraction @@ -78,6 +81,7 @@ internal class InlineDatasetBuilder( return presentation.buildDataset(extraction) } + @RequiresApi(Build.VERSION_CODES.R) private fun buildInlineSuggestionDataset( spec: InlinePresentationSpec, extraction: Extraction, @@ -100,16 +104,6 @@ internal class InlineDatasetBuilder( extraction = extraction ) - private suspend fun findPasswordsSuggestions( - extraction: Extraction, - count: Int - ): List { - if (count == 0) return emptyList() - return extraction.urls.flatMap { - passwordRepository.findVaultPasswordsByUrl(url = it) - }.take(count) - } - private fun appIcon(): Icon = Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply { setTintBlendMode(BlendMode.DST) diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt index 6e453e5e..b5ab3ee8 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt @@ -11,9 +11,9 @@ import androidx.autofill.inline.v1.InlineSuggestionUi import org.koin.core.annotation.Single @Single -@RequiresApi(Build.VERSION_CODES.R) class InlineSuggestionFactory { + @RequiresApi(Build.VERSION_CODES.R) fun buildPresentation( spec: InlinePresentationSpec, pendingIntent: PendingIntent, @@ -28,6 +28,7 @@ class InlineSuggestionFactory { icon = icon ) + @RequiresApi(Build.VERSION_CODES.R) fun buildPinnedPresentation( spec: InlinePresentationSpec, pendingIntent: PendingIntent, @@ -40,6 +41,7 @@ class InlineSuggestionFactory { ) @SuppressLint("RestrictedApi") + @RequiresApi(Build.VERSION_CODES.R) private fun buildUniversalPresentation( spec: InlinePresentationSpec, pendingIntent: PendingIntent, diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt new file mode 100644 index 00000000..c93bddd5 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt @@ -0,0 +1,63 @@ +package de.davis.keygo.autofill.presentation.dataset.menu + +import android.content.Context +import android.os.Build +import android.service.autofill.Dataset +import androidx.annotation.DeprecatedSinceApi +import de.davis.keygo.R +import de.davis.keygo.autofill.presentation.dataset.DatasetBuilder +import de.davis.keygo.autofill.presentation.dataset.SuggestionFinder +import de.davis.keygo.autofill.presentation.getSelectionPendingIntent +import de.davis.keygo.autofill.presentation.model.Extraction +import de.davis.keygo.core.domain.model.Password +import org.koin.core.annotation.Single + +@Single +@DeprecatedSinceApi(Build.VERSION_CODES.R) +internal class MenuDatasetBuilder( + private val suggestionFinder: SuggestionFinder, + private val menuDatasetBuilder: MenuSuggestionFactory, + private val datasetBuilder: DatasetBuilder, + private val context: Context +) { + + suspend fun buildMenuDatasets( + extraction: Extraction + ): List { + val suggestions = suggestionFinder.findPasswordsSuggestions(extraction, count = 4) + + return suggestions.map { suggestion -> + buildSuggestionDataset(extraction, suggestion) + } + listOf(buildAppDataset(extraction)) + } + + private fun buildAppDataset(extraction: Extraction): Dataset { + val remoteViews = menuDatasetBuilder.buildMenuSuggestion( + title = context.getString(R.string.app_name), + subtitle = context.getString(R.string.autofill_service), + icon = R.mipmap.ic_launcher_round + ) + + return datasetBuilder.buildDataset( + remoteViews = remoteViews, + intentSender = context.getSelectionPendingIntent().intentSender, + extraction = extraction, + ) + } + + private fun buildSuggestionDataset( + extraction: Extraction, + suggestion: Password + ): Dataset { + val remoteViews = menuDatasetBuilder.buildMenuSuggestion( + title = suggestion.name, + subtitle = suggestion.username ?: "----", + ) + + return datasetBuilder.buildDataset( + remoteViews = remoteViews, + intentSender = context.getSelectionPendingIntent().intentSender, + extraction = extraction, + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuSuggestionFactory.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuSuggestionFactory.kt new file mode 100644 index 00000000..862632d2 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuSuggestionFactory.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.autofill.presentation.dataset.menu + +import android.content.Context +import android.widget.RemoteViews +import androidx.annotation.DrawableRes +import de.davis.keygo.R +import org.koin.core.annotation.Single + +@Single +class MenuSuggestionFactory( + private val context: Context +) { + + fun buildMenuSuggestion( + title: String, + subtitle: String? = null, + @DrawableRes + icon: Int? = null + ): RemoteViews = RemoteViews( + context.packageName, + R.layout.autofill_menu + ).apply { + setTextViewText(R.id.title, title) + subtitle?.let { setTextViewText(R.id.subtitle, it) } + icon?.let { setImageViewResource(R.id.icon, it) } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/autofill_menu.xml b/app/src/main/res/layout/autofill_menu.xml new file mode 100644 index 00000000..7f62d9ad --- /dev/null +++ b/app/src/main/res/layout/autofill_menu.xml @@ -0,0 +1,32 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 145fb314..32e12a6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,4 +127,6 @@ Warning Some items will have identical names. + + Autofill Service \ No newline at end of file From a6c6a4484e273ace738a4070ad7bd23650fc51d3 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 9 Aug 2025 16:01:58 +0200 Subject: [PATCH 04/91] [Autofill] Refactor Identifier to Credentials --- .../keygo/autofill/domain/model/FieldType.kt | 12 ++++++------ .../domain/usecase/ClassificationUseCase.kt | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt index 1c8da252..df440f74 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt @@ -1,13 +1,13 @@ package de.davis.keygo.autofill.domain.model sealed interface FieldType { - sealed interface Identifier : FieldType { - data object Username : Identifier - data object EMail : Identifier - data object Phone : Identifier - } + sealed interface Credentials : FieldType { + data object Username : Credentials + data object EMail : Credentials + data object Phone : Credentials - data object Password : FieldType + data object Password : FieldType + } data object Undefined : FieldType } \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt index 4c99af72..8794d834 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt @@ -29,8 +29,8 @@ internal class ClassificationUseCase { if (type == null) return FieldType.Undefined when (type) { - "password" -> return FieldType.Password - "email" -> return FieldType.Identifier.EMail + "password" -> return FieldType.Credentials.Password + "email" -> return FieldType.Credentials.EMail } return FieldType.Undefined @@ -49,11 +49,11 @@ internal class ClassificationUseCase { private fun classifyToken(token: String): FieldType { val type = when (token) { - View.AUTOFILL_HINT_USERNAME -> FieldType.Identifier.Username - View.AUTOFILL_HINT_PHONE -> FieldType.Identifier.Phone - View.AUTOFILL_HINT_EMAIL_ADDRESS -> FieldType.Identifier.EMail + View.AUTOFILL_HINT_USERNAME -> FieldType.Credentials.Username + View.AUTOFILL_HINT_PHONE -> FieldType.Credentials.Phone + View.AUTOFILL_HINT_EMAIL_ADDRESS -> FieldType.Credentials.EMail - View.AUTOFILL_HINT_PASSWORD -> FieldType.Password + View.AUTOFILL_HINT_PASSWORD -> FieldType.Credentials.Password else -> FieldType.Undefined } @@ -61,8 +61,8 @@ internal class ClassificationUseCase { if (type !is FieldType.Undefined) return type - if (USERNAME_REGEX.containsMatchIn(token)) return FieldType.Identifier.Username - if (EMAIL_REGEX.containsMatchIn(token)) return FieldType.Identifier.EMail + if (USERNAME_REGEX.containsMatchIn(token)) return FieldType.Credentials.Username + if (EMAIL_REGEX.containsMatchIn(token)) return FieldType.Credentials.EMail return FieldType.Undefined } From fee7f520ed0544de36a4010fb8819d082660d7ac Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 10 Aug 2025 14:32:47 +0200 Subject: [PATCH 05/91] [Autofill] Fix password credential type --- .../kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt index df440f74..5ae8443c 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt @@ -6,7 +6,7 @@ sealed interface FieldType { data object EMail : Credentials data object Phone : Credentials - data object Password : FieldType + data object Password : Credentials } data object Undefined : FieldType From e4eb0e76e515ae017ed3ec50c799270bcf834c7c Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 10 Aug 2025 19:29:55 +0200 Subject: [PATCH 06/91] [Autofill] Add compatibility support --- .../main/res/xml/keygo_autofill_service.xml | 277 +++++++++++++++++- 1 file changed, 276 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/xml/keygo_autofill_service.xml b/app/src/main/res/xml/keygo_autofill_service.xml index eca857d9..59a918de 100644 --- a/app/src/main/res/xml/keygo_autofill_service.xml +++ b/app/src/main/res/xml/keygo_autofill_service.xml @@ -1,3 +1,278 @@ \ No newline at end of file + xmlns:tools="http://schemas.android.com/tools" + android:supportsInlineSuggestions="true" + tools:targetApi="33"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From df88f8f1b8b0c9497a253baf9dd7d849fe70eb45 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 10 Aug 2025 19:30:48 +0200 Subject: [PATCH 07/91] [Autofill] Fix extraction for compatibility browsers --- .../keygo/autofill/presentation/Extractor.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt index 628d22a3..ad9d46bf 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt @@ -45,10 +45,18 @@ internal class Extractor(private val classificationUseCase: ClassificationUseCas return val isImportant = node.isImportantForAutofill() || manualRequest - if (!isImportant && !node.isEditableView()) { + if (!isImportant) { Log.d( TAG, - "Skipping node [neither important nor editable]: ${node.className}" + "Skipping node [not important]: ${node.className} - HTML-Tag: ${node.htmlInfo?.tag}" + ) + return + } + + if (!node.looksLikeInput()) { + Log.d( + TAG, + "Skipping node [not input]: ${node.className} - HTML-Tag: ${node.htmlInfo?.tag}" ) return } @@ -99,6 +107,15 @@ internal class Extractor(private val classificationUseCase: ClassificationUseCas } else true + private fun AssistStructure.ViewNode.looksLikeInput(): Boolean { + // HTML signals (native browsers / WebView with HtmlInfo) + val htmlTag = htmlInfo?.tag?.lowercase() + val isHtmlInput = htmlTag == "input" || htmlTag == "textarea" + + // Android widget signals + val isEditableView = isEditableView() + return isHtmlInput || isEditableView + } private fun AssistStructure.ViewNode.isEditableView(): Boolean = isEnabled && when (className) { EditText::class.qualifiedName, From 2b31857d04702c4bad17443ea6e73e3a1b0ed00d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 10 Aug 2025 22:48:58 +0200 Subject: [PATCH 08/91] [Autofill] Implement basic save request logic --- .../presentation/KeyGoAutofillService.kt | 43 ++++++++- .../autofill/presentation/PendingIntentExt.kt | 1 + .../presentation/dataset/SaveInfoAppender.kt | 92 +++++++++++++++++++ .../autofill/presentation/model/Extraction.kt | 5 + 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt index ed60187f..d0b74edc 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt @@ -1,5 +1,7 @@ package de.davis.keygo.autofill.presentation +import android.app.assist.AssistStructure +import android.os.Build import android.os.CancellationSignal import android.service.autofill.AutofillService import android.service.autofill.FillCallback @@ -8,6 +10,9 @@ import android.service.autofill.FillResponse import android.service.autofill.SaveCallback import android.service.autofill.SaveRequest import android.util.Log +import android.view.autofill.AutofillId +import androidx.core.os.bundleOf +import de.davis.keygo.autofill.presentation.dataset.applySaveInfo import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -59,11 +64,21 @@ class KeyGoAutofillService : AutofillService() { return@launch } - Log.d(TAG, "Extracted fields: $extraction") + val inCompatibilityMode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + (request.flags and FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST) != 0 + else true + Log.d(TAG, "In Compatibility Mode: $inCompatibilityMode") + Log.d(TAG, "Extracted fields [${extraction.fields.size}]: $extraction") val dataset = datasetProvider.getAutofillDataset(request, extraction) val response = FillResponse.Builder().apply { dataset.forEach(::addDataset) + applySaveInfo( + extraction = extraction, + clientInfo = request.clientState ?: bundleOf(), + requestId = request.id, + inCompatibilityMode = inCompatibilityMode + ) }.build() callback.onSuccess(response) @@ -75,7 +90,31 @@ class KeyGoAutofillService : AutofillService() { } override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { - callback.onFailure("[Saving] Not supported yet.") + Log.d(TAG, "onSaveRequest called with request: ${request.fillContexts}") + val structure = request.fillContexts.lastOrNull()?.structure + if (structure == null) { + callback.onFailure("No structure found") + return + } + + val clientState = request.clientState + if (clientState == null) { + callback.onFailure("No client state found") + return + } + + // TODO show UI + + callback.onSuccess() + } + + private fun AssistStructure.ViewNode.findChildById(id: AutofillId): AssistStructure.ViewNode? { + if (autofillId == id) return this + for (i in 0 until childCount) { + val child = getChildAt(i).findChildById(id) + if (child != null) return child + } + return null } companion object { diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt index 14a7f972..1e44b38e 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt @@ -7,6 +7,7 @@ import android.content.Intent private const val AUTOFILL_PENDING_INTENT_FLAGS = PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT +// TODO: show UI internal fun Context.getSelectionPendingIntent() = PendingIntent.getActivity( this, 1001, diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt new file mode 100644 index 00000000..5dfadbb4 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt @@ -0,0 +1,92 @@ +package de.davis.keygo.autofill.presentation.dataset + +import android.os.Build +import android.os.Bundle +import android.service.autofill.FillResponse +import android.service.autofill.RegexValidator +import android.service.autofill.SaveInfo +import android.view.autofill.AutofillId +import de.davis.keygo.autofill.domain.model.FieldType +import de.davis.keygo.autofill.presentation.model.Extraction + +internal fun FillResponse.Builder.applySaveInfo( + extraction: Extraction, + clientInfo: Bundle, + requestId: Int, + inCompatibilityMode: Boolean, +) { + val (updatedClientState, saveType) = clientInfo.updateState(requestId, extraction) + val password = updatedClientState.getPasswordId() + val username = updatedClientState.getUsernameId() + val email = updatedClientState.getEmailId() + + val requiredIds = listOfNotNull(password, username, email).toTypedArray() + + val saveInfo = SaveInfo.Builder(saveType, requiredIds).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var flag = if (password == null) SaveInfo.FLAG_DELAY_SAVE else 0 + + if (inCompatibilityMode) + flag = flag or SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE + + println("SaveInfo flags: $flag") + setFlags(flag) + } + + password?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) + RegexValidator(it, "^\\p{ASCII}$".toPattern()) + .also(::setValidator) + } + }.build() + + setSaveInfo(saveInfo) + setClientState(updatedClientState) +} + +private const val KEY_REQUEST_ID = "requestId" +private const val KEY_PASSWORD_ID = "passwordId" +private const val KEY_USERNAME_ID = "usernameId" +private const val KEY_EMAIL_ID = "emailID" +private const val KEY_URL = "url" +private const val KEY_SAVE_TYPE = "saveType" + +internal fun Bundle.updateState( + requestId: Int, + extraction: Extraction, +): Pair { + var saveType = getInt(KEY_SAVE_TYPE, 0) + + return Bundle(this).apply { + putInt(KEY_REQUEST_ID, requestId) + + val credentialFields = extraction.getCredentialFields() + credentialFields.find { it.type == FieldType.Credentials.Password }?.let { + putParcelable(KEY_PASSWORD_ID, it.autofillId) + saveType = saveType or SaveInfo.SAVE_DATA_TYPE_PASSWORD + } + credentialFields.find { it.type == FieldType.Credentials.Username }?.let { + putParcelable(KEY_USERNAME_ID, it.autofillId) + saveType = saveType or SaveInfo.SAVE_DATA_TYPE_USERNAME + } + credentialFields.find { it.type == FieldType.Credentials.EMail }?.let { + putParcelable(KEY_EMAIL_ID, it.autofillId) + saveType = saveType or SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS + } + + extraction.urls.firstOrNull()?.let { + putString(KEY_URL, it) + } + + putInt(KEY_SAVE_TYPE, saveType) + } to saveType +} + +@Suppress("DEPRECATION") +internal fun Bundle.getPasswordId(): AutofillId? = getParcelable(KEY_PASSWORD_ID) + +@Suppress("DEPRECATION") +internal fun Bundle.getUsernameId(): AutofillId? = getParcelable(KEY_USERNAME_ID) + +@Suppress("DEPRECATION") +internal fun Bundle.getEmailId(): AutofillId? = getParcelable(KEY_EMAIL_ID) \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt index fa331619..3802f5b7 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt @@ -1,5 +1,7 @@ package de.davis.keygo.autofill.presentation.model +import de.davis.keygo.autofill.domain.model.FieldType + data class Extraction( val fields: List, val urls: Set @@ -7,4 +9,7 @@ data class Extraction( fun hasFields(): Boolean { return fields.isNotEmpty() } + + fun getCredentialFields(): List = + fields.filter { it.type is FieldType.Credentials } } \ No newline at end of file From 6667ec0ba4051fedf37543090f9734374f500f8e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 18 Aug 2025 20:01:38 +0200 Subject: [PATCH 09/91] [Autofill] Fix unintended one shot --- .../de/davis/keygo/autofill/presentation/PendingIntentExt.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt index 1e44b38e..685f7495 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent private const val AUTOFILL_PENDING_INTENT_FLAGS = - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT // TODO: show UI internal fun Context.getSelectionPendingIntent() = PendingIntent.getActivity( From e44179f1a0be04fc92e74bb3902ee5d6e46fdb29 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Thu, 21 Aug 2025 21:43:35 +0200 Subject: [PATCH 10/91] [Core] Move docs --- .../keygo/core/data/repository/PasswordRepositoryImpl.kt | 8 -------- .../keygo/core/domain/repository/PasswordRepository.kt | 8 ++++++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt index a349d938..e0233ab4 100644 --- a/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt +++ b/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt @@ -24,20 +24,12 @@ internal class PasswordRepositoryImpl( vaultPassword.map { it.toDomain() } } - /** - * Finds all [Password] pairs where the username or website - * contains the given **substrings** (case-insensitive). - */ override suspend fun searchVaultPasswords( username: String?, website: String? ): List = passwordDao.searchVaultPassword(username, website) .map(VaultPassword::toDomain) - /** - * Retrieves all [Password]s where the stored website - * appears **anywhere inside** the provided URL (case-insensitive). - */ override suspend fun findVaultPasswordsByUrl(url: String): List = passwordDao.findVaultPasswordsByUrl(url) .map(VaultPassword::toDomain) diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt index ba03f477..73c1ddcf 100644 --- a/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt +++ b/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt @@ -9,11 +9,19 @@ interface PasswordRepository { suspend fun createNewOrUpdatePassword(password: Password): ItemId fun observeVaultPasswords(): Flow> + /** + * Finds all [Password] pairs where the username or website + * contains the given **substrings** (case-insensitive). + */ suspend fun searchVaultPasswords( username: String? = null, website: String? = null ): List + /** + * Retrieves all [Password]s where the stored website + * appears **anywhere inside** the provided URL (case-insensitive). + */ suspend fun findVaultPasswordsByUrl(url: String): List suspend fun getVaultPasswordById(vaultId: ItemId): Password? From 412e01e539f4b637d763ef173c25c636d692cef2 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 23 Aug 2025 23:25:04 +0200 Subject: [PATCH 11/91] [Autofill] Add filling feature prove-of-concept --- app/src/main/AndroidManifest.xml | 6 + .../keygo/autofill/domain/alias/Handle.kt | 5 - .../autofill/domain/model/FieldFeatures.kt | 7 - .../presentation/AutofillDatasetProvider.kt | 48 +++++- .../Classifier.kt} | 42 +++-- .../keygo/autofill/presentation/Extractor.kt | 9 +- .../presentation/KeyGoAutofillService.kt | 2 +- .../autofill/presentation/PendingIntentExt.kt | 20 ++- .../presentation/activity/AutofillActivity.kt | 143 ++++++++++++++++++ .../activity/AutofillViewModel.kt | 67 ++++++++ .../presentation/dataset/SaveInfoAppender.kt | 2 +- .../dataset/inline/InlineDatasetBuilder.kt | 17 ++- .../dataset/menu/MenuDatasetBuilder.kt | 18 ++- .../mapper/ViewNodeToFieldFeaturesMapper.kt | 2 +- .../presentation/model/AutofillEvent.kt | 10 ++ .../presentation/model/AutofillInformation.kt | 11 ++ .../presentation/model/AutofillValue.kt | 8 + .../presentation/model/ExtractedField.kt | 9 +- .../autofill/presentation/model/Extraction.kt | 6 +- .../presentation/model/FieldFeatures.kt | 11 ++ .../model/FieldType.kt | 10 +- app/src/main/res/values/themes.xml | 9 ++ 22 files changed, 393 insertions(+), 69 deletions(-) delete mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/domain/alias/Handle.kt delete mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldFeatures.kt rename app/src/main/kotlin/de/davis/keygo/autofill/{domain/usecase/ClassificationUseCase.kt => presentation/Classifier.kt} (64%) create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillActivity.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillViewModel.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillEvent.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillInformation.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillValue.kt create mode 100644 app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldFeatures.kt rename app/src/main/kotlin/de/davis/keygo/autofill/{domain => presentation}/model/FieldType.kt (58%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f8d87e7c..6f0cb358 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,6 +55,12 @@ + + \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/alias/Handle.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/alias/Handle.kt deleted file mode 100644 index 0ad947c5..00000000 --- a/app/src/main/kotlin/de/davis/keygo/autofill/domain/alias/Handle.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.davis.keygo.autofill.domain.alias - -import java.util.UUID - -typealias Handle = UUID \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldFeatures.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldFeatures.kt deleted file mode 100644 index 4733bdeb..00000000 --- a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldFeatures.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.davis.keygo.autofill.domain.model - -data class FieldFeatures( - val autofillHints: Set, - val htmlAttributes: Map, - val tokens: Set, -) \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt index 765a5f6d..76489bfb 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt @@ -1,21 +1,28 @@ package de.davis.keygo.autofill.presentation +import android.content.Context import android.os.Build import android.service.autofill.Dataset +import android.service.autofill.Field import android.service.autofill.FillRequest +import android.service.autofill.Presentations +import android.widget.RemoteViews import androidx.annotation.ChecksSdkIntAtLeast import de.davis.keygo.autofill.presentation.dataset.inline.InlineDatasetBuilder import de.davis.keygo.autofill.presentation.dataset.menu.MenuDatasetBuilder +import de.davis.keygo.autofill.presentation.model.AutofillValue import de.davis.keygo.autofill.presentation.model.Extraction import org.koin.core.annotation.Single +import android.view.autofill.AutofillValue as AndroidAutofillValue @Single internal class AutofillDatasetProvider( + applicationContext: Context, private val inlineDatasetBuilder: InlineDatasetBuilder, private val menuDatasetBuilder: MenuDatasetBuilder, ) { - suspend fun getAutofillDataset(request: FillRequest, extraction: Extraction): List { + suspend fun getAutofillDatasets(request: FillRequest, extraction: Extraction): List { if (systemSupportsInlineSuggestions(request)) return inlineDatasetBuilder.buildInlineDatasets( specs = request.inlineSuggestionsRequest!!.inlinePresentationSpecs, @@ -25,6 +32,39 @@ internal class AutofillDatasetProvider( return menuDatasetBuilder.buildMenuDatasets(extraction = extraction) } + fun getFillingDataset(values: List) = getDefaultDataset() + .applyValues(values) + .build() + + private fun getDefaultDataset() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + Dataset.Builder( + Presentations.Builder() + .setMenuPresentation(dummyRemoteViews) + .build() + ) + else Dataset.Builder() + + private fun Dataset.Builder.applyValues(values: List) = apply { + values.forEach { + val value = AndroidAutofillValue.forText(it.value) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setField( + it.autofillId, + Field.Builder() + .setValue(value) + .build() + ) + } else { + @Suppress("DEPRECATION") + setValue( + it.autofillId, + value, + dummyRemoteViews + ) + } + } + } + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R) private fun systemSupportsInlineSuggestions(request: FillRequest): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) @@ -34,4 +74,10 @@ internal class AutofillDatasetProvider( maxSuggestion > 0 && specCount > 0 } ?: false else false + + private val dummyRemoteViews: RemoteViews = + RemoteViews( + applicationContext.packageName, + android.R.layout.simple_list_item_1 + ) } diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Classifier.kt similarity index 64% rename from app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt rename to app/src/main/kotlin/de/davis/keygo/autofill/presentation/Classifier.kt index 8794d834..fa5ca254 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/ClassificationUseCase.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Classifier.kt @@ -1,14 +1,12 @@ -package de.davis.keygo.autofill.domain.usecase +package de.davis.keygo.autofill.presentation import android.view.View -import de.davis.keygo.autofill.domain.model.FieldFeatures -import de.davis.keygo.autofill.domain.model.FieldType -import org.koin.core.annotation.Single +import de.davis.keygo.autofill.presentation.model.FieldFeatures +import de.davis.keygo.autofill.presentation.model.FieldType -@Single -internal class ClassificationUseCase { +internal object Classifier { - operator fun invoke(features: FieldFeatures): FieldType { + fun classify(features: FieldFeatures): FieldType { var type = classifyTokens(features.autofillHints) if (type !is FieldType.Undefined) return type @@ -67,20 +65,18 @@ internal class ClassificationUseCase { return FieldType.Undefined } - companion object { - // TODO: use more sophisticated regexes - val USERNAME_REGEX = Regex( - "\\b(username|login|user|usuario|nombredeusuario|nomd'utilisateur|" + - "benutzername|nomeusuario|nomeutente|imyapolzovatelya|yonghuming|" + - "yūzāmei|ismalmustakhdim)" + - "(\\b|$)", RegexOption.IGNORE_CASE - ) - - val EMAIL_REGEX = Regex( - "\\b(email|account|correo|cuenta|adressemail|compte|" + - "emailadresse|konto|conta|uchetnayazapis|elektronnayapochta|" + - "zhanghu|dianziyoujian|akaunto|mēruadoresu|alhisab|albaridal(')?iliktruni)" + - "(\\b|$)", RegexOption.IGNORE_CASE - ) - } + // TODO: use more sophisticated regexes + private val USERNAME_REGEX = Regex( + "\\b(username|login|user|usuario|nombredeusuario|nomd'utilisateur|" + + "benutzername|nomeusuario|nomeutente|imyapolzovatelya|yonghuming|" + + "yūzāmei|ismalmustakhdim)" + + "(\\b|$)", RegexOption.IGNORE_CASE + ) + + private val EMAIL_REGEX = Regex( + "\\b(email|account|correo|cuenta|adressemail|compte|" + + "emailadresse|konto|conta|uchetnayazapis|elektronnayapochta|" + + "zhanghu|dianziyoujian|akaunto|mēruadoresu|alhisab|albaridal(')?iliktruni)" + + "(\\b|$)", RegexOption.IGNORE_CASE + ) } \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt index ad9d46bf..e91449ea 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Extractor.kt @@ -8,16 +8,14 @@ import android.widget.AutoCompleteTextView import android.widget.EditText import android.widget.MultiAutoCompleteTextView import android.widget.TextView -import de.davis.keygo.autofill.domain.alias.Handle -import de.davis.keygo.autofill.domain.model.FieldType -import de.davis.keygo.autofill.domain.usecase.ClassificationUseCase import de.davis.keygo.autofill.presentation.mapper.toFieldFeatures import de.davis.keygo.autofill.presentation.model.ExtractedField import de.davis.keygo.autofill.presentation.model.Extraction +import de.davis.keygo.autofill.presentation.model.FieldType import org.koin.core.annotation.Single @Single -internal class Extractor(private val classificationUseCase: ClassificationUseCase) { +internal class Extractor() { fun extractRelevant( node: AssistStructure.ViewNode, @@ -62,7 +60,7 @@ internal class Extractor(private val classificationUseCase: ClassificationUseCas } val features = node.toFieldFeatures() - val type = classificationUseCase(features) + val type = Classifier.classify(features) if (type is FieldType.Undefined) { Log.d( TAG, @@ -72,7 +70,6 @@ internal class Extractor(private val classificationUseCase: ClassificationUseCas } outFields += ExtractedField( - handle = Handle.randomUUID(), autofillId = node.autofillId!!, features = features, type = type, diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt index d0b74edc..0bfbe644 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt @@ -70,7 +70,7 @@ class KeyGoAutofillService : AutofillService() { Log.d(TAG, "In Compatibility Mode: $inCompatibilityMode") Log.d(TAG, "Extracted fields [${extraction.fields.size}]: $extraction") - val dataset = datasetProvider.getAutofillDataset(request, extraction) + val dataset = datasetProvider.getAutofillDatasets(request, extraction) val response = FillResponse.Builder().apply { dataset.forEach(::addDataset) applySaveInfo( diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt index 685f7495..e84dc06f 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt @@ -3,17 +3,25 @@ package de.davis.keygo.autofill.presentation import android.app.PendingIntent import android.content.Context import android.content.Intent +import de.davis.keygo.autofill.presentation.activity.AutofillActivity +import de.davis.keygo.autofill.presentation.model.Extraction +import de.davis.keygo.core.domain.alias.ItemId private const val AUTOFILL_PENDING_INTENT_FLAGS = PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT // TODO: show UI -internal fun Context.getSelectionPendingIntent() = PendingIntent.getActivity( - this, - 1001, - packageManager.getLaunchIntentForPackage(packageName), - AUTOFILL_PENDING_INTENT_FLAGS -) +internal fun Context.getSelectionPendingIntent( + context: Context, + extraction: Extraction, + vaultId: ItemId +) = + PendingIntent.getActivity( + this, + 1001, + AutofillActivity.newIntent(context, extraction, vaultId), + AUTOFILL_PENDING_INTENT_FLAGS + ) internal fun Context.getOnLongClickPendingIntent() = PendingIntent.getService( this, diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillActivity.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillActivity.kt new file mode 100644 index 00000000..1b102184 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillActivity.kt @@ -0,0 +1,143 @@ +package de.davis.keygo.autofill.presentation.activity + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.service.autofill.Dataset +import android.view.autofill.AutofillManager +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.setContent +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.FragmentActivity +import de.davis.keygo.R +import de.davis.keygo.autofill.presentation.model.AutofillEvent +import de.davis.keygo.autofill.presentation.model.AutofillInformation +import de.davis.keygo.autofill.presentation.model.Extraction +import de.davis.keygo.core.domain.alias.ItemId +import de.davis.keygo.core.presentation.ObserveAsEvents +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import org.koin.androidx.compose.koinViewModel + + +// Just a prove of concept. +// We are able to launch a activity that is transparent and does not show up in the recent apps list. +// This is useful for UX. We can provide a simple biometric auth prompt just over the current app. +// The user does not notice that the app is switching to a different activity and it feels more +// integrated. This can also be taken further and we can show warning dialogs right on-top of the +// current app. + +/** + * This activity is transparent and does not show up in the recent apps list. It is used to gather + * the right data that should be used for autofill. If needed this activity can show a dialog or + * request biometrics to authenticate the user. + * + * The transparency allows us to have a more integrated user experience, as the user doesn't see a + * dedicated UI just to authenticate themself or to show a dialog. + */ +class AutofillActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val viewModel = koinViewModel() + + ObserveAsEvents(viewModel.events) { + when (it) { + AutofillEvent.Abort -> cancel() + + is AutofillEvent.Fill -> finishWithResult(it.dataset) + } + } + } + } + + private fun cancel() { + setResult(RESULT_CANCELED) + finish() + } + + private fun finishWithResult(dataset: Dataset) { + setResult( + RESULT_OK, + Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) + } + ) + finish() + } + + companion object { + + fun newIntent(context: Context, extraction: Extraction, vaultId: ItemId): Intent = Intent( + context, + AutofillActivity::class.java + ).apply { + putExtra( + AutofillViewModel.KEY_AUTOFILL_INFORMATION, + AutofillInformation(extraction, vaultId) + ) + } + } +} + + +// How about using some thing like a LocalComposition here? And the nearest handles the request. +// The problem we have with a dialog style way is the following: +// The caller can request and dismiss the prompt using a simple boolean state. However, when the +// prompt is closed by the system (e.g., user cancels, fails, ...), the caller *should* reset the +// state back to `false`. So there is no direct control over the dialog. + +// The proposed solution would integrate a local manager, just like snackbars +@Composable +fun Test( + onSuccess: () -> Unit, + onError: () -> Unit, + onFailed: () -> Unit +) { + val fragmentActivity = LocalActivity.current as? FragmentActivity + requireNotNull(fragmentActivity) { "BiometricAuthHandler must be used within a FragmentActivity context." } + + val prompt = remember(fragmentActivity) { + BiometricPrompt( + fragmentActivity, + Dispatchers.Default.asExecutor(), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onSuccess() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onError() + } + + override fun onAuthenticationFailed() { + onFailed() + } + } + ) + } + + val title = stringResource(R.string.authenticate) + val negativeButtonText = stringResource(R.string.cancel) + + DisposableEffect(prompt) { + prompt.authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setNegativeButtonText(negativeButtonText) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build(), + ) + + onDispose { + prompt.cancelAuthentication() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillViewModel.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillViewModel.kt new file mode 100644 index 00000000..2e7a9ade --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillViewModel.kt @@ -0,0 +1,67 @@ +package de.davis.keygo.autofill.presentation.activity + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.autofill.presentation.AutofillDatasetProvider +import de.davis.keygo.autofill.presentation.model.AutofillEvent +import de.davis.keygo.autofill.presentation.model.AutofillInformation +import de.davis.keygo.autofill.presentation.model.AutofillValue +import de.davis.keygo.autofill.presentation.model.FieldType +import de.davis.keygo.core.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.domain.repository.PasswordRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel + +@KoinViewModel +internal class AutofillViewModel( + savedStateHandle: SavedStateHandle, + private val passwordRepository: PasswordRepository, + private val cryptographicScopeProvider: CryptographicScopeProvider, + private val autofillDatasetProvider: AutofillDatasetProvider, +) : ViewModel() { + + private val autofillInformation = + savedStateHandle.get(KEY_AUTOFILL_INFORMATION) + ?: throw IllegalArgumentException("Extraction must not be null") + + private val eventChannel = Channel() + val events = eventChannel.receiveAsFlow() + + init { + viewModelScope.launch { + val password = passwordRepository.getVaultPasswordById(autofillInformation.vaultId) + ?: throw IllegalArgumentException("Password for vaultId=${autofillInformation.vaultId} not found") + + val values = autofillInformation.extraction.fields.mapNotNull { + val value = when (it.type) { + FieldType.Credentials.EMail -> password.username + FieldType.Credentials.Password -> cryptographicScopeProvider.scope { + password.encryptedData.decrypt().decodeToString() + } + + FieldType.Credentials.Phone -> password.username + FieldType.Credentials.Username -> password.username + FieldType.Undefined -> return@mapNotNull null + } + + if (value.isNullOrBlank()) return@mapNotNull null + + AutofillValue( + autofillId = it.autofillId, + value = value + ) + } + + + + eventChannel.send(AutofillEvent.Fill(autofillDatasetProvider.getFillingDataset(values))) + } + } + + companion object { + const val KEY_AUTOFILL_INFORMATION = "extraction" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt index 5dfadbb4..5c91f1df 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt @@ -6,8 +6,8 @@ import android.service.autofill.FillResponse import android.service.autofill.RegexValidator import android.service.autofill.SaveInfo import android.view.autofill.AutofillId -import de.davis.keygo.autofill.domain.model.FieldType import de.davis.keygo.autofill.presentation.model.Extraction +import de.davis.keygo.autofill.presentation.model.FieldType internal fun FillResponse.Builder.applySaveInfo( extraction: Extraction, diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt index c90599e8..f97e64de 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt @@ -14,6 +14,8 @@ import de.davis.keygo.autofill.presentation.dataset.SuggestionFinder import de.davis.keygo.autofill.presentation.getOnLongClickPendingIntent import de.davis.keygo.autofill.presentation.getSelectionPendingIntent import de.davis.keygo.autofill.presentation.model.Extraction +import de.davis.keygo.core.domain.alias.ItemId +import de.davis.keygo.core.domain.alias.ItemIdNone import de.davis.keygo.core.domain.model.Password import org.koin.core.annotation.Single @@ -63,7 +65,7 @@ internal class InlineDatasetBuilder( icon = appIcon() ) - return presentation.buildDataset(extraction) + return presentation.buildDataset(extraction, vaultId = ItemIdNone) } @RequiresApi(Build.VERSION_CODES.R) @@ -78,7 +80,7 @@ internal class InlineDatasetBuilder( title = context.getString(R.string.app_name) ) - return presentation.buildDataset(extraction) + return presentation.buildDataset(extraction, vaultId = ItemIdNone) } @RequiresApi(Build.VERSION_CODES.R) @@ -94,16 +96,21 @@ internal class InlineDatasetBuilder( subtitle = suggestion.username ?: "----", ) - return presentation.buildDataset(extraction) + return presentation.buildDataset(extraction, suggestion.vaultItemId) } - private fun InlinePresentation.buildDataset(extraction: Extraction) = + private fun InlinePresentation.buildDataset(extraction: Extraction, vaultId: ItemId) = datasetBuilder.buildDataset( inlinePresentation = this, - intentSender = context.getSelectionPendingIntent().intentSender, + intentSender = context.getSelectionPendingIntent( + context, + extraction, + vaultId + ).intentSender, extraction = extraction ) + //TODO: maybe return null for API < 29 private fun appIcon(): Icon = Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply { setTintBlendMode(BlendMode.DST) diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt index c93bddd5..acc813d7 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt @@ -9,6 +9,8 @@ import de.davis.keygo.autofill.presentation.dataset.DatasetBuilder import de.davis.keygo.autofill.presentation.dataset.SuggestionFinder import de.davis.keygo.autofill.presentation.getSelectionPendingIntent import de.davis.keygo.autofill.presentation.model.Extraction +import de.davis.keygo.core.domain.alias.ItemId +import de.davis.keygo.core.domain.alias.ItemIdNone import de.davis.keygo.core.domain.model.Password import org.koin.core.annotation.Single @@ -28,10 +30,10 @@ internal class MenuDatasetBuilder( return suggestions.map { suggestion -> buildSuggestionDataset(extraction, suggestion) - } + listOf(buildAppDataset(extraction)) + } + listOf(buildAppDataset(extraction, vaultId = ItemIdNone)) } - private fun buildAppDataset(extraction: Extraction): Dataset { + private fun buildAppDataset(extraction: Extraction, vaultId: ItemId): Dataset { val remoteViews = menuDatasetBuilder.buildMenuSuggestion( title = context.getString(R.string.app_name), subtitle = context.getString(R.string.autofill_service), @@ -40,7 +42,11 @@ internal class MenuDatasetBuilder( return datasetBuilder.buildDataset( remoteViews = remoteViews, - intentSender = context.getSelectionPendingIntent().intentSender, + intentSender = context.getSelectionPendingIntent( + context, + extraction, + vaultId + ).intentSender, extraction = extraction, ) } @@ -56,7 +62,11 @@ internal class MenuDatasetBuilder( return datasetBuilder.buildDataset( remoteViews = remoteViews, - intentSender = context.getSelectionPendingIntent().intentSender, + intentSender = context.getSelectionPendingIntent( + context, + extraction, + suggestion.vaultItemId + ).intentSender, extraction = extraction, ) } diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt index f7b7ba4e..b831f0ae 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt @@ -1,7 +1,7 @@ package de.davis.keygo.autofill.presentation.mapper import android.app.assist.AssistStructure -import de.davis.keygo.autofill.domain.model.FieldFeatures +import de.davis.keygo.autofill.presentation.model.FieldFeatures internal fun AssistStructure.ViewNode.toFieldFeatures(): FieldFeatures { val autofillHints = autofillHints diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillEvent.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillEvent.kt new file mode 100644 index 00000000..f09e0183 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillEvent.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.autofill.presentation.model + +import android.service.autofill.Dataset + +sealed interface AutofillEvent { + + data object Abort : AutofillEvent + + data class Fill(val dataset: Dataset) : AutofillEvent +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillInformation.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillInformation.kt new file mode 100644 index 00000000..b7d31d7e --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillInformation.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.autofill.presentation.model + +import android.os.Parcelable +import de.davis.keygo.core.domain.alias.ItemId +import kotlinx.parcelize.Parcelize + +@Parcelize +data class AutofillInformation( + val extraction: Extraction, + val vaultId: ItemId +) : Parcelable diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillValue.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillValue.kt new file mode 100644 index 00000000..cb21fe90 --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillValue.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.autofill.presentation.model + +import android.view.autofill.AutofillId + +data class AutofillValue( + val autofillId: AutofillId, + val value: String +) diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/ExtractedField.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/ExtractedField.kt index 20b89a34..783673d1 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/ExtractedField.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/ExtractedField.kt @@ -1,13 +1,12 @@ package de.davis.keygo.autofill.presentation.model +import android.os.Parcelable import android.view.autofill.AutofillId -import de.davis.keygo.autofill.domain.alias.Handle -import de.davis.keygo.autofill.domain.model.FieldFeatures -import de.davis.keygo.autofill.domain.model.FieldType +import kotlinx.parcelize.Parcelize +@Parcelize data class ExtractedField( - val handle: Handle, val autofillId: AutofillId, val features: FieldFeatures, val type: FieldType, -) \ No newline at end of file +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt index 3802f5b7..be7f4e5a 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Extraction.kt @@ -1,11 +1,13 @@ package de.davis.keygo.autofill.presentation.model -import de.davis.keygo.autofill.domain.model.FieldType +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +@Parcelize data class Extraction( val fields: List, val urls: Set -) { +) : Parcelable { fun hasFields(): Boolean { return fields.isNotEmpty() } diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldFeatures.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldFeatures.kt new file mode 100644 index 00000000..952e061a --- /dev/null +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldFeatures.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.autofill.presentation.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FieldFeatures( + val autofillHints: Set, + val htmlAttributes: Map, + val tokens: Set, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldType.kt similarity index 58% rename from app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt rename to app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldType.kt index 5ae8443c..4a32c8e6 100644 --- a/app/src/main/kotlin/de/davis/keygo/autofill/domain/model/FieldType.kt +++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldType.kt @@ -1,6 +1,12 @@ -package de.davis.keygo.autofill.domain.model +package de.davis.keygo.autofill.presentation.model -sealed interface FieldType { +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed interface FieldType : Parcelable { + + @Parcelize sealed interface Credentials : FieldType { data object Username : Credentials data object EMail : Credentials diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 7a8715de..6b0c4feb 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,14 @@ + +