Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f9ea4a0
first draft; autofill service can now directly fill passwords without…
difanta Feb 12, 2026
07f2962
AutofillService fix: lint
difanta Feb 12, 2026
770d37d
complete autofills with authentication without exchanging sensible in…
difanta Feb 16, 2026
e71edc3
one interface to handle different autofill situations;
difanta Feb 17, 2026
fa2c778
maybe fix domain matching
difanta Feb 20, 2026
4fe1f1f
unique autofill intent codes
difanta Feb 21, 2026
7779e25
never use searchByUsername in autofill service because it always sear…
difanta Feb 22, 2026
2f41b7d
fix create password autofill bug by remembering the state correctly
difanta Feb 22, 2026
44a5b7f
provide initial values when creating a password during ChoosePwd
difanta Feb 22, 2026
9809add
remove leftover log
difanta Feb 22, 2026
2c6153b
fix the case where master password is required but not saved
difanta Feb 22, 2026
f180d9d
restore build
difanta Feb 25, 2026
19a37ea
clean up imports; better label for the "More" button; add isAutofill/…
difanta Feb 28, 2026
dfbcd6f
prevent intent code conflicts
difanta Feb 28, 2026
fd4199b
cleanup autofill service; decrypt passwords lazily to save battery
difanta Feb 28, 2026
4d48982
cleanup NavHost; undo manual state management
difanta Feb 28, 2026
2544c1c
do not autofill textual fields as username
difanta Feb 28, 2026
3b40831
add translations for "More" button
difanta Apr 2, 2026
402b443
remove unused getAttribute
difanta Apr 2, 2026
53b7e59
fix null/empty checks
difanta Apr 2, 2026
19ce435
fix import translations
difanta Apr 2, 2026
ead1304
improve lock check; remove some information from logs
difanta Apr 2, 2026
a931cb2
handle exception thrown in construction of ApiController if not logge…
difanta Apr 2, 2026
ec8ef8b
plug back cheap login check
difanta Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-parcelize'
id 'com.google.devtools.ksp' version "$ksp_version"
id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
id 'org.jetbrains.kotlin.plugin.compose' version "$kotlin_version"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ import com.hegocre.nextcloudpasswords.BuildConfig
class AssistStructureParser(assistStructure: AssistStructure) {
val usernameAutofillIds = mutableListOf<AutofillId>()
val passwordAutofillIds = mutableListOf<AutofillId>()
val usernameAutofillContent = mutableListOf<String?>()
val passwordAutofillContent = mutableListOf<String?>()
private var lastTextAutofillId: AutofillId? = null
private var lastTextAutofillContent: String? = null
private var candidateTextAutofillId: AutofillId? = null

val structure = assistStructure

private val webDomains = HashMap<String, Int>()

val packageName = assistStructure.activityComponent.flattenToShortString().substringBefore("/")
Expand All @@ -40,6 +45,7 @@ class AssistStructureParser(assistStructure: AssistStructure) {
if (usernameAutofillIds.isEmpty())
candidateTextAutofillId?.let {
usernameAutofillIds.add(it)
usernameAutofillContent.add(lastTextAutofillContent)
}
}

Expand All @@ -55,15 +61,18 @@ class AssistStructureParser(assistStructure: AssistStructure) {
when (fieldType) {
FIELD_TYPE_USERNAME -> {
usernameAutofillIds.add(autofillId)
usernameAutofillContent.add(node.text.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
}
FIELD_TYPE_TEXT -> {
lastTextAutofillId = autofillId
lastTextAutofillContent = node.text.toString()
}
}
}
Expand Down Expand Up @@ -104,31 +113,38 @@ class AssistStructureParser(assistStructure: AssistStructure) {

// Get by autofill hint
node.autofillHints?.forEach { hint ->
if (hint == View.AUTOFILL_HINT_USERNAME || hint == View.AUTOFILL_HINT_EMAIL_ADDRESS) {
if (hint == View.AUTOFILL_HINT_USERNAME ||
hint == View.AUTOFILL_HINT_EMAIL_ADDRESS ||
hint.contains("user", true) ||
hint.contains("mail", true)
) {
return FIELD_TYPE_USERNAME
} else if (hint == View.AUTOFILL_HINT_PASSWORD) {
} else if (hint == View.AUTOFILL_HINT_PASSWORD || hint.contains("password", true)) {
return FIELD_TYPE_PASSWORD
}
}

// Get by HTML attributes
if (node.hasAttribute("type", "password") ||
node.hasAttribute("name", "password")
) {
return FIELD_TYPE_PASSWORD
}

if (node.hasAttribute("type", "email") ||
node.hasAttribute("type", "tel") ||
node.hasAttribute("type", "text") ||
//node.hasAttribute("type", "text") ||
node.hasAttribute("name", "email") ||
node.hasAttribute("name", "mail") ||
node.hasAttribute("name", "user") ||
node.hasAttribute("name", "username")
) {
return FIELD_TYPE_USERNAME
}
if (node.hasAttribute("type", "password")) {
return FIELD_TYPE_PASSWORD
}


if (node.hint?.lowercase()?.contains("user") == true ||
node.hint?.lowercase()?.contains("mail") == true
if (node.hint?.contains("user", true) == true ||
node.hint?.contains("mail", true) == true
) {
return FIELD_TYPE_USERNAME
}
Expand All @@ -141,6 +157,10 @@ class AssistStructureParser(assistStructure: AssistStructure) {
if (node.inputType.isTextType()) {
return FIELD_TYPE_TEXT
}

if (node.hasAttribute("type", "text")) {
return FIELD_TYPE_TEXT
}
}
return null
}
Expand All @@ -153,7 +173,7 @@ class AssistStructureParser(assistStructure: AssistStructure) {
* @return Whether the value of the provided attribute matches the provided value.
*/
private fun AssistStructure.ViewNode?.hasAttribute(attr: String, value: String): Boolean =
this?.htmlInfo?.attributes?.firstOrNull { it.first == attr && it.second == value } != null
this?.htmlInfo?.attributes?.firstOrNull { it.first.lowercase() == attr && it.second.lowercase() == value } != null

/**
* Check if a text field matches the [InputType.TYPE_CLASS_TEXT] input type.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.hegocre.nextcloudpasswords.services.autofill

import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.assist.AssistStructure
import android.content.Context
import android.content.Intent
import android.content.IntentSender
Expand All @@ -20,91 +19,186 @@ import android.widget.inline.InlinePresentationSpec
import androidx.annotation.RequiresApi
import androidx.autofill.inline.v1.InlineSuggestionUi
import com.hegocre.nextcloudpasswords.R
import com.hegocre.nextcloudpasswords.ui.activities.MainActivity
import android.service.autofill.SaveInfo
import android.os.Bundle
import android.util.Log
import com.hegocre.nextcloudpasswords.utils.AutofillData
import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData

@RequiresApi(Build.VERSION_CODES.O)
object AutofillHelper {
@RequiresApi(Build.VERSION_CODES.O)
fun buildDataset(
context: Context,
password: Triple<String, String, String>?,
assistStructure: AssistStructure,
password: PasswordAutofillData?,
helper: AssistStructureParser,
inlinePresentationSpec: InlinePresentationSpec?,
authenticationIntent: IntentSender? = null
intent: IntentSender? = null,
needsAppLock: Boolean = false,
datasetIdx: Int = 0
): Dataset {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (inlinePresentationSpec != null) {
buildInlineDataset(
context,
password,
assistStructure,
helper,
inlinePresentationSpec,
authenticationIntent
intent,
needsAppLock,
datasetIdx
)
} else {
buildPresentationDataset(context, password, assistStructure, authenticationIntent)
buildPresentationDataset(context, password, helper, intent, needsAppLock, datasetIdx)
}
} else {
buildPresentationDataset(context, password, assistStructure, authenticationIntent)
buildPresentationDataset(context, password, helper, intent, needsAppLock, datasetIdx)
}
}

@RequiresApi(Build.VERSION_CODES.P)
fun buildSaveInfo(helper: AssistStructureParser): Pair<SaveInfo, Bundle?>? {
val requiredIds = mutableListOf<AutofillId>()
val optionalIds = mutableListOf<AutofillId>()

Log.d(NCPAutofillService.TAG, "Building SaveInfo, usernameAutofillIds: ${helper.usernameAutofillIds}, passwordAutofillIds: ${helper.passwordAutofillIds}")

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

Log.d(NCPAutofillService.TAG, "Required IDs: $requiredIds, Optional IDs: $optionalIds")

val type = if (helper.usernameAutofillIds.isNotEmpty()) {
SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD
} else {
SaveInfo.SAVE_DATA_TYPE_PASSWORD
}

val builder = if (requiredIds.isNotEmpty()) {
SaveInfo.Builder(type, requiredIds.toTypedArray())
} else {
SaveInfo.Builder(type)
}

// 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)
}.build(),
Bundle().apply {
putCharSequence(USERNAME, helper.usernameAutofillContent.firstOrNull() ?: "")
}
)
} else if (helper.passwordAutofillIds.isNotEmpty()) {
return Pair(
builder.apply {
setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE)
if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds.toTypedArray())
}.build(),
null
)
} else {
// if not delaying save and no password views, do not save
return null
}
}

@RequiresApi(Build.VERSION_CODES.R)
private fun buildInlineDataset(
context: Context,
password: Triple<String, String, String>?,
assistStructure: AssistStructure,
password: PasswordAutofillData?,
helper: AssistStructureParser,
inlinePresentationSpec: InlinePresentationSpec,
authenticationIntent: IntentSender? = null
intent: IntentSender? = null,
needsAppLock: Boolean = false,
datasetIdx: Int
): Dataset {
val helper = AssistStructureParser(assistStructure)
return Dataset.Builder()
.apply {
// build redacted dataset when app lock is needed
return if (needsAppLock && password?.id != null) {
Dataset.Builder().apply {
helper.usernameAutofillIds.forEach { autofillId ->
addInlineAutofillValue(
context,
autofillId,
password?.first,
password?.second,
password.label,
null,
inlinePresentationSpec
)
}
helper.passwordAutofillIds.forEach { autofillId ->
addInlineAutofillValue(
context,
autofillId,
password?.first,
password?.third,
password.label,
null,
inlinePresentationSpec
)
}
if (authenticationIntent != null) {
setAuthentication(authenticationIntent)
setAuthentication(buildIntent(context, 1005+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure)))
}.build()
} else {
Dataset.Builder().apply {
helper.usernameAutofillIds.forEach { autofillId ->
addInlineAutofillValue(
context,
autofillId,
password?.label,
password?.username,
inlinePresentationSpec
)
}
helper.passwordAutofillIds.forEach { autofillId ->
addInlineAutofillValue(
context,
autofillId,
password?.label,
password?.password,
inlinePresentationSpec
)
}
intent?.let { setAuthentication(it) }
}.build()
}
}

@RequiresApi(Build.VERSION_CODES.O)
private fun buildPresentationDataset(
context: Context,
password: Triple<String, String, String>?,
assistStructure: AssistStructure,
authenticationIntent: IntentSender? = null
password: PasswordAutofillData?,
helper: AssistStructureParser,
intent: IntentSender? = null,
needsAppLock: Boolean = false,
datasetIdx: Int
): Dataset {
val helper = AssistStructureParser(assistStructure)
return Dataset.Builder().apply {
helper.usernameAutofillIds.forEach { autofillId ->
addAutofillValue(context, autofillId, password?.first, password?.second)
}
helper.passwordAutofillIds.forEach { autofillId ->
addAutofillValue(context, autofillId, password?.first, password?.third)
}
if (authenticationIntent != null) {
setAuthentication(authenticationIntent)
}
}.build()
// build redacted dataset when app lock is needed
return if (needsAppLock && password?.id != null) {
Dataset.Builder().apply {
helper.usernameAutofillIds.forEach { autofillId ->
addAutofillValue(context, autofillId, password.label, null)
}
helper.passwordAutofillIds.forEach { autofillId ->
addAutofillValue(context, autofillId, password.label, null)
}
setAuthentication(buildIntent(context, 1005+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure)))
}.build()
} else {
Dataset.Builder().apply {
helper.usernameAutofillIds.forEach { autofillId ->
addAutofillValue(context, autofillId, password?.label, password?.username)
}
helper.passwordAutofillIds.forEach { autofillId ->
addAutofillValue(context, autofillId, password?.label, password?.password)
}
intent?.let { setAuthentication(it) }
}.build()
}
}

@SuppressLint("RestrictedApi")
@RequiresApi(Build.VERSION_CODES.O)
private fun Dataset.Builder.addAutofillValue(
context: Context,
autofillId: AutofillId,
Expand Down Expand Up @@ -154,9 +248,8 @@ object AutofillHelper {
) {
val autofillLabel = label ?: context.getString(R.string.app_name)

val authIntent = Intent().apply {
val authIntent = Intent(AUTOFILL_INTENT_ID).apply {
setPackage(context.packageName)
identifier = AUTOFILL_INTENT_ID
}

val intentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Expand Down Expand Up @@ -213,5 +306,18 @@ object AutofillHelper {
}
}

fun buildIntent(context: Context, code: Int, autofillData: AutofillData): IntentSender {
val appIntent = Intent(context, MainActivity::class.java).apply {
putExtra(NCPAutofillService.AUTOFILL_DATA, autofillData)
}

val intentFlags = PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE

return PendingIntent.getActivity(
context, code, appIntent, intentFlags
).intentSender
}

private const val AUTOFILL_INTENT_ID = "com.hegocre.nextcloudpasswords.intents.autofill"
const val USERNAME = "username"
}
Loading