Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5c358ff
first draft; autofill service can now directly fill passwords without…
difanta Feb 12, 2026
f5134b8
AutofillService fix: lint
difanta Feb 12, 2026
542176e
complete autofills with authentication without exchanging sensible in…
difanta Feb 16, 2026
066dcdf
one interface to handle different autofill situations;
difanta Feb 17, 2026
4b1d362
maybe fix domain matching
difanta Feb 20, 2026
c162d87
unique autofill intent codes
difanta Feb 21, 2026
83b4208
never use searchByUsername in autofill service because it always sear…
difanta Feb 22, 2026
8b9d710
fix create password autofill bug by remembering the state correctly
difanta Feb 22, 2026
3360454
provide initial values when creating a password during ChoosePwd
difanta Feb 22, 2026
4cda767
remove leftover log
difanta Feb 22, 2026
88df60d
fix the case where master password is required but not saved
difanta Feb 22, 2026
a7d2e9e
restore build
difanta Feb 25, 2026
eb857f3
clean up imports; better label for the "More" button; add isAutofill/…
difanta Feb 28, 2026
7823dd5
prevent intent code conflicts
difanta Feb 28, 2026
935ef55
cleanup autofill service; decrypt passwords lazily to save battery
difanta Feb 28, 2026
a430343
cleanup NavHost; undo manual state management
difanta Feb 28, 2026
2fc43a3
do not autofill textual fields as username
difanta Feb 28, 2026
efab8d5
add translations for "More" button
difanta Apr 2, 2026
014186f
remove unused getAttribute
difanta Apr 2, 2026
31d9891
fix null/empty checks
difanta Apr 2, 2026
2adb198
fix import translations
difanta Jul 3, 2026
4124762
improve lock check; remove some information from logs
difanta Apr 2, 2026
4c5de4b
handle exception thrown in construction of ApiController if not logge…
difanta Apr 2, 2026
b80a53b
plug back cheap login check
difanta Apr 3, 2026
9d8d15a
add kotlin-gradle-plugin classpath
difanta Jul 8, 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 @@ -5,6 +5,7 @@ plugins {
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"
id 'kotlin-parcelize'
}

java {
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
Comment thread
difanta marked this conversation as resolved.

/**
* 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