diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/AssistStructureParser.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/AssistStructureParser.kt index 2ff10fd8..921ab250 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/AssistStructureParser.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/AssistStructureParser.kt @@ -13,39 +13,50 @@ import com.hegocre.nextcloudpasswords.BuildConfig /** * Parser used to get the needed information from an assist structure to reply to an Autofill request. * - * @param assistStructure The assist structure provided by the Autofill Request, containing all autofill - * fields information + * @param assistStructures The list assist structures provided by the Autofill Request, containing all autofill + * fields information, the list must be non-empty */ @RequiresApi(Build.VERSION_CODES.O) -class AssistStructureParser(assistStructure: AssistStructure) { - val usernameAutofillIds = mutableListOf() - val passwordAutofillIds = mutableListOf() - val usernameAutofillContent = mutableListOf() - val passwordAutofillContent = mutableListOf() - private var lastTextAutofillId: AutofillId? = null - private var lastTextAutofillContent: String? = null - private var candidateTextAutofillId: AutofillId? = null - - val structure = assistStructure +class AssistStructureParser(assistStructures: List) { + val usernameAutofillData = mutableListOf>() + val passwordAutofillData = mutableListOf>() + private var lastTextAutofillData: Pair? = null + private var candidateTextAutofillData: Pair? = null + + val structures = assistStructures private val webDomains = HashMap() - val packageName = assistStructure.activityComponent.flattenToShortString().substringBefore("/") + val packageName = assistStructures.last().activityComponent.flattenToShortString().substringBefore("/") // Get the most repeated domain on the fields (there may be more than one) val webDomain: String? get() = webDomains.toList().filter { it.first != "localhost" } .maxByOrNull { (_, value) -> value }?.first + val usernameAutofillIds: List + get() = usernameAutofillData.map { it.first } + + val passwordAutofillIds: List + get() = passwordAutofillData.map { it.first } + + val usernameAutofillContent: List + get() = usernameAutofillData.map { it.second } + + val passwordAutofillContent: List + get() = passwordAutofillData.map { it.second } + init { - for (i in 0 until assistStructure.windowNodeCount) { - val windowNode = assistStructure.getWindowNodeAt(i) - windowNode.rootViewNode?.let { parseNode(it) } + // parse the structures from the most recent one + assistStructures.reversed().forEach { assistStructure -> + for (i in 0 until assistStructure.windowNodeCount) { + val windowNode = assistStructure.getWindowNodeAt(i) + windowNode.rootViewNode?.let { parseNode(it) } + } } - if (usernameAutofillIds.isEmpty()) - candidateTextAutofillId?.let { - usernameAutofillIds.add(it) - usernameAutofillContent.add(lastTextAutofillContent) + if (usernameAutofillData.isEmpty()) + candidateTextAutofillData?.let { + usernameAutofillData.add(it) } } @@ -60,19 +71,20 @@ class AssistStructureParser(assistStructure: AssistStructure) { if (fieldType != null) { when (fieldType) { FIELD_TYPE_USERNAME -> { - usernameAutofillIds.add(autofillId) - usernameAutofillContent.add(node.text.toString()) + if (!usernameAutofillIds.contains(autofillId)) { + usernameAutofillData.add(Pair(autofillId, node.autofillValue?.textValue.toString())) + } } FIELD_TYPE_PASSWORD -> { - passwordAutofillIds.add(autofillId) - passwordAutofillContent.add(node.text.toString()) - // We save the autofillId of the field above the password field, - // in case we don't find any explicit username field - candidateTextAutofillId = lastTextAutofillId + if (!passwordAutofillIds.contains(autofillId)) { + passwordAutofillData.add(Pair(autofillId, node.autofillValue?.textValue.toString())) + // We save the autofillId of the field above the password field, + // in case we don't find any explicit username field + candidateTextAutofillData = lastTextAutofillData + } } FIELD_TYPE_TEXT -> { - lastTextAutofillId = autofillId - lastTextAutofillContent = node.text.toString() + lastTextAutofillData = Pair(autofillId, node.autofillValue?.textValue.toString()) } } } diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/AutofillHelper.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/AutofillHelper.kt index 28af3c54..5931e94a 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/AutofillHelper.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/AutofillHelper.kt @@ -30,34 +30,48 @@ import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData object AutofillHelper { fun buildDataset( context: Context, - password: PasswordAutofillData?, helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec?, - intent: IntentSender? = null, - needsAppLock: Boolean = false, - datasetIdx: Int = 0 + password: PasswordAutofillData?, + intent: IntentSender?, + needsAppLock: Boolean, + intentIdx: Int = 0 ): Dataset { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (inlinePresentationSpec != null) { buildInlineDataset( context, - password, helper, inlinePresentationSpec, + password, intent, needsAppLock, - datasetIdx + intentIdx ) } else { - buildPresentationDataset(context, password, helper, intent, needsAppLock, datasetIdx) + buildPresentationDataset( + context, + helper, + password, + intent, + needsAppLock, + intentIdx + ) } } else { - buildPresentationDataset(context, password, helper, intent, needsAppLock, datasetIdx) + buildPresentationDataset( + context, + helper, + password, + intent, + needsAppLock, + intentIdx + ) } } @RequiresApi(Build.VERSION_CODES.P) - fun buildSaveInfo(helper: AssistStructureParser): Pair? { + fun buildSaveInfo(helper: AssistStructureParser, searchHint: String): Pair? { val requiredIds = mutableListOf() val optionalIds = mutableListOf() @@ -65,7 +79,7 @@ object AutofillHelper { if (helper.passwordAutofillIds.size == 1) requiredIds += helper.passwordAutofillIds[0] else optionalIds += helper.passwordAutofillIds - + if (helper.usernameAutofillIds.size == 1) requiredIds += helper.usernameAutofillIds[0] else optionalIds += helper.usernameAutofillIds @@ -85,13 +99,13 @@ object AutofillHelper { // if there are only username views but no password views, then delay the save on supported devices if(helper.usernameAutofillIds.isNotEmpty() && helper.passwordAutofillIds.isEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Log.d(NCPAutofillService.TAG, "Delaying save because only username views are detected") return Pair( builder.apply { setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE or SaveInfo.FLAG_DELAY_SAVE) + if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds.toTypedArray()) }.build(), Bundle().apply { - putCharSequence(USERNAME, helper.usernameAutofillContent.firstOrNull() ?: "") + putCharSequence(SEARCH_HINT, searchHint) } ) } else if (helper.passwordAutofillIds.isNotEmpty()) { @@ -111,12 +125,12 @@ object AutofillHelper { @RequiresApi(Build.VERSION_CODES.R) private fun buildInlineDataset( context: Context, - password: PasswordAutofillData?, helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec, + password: PasswordAutofillData?, intent: IntentSender? = null, needsAppLock: Boolean = false, - datasetIdx: Int + intentIdx: Int ): Dataset { // build redacted dataset when app lock is needed return if (needsAppLock && password?.id != null) { @@ -125,21 +139,25 @@ object AutofillHelper { addInlineAutofillValue( context, autofillId, + inlinePresentationSpec, password.label, - null, - inlinePresentationSpec + null ) } helper.passwordAutofillIds.forEach { autofillId -> addInlineAutofillValue( context, autofillId, + inlinePresentationSpec, password.label, - null, - inlinePresentationSpec + null ) } - setAuthentication(buildIntent(context, 1005+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) + setAuthentication(buildIntent( + context, + 1005+intentIdx, + AutofillData.FromId(id=password.id, structures=helper.structures) + )) }.build() } else { Dataset.Builder().apply { @@ -147,18 +165,18 @@ object AutofillHelper { addInlineAutofillValue( context, autofillId, + inlinePresentationSpec, password?.label, - password?.username, - inlinePresentationSpec + password?.username ) } helper.passwordAutofillIds.forEach { autofillId -> addInlineAutofillValue( context, autofillId, + inlinePresentationSpec, password?.label, - password?.password, - inlinePresentationSpec + password?.password ) } intent?.let { setAuthentication(it) } @@ -168,11 +186,11 @@ object AutofillHelper { private fun buildPresentationDataset( context: Context, - password: PasswordAutofillData?, helper: AssistStructureParser, + password: PasswordAutofillData?, intent: IntentSender? = null, needsAppLock: Boolean = false, - datasetIdx: Int + intentIdx: Int ): Dataset { // build redacted dataset when app lock is needed return if (needsAppLock && password?.id != null) { @@ -183,7 +201,11 @@ object AutofillHelper { helper.passwordAutofillIds.forEach { autofillId -> addAutofillValue(context, autofillId, password.label, null) } - setAuthentication(buildIntent(context, 1005+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) + setAuthentication(buildIntent( + context, + 1005+intentIdx, + AutofillData.FromId(id=password.id, structures=helper.structures) + )) }.build() } else { Dataset.Builder().apply { @@ -242,9 +264,9 @@ object AutofillHelper { private fun Dataset.Builder.addInlineAutofillValue( context: Context, autofillId: AutofillId, + inlinePresentationSpec: InlinePresentationSpec, label: String?, value: String?, - inlinePresentationSpec: InlinePresentationSpec, ) { val autofillLabel = label ?: context.getString(R.string.app_name) @@ -319,5 +341,5 @@ object AutofillHelper { } private const val AUTOFILL_INTENT_ID = "com.hegocre.nextcloudpasswords.intents.autofill" - const val USERNAME = "username" + const val SEARCH_HINT = "search_hint" } \ No newline at end of file diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/NCPAutofillService.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/NCPAutofillService.kt index 88dc46ba..7cf1d2bd 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/NCPAutofillService.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/NCPAutofillService.kt @@ -112,19 +112,42 @@ class NCPAutofillService : AutofillService() { } } + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + serviceScope.launch { + try { + val intent: IntentSender? = withContext(Dispatchers.Default) { + processSaveRequest(request) + } + if (intent != null) callback.onSuccess(intent) + else callback.onFailure("Unable to complete Save Request") + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + callback.onFailure("Error handling save request: ${e.message}") + } + } + } else { + callback.onFailure("Saving not supported on android < 9.0") + } + } + private suspend fun processFillRequest(request: FillRequest): FillResponse? { loginException?.let { throw it } - Log.d(TAG, "Processing fill request") - val context = request.fillContexts.last() ?: return null - val helper = AssistStructureParser(context.structure) + Log.d(TAG, "Processing fill request with ${request.fillContexts.size} contexts") + if (request.fillContexts.isEmpty()) + return null + + val helper = AssistStructureParser(listOf(request.fillContexts.last().structure)) + val delayed_helper = AssistStructureParser(request.fillContexts.map { it.structure }) // Do not autofill this application if (helper.packageName == packageName) return null - if (helper.usernameAutofillIds.isEmpty() && helper.passwordAutofillIds.isEmpty()) { + if (helper.usernameAutofillData.isEmpty() && helper.passwordAutofillData.isEmpty()) { Log.e(TAG, "No username or password fields detected, cannot autofill") return null } @@ -163,13 +186,17 @@ class NCPAutofillService : AutofillService() { val needsAuth = hasAppLock.first() && isLocked.value + // use username from any of the past contexts as candidate for saving + val candidateUsername = delayed_helper.usernameAutofillContent.firstOrNull { !it.isNullOrBlank() } + return buildFillResponse( filteredList, helper, request, searchHint, needsAuth, - needsAppForMasterPassword + needsAppForMasterPassword, + candidateUsername ) } @@ -179,14 +206,15 @@ class NCPAutofillService : AutofillService() { request: FillRequest, searchHint: String, needsAuth: Boolean, - needsAppForMasterPassword: Boolean + needsAppForMasterPassword: Boolean, + candidateUsername: String? ): FillResponse { Log.d(TAG, "Building FillResponse, needsAuth: $needsAuth") val builder = FillResponse.Builder() val useInline = preferencesManager.getUseInlineAutofill() - val inlineRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && useInline) { - request.inlineSuggestionsRequest + val inlinePresentationSpec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && useInline) { + request.inlineSuggestionsRequest?.inlinePresentationSpecs?.first() } else null if (!needsAppForMasterPassword) { @@ -195,14 +223,14 @@ class NCPAutofillService : AutofillService() { builder.addDataset( AutofillHelper.buildDataset( applicationContext, + helper, + inlinePresentationSpec, PasswordAutofillData( id = password.id, label = "${password.label} - ${password.username}", username = password.username, password = password.password ), - helper, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, null, needsAuth, idx @@ -210,23 +238,34 @@ class NCPAutofillService : AutofillService() { ) } + Log.d(TAG, "Using Inline suggestions: ${inlinePresentationSpec != null}") + Log.d(TAG, "Datasets added to FillResponse") // Button to create a new password in the app and autofill it if (passwords.isEmpty()) { val saveData = SaveData( label = searchHint, - username = "", + username = candidateUsername ?: "", password = "", url = searchHint ) builder.addDataset( AutofillHelper.buildDataset( applicationContext, - PasswordAutofillData(label = applicationContext.getString(R.string.new_password), id = null, username = null, password = null), helper, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, - AutofillHelper.buildIntent(applicationContext, 1002, AutofillData.SaveAutofill(searchHint, saveData, helper.structure)), + inlinePresentationSpec, + PasswordAutofillData( + label = applicationContext.getString(R.string.new_password), + id = null, + username = null, + password = null + ), + AutofillHelper.buildIntent( + applicationContext, + 1002, + AutofillData.SaveAutofill(searchHint, saveData, helper.structures) + ), false ) ) @@ -239,79 +278,58 @@ class NCPAutofillService : AutofillService() { builder.addDataset( AutofillHelper.buildDataset( applicationContext, - PasswordAutofillData(label = applicationContext.getString(R.string.more), id = null, username = null, password = null), helper, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, - AutofillHelper.buildIntent(applicationContext, 1003, AutofillData.ChoosePwd(searchHint, helper.structure)), + inlinePresentationSpec, + PasswordAutofillData( + label = applicationContext.getString(R.string.more), + id = null, + username = null, + password = null + ), + AutofillHelper.buildIntent( + applicationContext, + 1003, + AutofillData.ChoosePwd(searchHint, helper.structures) + ), false ) ) - Log.d(TAG, "Button to open app added to FillResponse") + Log.d(TAG, "Button to conclude in app added to FillResponse") // set Save Info, with an optional bundle if delaying the save if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - AutofillHelper.buildSaveInfo(helper)?.let { pair -> + AutofillHelper.buildSaveInfo(helper, searchHint)?.let { pair -> builder.setSaveInfo(pair.first) pair.second?.let { bundle -> - builder.setClientState(bundle) - } + builder.setClientState(bundle) + Log.d(TAG, "SaveInfo set in FillResponse, delaying the save") + } ?: Log.d(TAG, "SaveInfo set in FillResponse") } } - Log.d(TAG, "SaveInfo set in FillResponse if applicable") - return builder.build() } - private suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) { - try { - val app = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - packageManager.getApplicationInfo( - packageName, - PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) - ) - else - packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) - - packageManager.getApplicationLabel(app).toString() - } catch (e: PackageManager.NameNotFoundException) { - "" - } - } - - override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - serviceScope.launch { - try { - val intent: IntentSender? = withContext(Dispatchers.Default) { - processSaveRequest(request) - } - if (intent != null) callback.onSuccess(intent) - else callback.onFailure("Unable to complete Save Request") - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - callback.onFailure("Error handling save request: ${e.message}") - } - } - } else { - callback.onFailure("Saving not supported on android < 9.0") - } - } - private suspend fun processSaveRequest(request: SaveRequest): IntentSender? { - val context = request.fillContexts.last() ?: return null - val helper = AssistStructureParser(context.structure) + Log.d(TAG, "Processing save request with ${request.fillContexts.size} contexts") + + if (request.fillContexts.isEmpty()) + return null + + val helper = AssistStructureParser(listOf(request.fillContexts.last().structure)) + val delayed_helper = AssistStructureParser(request.fillContexts.map { it.structure }) // Do not autofill this application if (helper.packageName == packageName) return null - val delayedUsername: String? = request.clientState?.getCharSequence(AutofillHelper.USERNAME)?.toString() - - val username: String = helper.usernameAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: delayedUsername ?: "" - val password: String = helper.passwordAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: "" + // Determine Search Hint + val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) + // Get the most recent username and password among the past contexts + val username: String = delayed_helper.usernameAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: "" + val password: String = delayed_helper.passwordAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: "" + if (password.isBlank()) { throw IllegalArgumentException("Blank password, cannot save") } @@ -328,10 +346,27 @@ class NCPAutofillService : AutofillService() { throw IllegalStateException("Session is not open and cannot be opened, cannot save") } - // Determine Search Hint - val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) + return AutofillHelper.buildIntent( + applicationContext, + 1004, + AutofillData.Save(searchHint, SaveData(searchHint, username, password, searchHint)) + ) + } + + private suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) { + try { + val app = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + packageManager.getApplicationInfo( + packageName, + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) + ) + else + packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) - return AutofillHelper.buildIntent(applicationContext, 1004, AutofillData.Save(searchHint, SaveData(searchHint, username, password, searchHint))) + packageManager.getApplicationLabel(app).toString() + } catch (e: PackageManager.NameNotFoundException) { + "" + } } companion object { diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/MainActivity.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/MainActivity.kt index f7cb2387..ac5d3369 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/MainActivity.kt @@ -68,7 +68,7 @@ class MainActivity : FragmentActivity() { label = label, username = username, password = password - ), autofillData.structure) + ), autofillData.structures) } else -> null } @@ -134,9 +134,16 @@ class MainActivity : FragmentActivity() { @RequiresApi(Build.VERSION_CODES.O) private fun autofillReply( password: PasswordAutofillData, - structure: AssistStructure + structures: List ) { - val dataset = AutofillHelper.buildDataset(this, password, AssistStructureParser(structure), null) + val dataset = AutofillHelper.buildDataset( + this, + AssistStructureParser(structures), + null, + password, + null, + false + ) val replyIntent = Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt index 260997b8..fe41e853 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt @@ -16,7 +16,7 @@ data class SaveData( sealed class AutofillData : Parcelable { interface isAutofill { - val structure: AssistStructure + val structures: List } interface isSave { @@ -26,20 +26,20 @@ sealed class AutofillData : Parcelable { @Parcelize data class FromId( val id: String, - override val structure: AssistStructure + override val structures: List ) : AutofillData(), isAutofill @Parcelize data class ChoosePwd( val searchHint: String, - override val structure: AssistStructure + override val structures: List ) : AutofillData(), isAutofill @Parcelize data class SaveAutofill( val searchHint: String, override val saveData: SaveData, - override val structure: AssistStructure, + override val structures: List, ) : AutofillData(), isAutofill, isSave @Parcelize