From f9ea4a053531168b1005a111fcc395c7880344ae Mon Sep 17 00:00:00 2001 From: difanta Date: Thu, 12 Feb 2026 15:38:12 +0100 Subject: [PATCH 01/24] first draft; autofill service can now directly fill passwords without opening app; service opens app lock prompt if needed via Dataset.setAuthentication; autofill service can now save from autofill (wip) --- app/src/main/AndroidManifest.xml | 8 + .../autofill/AssistStructureParser.kt | 39 +- .../services/autofill/AutofillHelper.kt | 116 ++++- .../services/autofill/NCPAutofillService.kt | 461 +++++++++++++++--- .../ui/activities/LockActivity.kt | 49 ++ .../ui/activities/MainActivity.kt | 3 +- 6 files changed, 569 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a26159f..756e725b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,6 +63,14 @@ + + + + + + () val passwordAutofillIds = mutableListOf() + val usernameAutofillContent = mutableListOf() + val passwordAutofillContent = mutableListOf() private var lastTextAutofillId: AutofillId? = null + private var lastTextAutofillContent: String? = null private var candidateTextAutofillId: AutofillId? = null private val webDomains = HashMap() @@ -40,6 +43,7 @@ class AssistStructureParser(assistStructure: AssistStructure) { if (usernameAutofillIds.isEmpty()) candidateTextAutofillId?.let { usernameAutofillIds.add(it) + usernameAutofillContent.add(lastTextAutofillContent) } } @@ -55,15 +59,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() } } } @@ -104,14 +111,24 @@ 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") || @@ -122,13 +139,10 @@ class AssistStructureParser(assistStructure: AssistStructure) { ) { 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 } @@ -153,7 +167,16 @@ 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 + + /** + * Retrieve a HTML attribute value from a view node. + * + * @param attr The attribute to retrieve. + * @return The retrieved attribute value, or null if not found. + */ + private fun AssistStructure.ViewNode?.getAttribute(attr: String): String? = + this?.htmlInfo?.attributes?.firstOrNull { it.first.lowercase() == attr }?.second /** * Check if a text field matches the [InputType.TYPE_CLASS_TEXT] input type. 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 8290b148..3145c194 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 @@ -20,43 +20,60 @@ 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.LockActivity +import android.service.autofill.SaveInfo object AutofillHelper { @RequiresApi(Build.VERSION_CODES.O) fun buildDataset( context: Context, - password: Triple?, - assistStructure: AssistStructure, + password: Triple?, + helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec?, - authenticationIntent: IntentSender? = null + intent: IntentSender? = null, + needsAppLock: Boolean = false ): Dataset { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (inlinePresentationSpec != null) { buildInlineDataset( context, password, - assistStructure, + helper, inlinePresentationSpec, - authenticationIntent + intent, + needsAppLock ) } else { - buildPresentationDataset(context, password, assistStructure, authenticationIntent) + buildPresentationDataset(context, password, helper, intent, needsAppLock) } } else { - buildPresentationDataset(context, password, assistStructure, authenticationIntent) + buildPresentationDataset(context, password, helper, intent, needsAppLock) } } + @RequiresApi(Build.VERSION_CODES.O) + fun buildSaveInfo( + helper: AssistStructureParser, + ): SaveInfo { + return SaveInfo.Builder(SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD).apply { + if(!helper.usernameAutofillIds.isEmpty() && helper.passwordAutofillIds.isEmpty()) { + setFlags(SaveInfo.FLAG_DELAY_SAVE) + } else { + setOptionalIds((helper.usernameAutofillIds + helper.passwordAutofillIds).toTypedArray()) + } + }.build() + } + @RequiresApi(Build.VERSION_CODES.R) private fun buildInlineDataset( context: Context, - password: Triple?, - assistStructure: AssistStructure, + password: Triple?, + helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec, - authenticationIntent: IntentSender? = null + intent: IntentSender? = null, + needsAppLock: Boolean = false ): Dataset { - val helper = AssistStructureParser(assistStructure) - return Dataset.Builder() + val dataset = Dataset.Builder() .apply { helper.usernameAutofillIds.forEach { autofillId -> addInlineAutofillValue( @@ -76,31 +93,72 @@ object AutofillHelper { inlinePresentationSpec ) } - if (authenticationIntent != null) { - setAuthentication(authenticationIntent) + if (intent != null) { + setAuthentication(intent) + } + }.build() + + + if (needsAppLock) { + return Dataset.Builder().apply { + helper.usernameAutofillIds.forEach { autofillId -> + addInlineAutofillValue( + context, + autofillId, + password?.first, + null, + inlinePresentationSpec + ) + } + helper.passwordAutofillIds.forEach { autofillId -> + addInlineAutofillValue( + context, + autofillId, + password?.first, + null, + inlinePresentationSpec + ) } + setAuthentication(buildAppLockIntent(context, dataset)) }.build() + } else { + return dataset + } } @RequiresApi(Build.VERSION_CODES.O) private fun buildPresentationDataset( context: Context, - password: Triple?, - assistStructure: AssistStructure, - authenticationIntent: IntentSender? = null + password: Triple?, + helper: AssistStructureParser, + intent: IntentSender? = null, + needsAppLock: Boolean = false ): Dataset { - val helper = AssistStructureParser(assistStructure) - return Dataset.Builder().apply { + val dataset = 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) + if (intent != null) { + setAuthentication(intent) } }.build() + + if (needsAppLock) { + return Dataset.Builder().apply { + helper.usernameAutofillIds.forEach { autofillId -> + addAutofillValue(context, autofillId, password?.first, null) + } + helper.passwordAutofillIds.forEach { autofillId -> + addAutofillValue(context, autofillId, password?.first, null) + } + setAuthentication(buildAppLockIntent(context, dataset)) + }.build() + } else { + return dataset + } } @SuppressLint("RestrictedApi") @@ -213,5 +271,21 @@ object AutofillHelper { } } + fun buildAppLockIntent(context: Context, dataset: Dataset): IntentSender { + val authIntent = Intent(context, LockActivity::class.java).apply { + putExtra(NCPAutofillService.SELECTED_DATASET, dataset) + } + + val intentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_CANCEL_CURRENT + } + + return PendingIntent.getActivity( + context, 1001, authIntent, intentFlags + ).intentSender + } + private const val AUTOFILL_INTENT_ID = "com.hegocre.nextcloudpasswords.intents.autofill" } \ 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 3ec208a9..6213c10c 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 @@ -12,78 +12,242 @@ import android.service.autofill.FillRequest import android.service.autofill.FillResponse import android.service.autofill.SaveCallback import android.service.autofill.SaveRequest +import androidx.compose.runtime.collectAsState +import android.util.Log import androidx.annotation.RequiresApi +import androidx.lifecycle.asFlow +import com.hegocre.nextcloudpasswords.api.ApiController +import com.hegocre.nextcloudpasswords.data.password.Password +import com.hegocre.nextcloudpasswords.data.password.PasswordController +import com.hegocre.nextcloudpasswords.data.password.NewPassword +import com.hegocre.nextcloudpasswords.data.password.UpdatedPassword import com.hegocre.nextcloudpasswords.data.user.UserController import com.hegocre.nextcloudpasswords.data.user.UserException import com.hegocre.nextcloudpasswords.utils.PreferencesManager +import com.hegocre.nextcloudpasswords.utils.decryptPasswords +import com.hegocre.nextcloudpasswords.utils.AppLockHelper +import com.hegocre.nextcloudpasswords.ui.activities.MainActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.CancellationException +import android.content.Context +import android.content.IntentSender +import com.hegocre.nextcloudpasswords.utils.encryptValue +import com.hegocre.nextcloudpasswords.utils.sha1Hash +import com.hegocre.nextcloudpasswords.api.FoldersApi @RequiresApi(Build.VERSION_CODES.O) class NCPAutofillService : AutofillService() { + + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob) + + private val preferencesManager by lazy { PreferencesManager.getInstance(applicationContext) } + private val apiController by lazy { ApiController.getInstance(applicationContext) } + private val passwordController by lazy { PasswordController.getInstance(applicationContext) } + private val userController by lazy { UserController.getInstance(applicationContext) } + + private val hasAppLock by lazy { preferencesManager.getHasAppLock() } + + val orderBy by lazy { preferencesManager.getOrderBy() } + val searchByUsername by lazy { preferencesManager.getSearchByUsername() } + val strictUrlMatching by lazy { preferencesManager.getUseStrictUrlMatching() } + + private lateinit var decryptedPasswordsState: StateFlow> + + override fun onCreate() { + super.onCreate() + decryptedPasswordsState = combine( + passwordController.getPasswords().asFlow(), + apiController.csEv1Keychain.asFlow() + ) { passwords, keychain -> + passwords.filter { !it.trashed && !it.hidden }.decryptPasswords(keychain) + } + .flowOn(Dispatchers.Default) + .stateIn( + scope = serviceScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) + } + + override fun onDestroy() { + super.onDestroy() + serviceJob.cancel() + } + @SuppressLint("RestrictedApi") override fun onFillRequest( request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback ) { - val context = request.fillContexts - val structure = context.last().structure + val job = serviceScope.launch { + try { + val response = withContext(Dispatchers.Default) { + processFillRequest(request) + } + callback.onSuccess(response) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error handling fill request ${e.message}") + callback.onSuccess(null) + } + } + + cancellationSignal.setOnCancelListener { + job.cancel() + } + } - val helper = AssistStructureParser(structure) + private suspend fun processFillRequest(request: FillRequest): FillResponse? { + val context = request.fillContexts.last() ?: return null + val helper = AssistStructureParser(context.structure) // Do not autofill this application - if (helper.packageName == packageName) { - callback.onSuccess(null) - return + if (helper.packageName == packageName) return null + + if (helper.usernameAutofillIds.isEmpty() && helper.passwordAutofillIds.isEmpty()) { + return null } + + // Check Login Status try { - UserController.getInstance(applicationContext).getServer() + userController.getServer() } catch (_: UserException) { - // User not logged in, cannot fill request - callback.onSuccess(null) - return + Log.e(TAG, "User not logged in, cannot autofill") + return null + } + + // Try to open Session + if (!apiController.sessionOpen.value) { + if (!apiController.openSession(preferencesManager.getMasterPassword())) { + Log.w(TAG, "Session is not open and cannot be opened") + } } - val useInline = PreferencesManager.getInstance(applicationContext).getUseInlineAutofill() - val inlineSuggestionsRequest = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && useInline) { - request.inlineSuggestionsRequest - } else null + if (apiController.sessionOpen.value) { + passwordController.syncPasswords() + } - val searchHint: String? = when { - // If the structure contains a domain, use that (probably a web browser) - helper.webDomain != null -> { - helper.webDomain + // Determine Search Hint + val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) + + // wait for passwords to be decrypted, then filter by search hint and sort them + val filteredList = decryptedPasswordsState.value.filter { + it.matches(searchHint, strictUrlMatching.first()) || + (searchByUsername.first() && it.username.contains(searchHint, ignoreCase = true)) + }.let { list -> + when (orderBy.first()) { + PreferencesManager.ORDER_BY_TITLE_DESCENDING -> list.sortedByDescending { it.label.lowercase() } + PreferencesManager.ORDER_BY_DATE_ASCENDING -> list.sortedBy { it.edited } + PreferencesManager.ORDER_BY_DATE_DESCENDING -> list.sortedByDescending { it.edited } + else -> list.sortedBy { it.label.lowercase() } } + } - else -> with(packageManager) { - //Get the name of the package (QUERY_ALL_PACKAGES permission needed) - try { - val app = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - getApplicationInfo( - helper.packageName, - PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) - ) - else - getApplicationInfo( - helper.packageName, - PackageManager.GET_META_DATA - ) - - getApplicationLabel(app).toString() - } catch (e: PackageManager.NameNotFoundException) { - e.printStackTrace() - null - } + return buildFillResponse( + filteredList, + helper, + request, + searchHint + ) + } + + private suspend fun buildFillResponse( + passwords: List, + helper: AssistStructureParser, + request: FillRequest, + searchHint: String + ): FillResponse { + val builder = FillResponse.Builder() + val useInline = preferencesManager.getUseInlineAutofill() + + val inlineRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && useInline) { + request.inlineSuggestionsRequest + } else null + + val mainAppIntent = buildMainAppIntent(applicationContext, searchHint) + + val needsAuth = hasAppLock.first() + + // Add one Dataset for each password + for (password in passwords) { + builder.addDataset( + AutofillHelper.buildDataset( + applicationContext, + Triple("${password.label} - ${password.username}", password.username, password.password), + helper, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, + null, + needsAuth + ) + ) + } + + // Add "Generate Password" option (only if there are no passwords?) + if (passwords.isEmpty() && apiController.sessionOpen.value) { + val options = preferencesManager.getPasswordGenerationOptions()?.split(";") ?: listOf() + apiController.generatePassword( + options.getOrNull(0)?.toIntOrNull() ?: 4, + options.getOrNull(1)?.toBooleanStrictOrNull() ?: true, + options.getOrNull(2)?.toBooleanStrictOrNull() ?: true + )?.let { + builder.addDataset( + AutofillHelper.buildDataset( + applicationContext, + Triple("Generate new password", null, it), + helper, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, + null, + false + ) + ) } } - // Intent to open MainActivity and provide a response to the request - val authIntent = Intent("com.hegocre.nextcloudpasswords.action.main").apply { - setPackage(packageName) + // Option to conclude the autofill in the app + builder.addDataset( + AutofillHelper.buildDataset( + applicationContext, + null, + helper, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, + mainAppIntent, + false + ) + ) + + return builder.setSaveInfo(AutofillHelper.buildSaveInfo(helper)).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) { + "" + } + } + + private fun buildMainAppIntent(context: Context, searchHint: String): IntentSender { + val appIntent = Intent(context, MainActivity::class.java).apply { putExtra(AUTOFILL_REQUEST, true) - searchHint?.let { - putExtra(AUTOFILL_SEARCH_HINT, it) - } + putExtra(AUTOFILL_SEARCH_HINT, searchHint) } val intentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -92,52 +256,195 @@ class NCPAutofillService : AutofillService() { PendingIntent.FLAG_CANCEL_CURRENT } - val intentSender = PendingIntent.getActivity( - this, - 1001, - authIntent, - intentFlags + return PendingIntent.getActivity( + context, 1001, appIntent, intentFlags ).intentSender + } - if (helper.passwordAutofillIds.isNotEmpty()) { - val fillResponse = FillResponse.Builder().apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - addDataset( - AutofillHelper.buildDataset( - applicationContext, - null, - structure, - inlineSuggestionsRequest?.inlinePresentationSpecs?.first(), - intentSender - ) - ) - } else { - addDataset( - AutofillHelper.buildDataset( - applicationContext, - null, - structure, - null, - intentSender - ) - ) + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + val job = serviceScope.launch { + try { + val response: Boolean = withContext(Dispatchers.Default) { + processSaveRequest(request) + } + if (response) callback.onSuccess() + else callback.onFailure("Unable to complete Save Request") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + callback.onFailure("Error handling save request: ${e.message}") + } + } + } + + private suspend fun processSaveRequest(request: SaveRequest): Boolean { + val context = request.fillContexts.last() ?: return false + val helper = AssistStructureParser(context.structure) + + // Do not autofill this application + if (helper.packageName == packageName) return false + + val username: String = helper.usernameAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: "" + val password: String = helper.passwordAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: "" + + if (password.isBlank()) { + val usernameIds = helper.usernameAutofillIds.map { it.toString() } + val passwordIds = helper.passwordAutofillIds.map { it.toString() } + throw Exception("Blank password, cannot save") + } + + // Check Login Status + try { + userController.getServer() + } catch (_: UserException) { + throw Exception("User not logged in, cannot save") + } + + // Ensure Session is open + if (!apiController.sessionOpen.value) { + if (!apiController.openSession(preferencesManager.getMasterPassword())) { + throw Exception("Session is not open and cannot be opened, Cannot save") + } + } + + // Determine Search Hint + val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) + + // wait for passwords to be decrypted, then filter by search hint and sort them + val filteredList = decryptedPasswordsState.value.filter { + it.matches(searchHint, strictUrlMatching.first()) || + (searchByUsername.first() && it.username.contains(searchHint, ignoreCase = true)) + }.let { list -> + when (orderBy.first()) { + PreferencesManager.ORDER_BY_TITLE_DESCENDING -> list.sortedByDescending { it.label.lowercase() } + PreferencesManager.ORDER_BY_DATE_ASCENDING -> list.sortedBy { it.edited } + PreferencesManager.ORDER_BY_DATE_DESCENDING -> list.sortedByDescending { it.edited } + else -> list.sortedBy { it.label.lowercase() } + } + } + + if (filteredList.isEmpty()) { + // prompt to choose label? + if (!createPassword(searchHint, username, password, searchHint)) { + throw Exception("Failed to create password") + } + } else { + // should prompt too choose which one(s) + filteredList.forEach { + if (!it.equals(username, password, searchHint)) { + if (!updatePassword(it, searchHint, username, password, searchHint)) { + throw Exception("Failed to update password") + } } - }.build() + } + } + + return true + } + // check equality ignoring label + private fun Password.equals(username: String, password: String, url: String): Boolean { + return (this.username == username) + && (this.password == password) + && (this.url == url) + } - callback.onSuccess(fillResponse) + private suspend fun createPassword(label: String, username: String, password: String, url: String): Boolean { + val keychain = apiController.csEv1Keychain.asFlow().first() + val serverSettings = apiController.serverSettings.asFlow().first() + + lateinit var newPassword: NewPassword + if(keychain != null && serverSettings.encryptionCse != 0) { + newPassword = NewPassword( + password = password.encryptValue(keychain.current, keychain), + label = label.encryptValue(keychain.current, keychain), + username = username.encryptValue(keychain.current, keychain), + url = url.encryptValue(keychain.current, keychain), + notes = "".encryptValue(keychain.current, keychain), + customFields = "[]".encryptValue(keychain.current, keychain), + hash = password.sha1Hash(), + cseType = "CSEv1r1", + cseKey = keychain.current, + folder = FoldersApi.DEFAULT_FOLDER_UUID, + edited = (System.currentTimeMillis() / 1000).toInt(), + hidden = false, + favorite = false + ) } else { - // Do not return a response if there are no autofill fields. - callback.onSuccess(null) + newPassword = NewPassword( + password = password, + label = label, + username = username, + url = url, + notes = "", + customFields = "[]", + hash = password.sha1Hash(), + cseType = "none", + cseKey = "", + folder = FoldersApi.DEFAULT_FOLDER_UUID, + edited = (System.currentTimeMillis() / 1000).toInt(), + hidden = false, + favorite = false + ) } + return apiController.createPassword(newPassword) } - override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { - callback.onFailure("Not implemented") + suspend fun updatePassword(oldPassword: Password, label: String, username: String, password: String, url: String): Boolean { + val keychain = apiController.csEv1Keychain.asFlow().first() + val serverSettings = apiController.serverSettings.asFlow().first() + + val _label = oldPassword.label // do not change labels (we are just using searchHint for the label) + val _username = if (username.isBlank()) oldPassword.username else username + val _password = if (password.isBlank()) oldPassword.password else password + val _url = if (url.isBlank()) oldPassword.url else url + + lateinit var updatedPassword: UpdatedPassword + if(keychain != null && serverSettings.encryptionCse != 0) { + updatedPassword = UpdatedPassword( + id = oldPassword.id, + revision = oldPassword.revision, + password = _password.encryptValue(keychain.current, keychain) , + label = _label.encryptValue(keychain.current, keychain) , + username = _username.encryptValue(keychain.current, keychain) , + url = _url.encryptValue(keychain.current, keychain) , + notes = oldPassword.notes.encryptValue(keychain.current, keychain), + customFields = oldPassword.customFields.encryptValue(keychain.current, keychain), + hash = password.sha1Hash(), + cseType = "CSEv1r1", + cseKey = keychain.current, + folder = oldPassword.folder, + edited = (System.currentTimeMillis() / 1000).toInt(), + hidden = oldPassword.hidden, + favorite = oldPassword.favorite + ) + } else { + updatedPassword = UpdatedPassword( + id = oldPassword.id, + revision = oldPassword.revision, + password = _password, + label = _label, + username = _username, + url = _url, + notes = oldPassword.notes, + customFields = oldPassword.customFields, + hash = password.sha1Hash(), + cseType = "none", + cseKey = "", + folder = oldPassword.folder, + edited = (System.currentTimeMillis() / 1000).toInt(), + hidden = oldPassword.hidden, + favorite = oldPassword.favorite + ) + } + return apiController.updatePassword(updatedPassword) } companion object { + const val TAG = "NCPAutofillService" + private const val TIMEOUT_MS = 2000L const val AUTOFILL_REQUEST = "autofill_request" const val AUTOFILL_SEARCH_HINT = "autofill_query" + const val SELECTED_DATASET = "selected_dataset" } } \ No newline at end of file diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt new file mode 100644 index 00000000..df0ef250 --- /dev/null +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt @@ -0,0 +1,49 @@ +package com.hegocre.nextcloudpasswords.ui.activities + +import com.hegocre.nextcloudpasswords.ui.components.NextcloudPasswordsAppLock +import com.hegocre.nextcloudpasswords.utils.AppLockHelper +import com.hegocre.nextcloudpasswords.services.autofill.NCPAutofillService +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.FragmentActivity +import androidx.activity.compose.setContent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.remember +import android.app.Activity +import android.util.Log +import android.view.autofill.AutofillManager +import android.service.autofill.Dataset +import android.os.Build + +class LockActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val context = LocalContext.current + val appLockHelper = remember { AppLockHelper.getInstance(context) } + + NextcloudPasswordsAppLock( + onCheckPasscode = appLockHelper::checkPasscode, + onCorrectPasscode = { + appLockHelper.disableLock() + + val dataset: Dataset? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + intent.getParcelableExtra( + NCPAutofillService.SELECTED_DATASET, + Dataset::class.java + ) + else + @Suppress("DEPRECATION") intent.getParcelableExtra(NCPAutofillService.SELECTED_DATASET) + + val resultIntent = Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) + } + + setResult(Activity.RESULT_OK, resultIntent) + finish() + } + ) + } + } +} \ No newline at end of file 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 f27cb1b5..d823d79c 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 @@ -20,6 +20,7 @@ import com.hegocre.nextcloudpasswords.api.ApiController import com.hegocre.nextcloudpasswords.data.user.UserController import com.hegocre.nextcloudpasswords.services.autofill.AutofillHelper import com.hegocre.nextcloudpasswords.services.autofill.NCPAutofillService +import com.hegocre.nextcloudpasswords.services.autofill.AssistStructureParser import com.hegocre.nextcloudpasswords.ui.components.NCPAppLockWrapper import com.hegocre.nextcloudpasswords.ui.components.NextcloudPasswordsApp import com.hegocre.nextcloudpasswords.ui.viewmodels.PasswordsViewModel @@ -141,7 +142,7 @@ class MainActivity : FragmentActivity() { password: Triple, structure: AssistStructure ) { - val dataset = AutofillHelper.buildDataset(this, password, structure, null) + val dataset = AutofillHelper.buildDataset(this, password, AssistStructureParser(structure), null, null, false) val replyIntent = Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) From 07f29622d4bd4b239ca04d6301a2cf0a8cfeabf2 Mon Sep 17 00:00:00 2001 From: difanta Date: Thu, 12 Feb 2026 16:30:09 +0100 Subject: [PATCH 02/24] AutofillService fix: lint --- .../services/autofill/AutofillHelper.kt | 8 +++----- .../services/autofill/NCPAutofillService.kt | 10 +++++++--- .../nextcloudpasswords/ui/activities/LockActivity.kt | 2 ++ 3 files changed, 12 insertions(+), 8 deletions(-) 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 3145c194..241f9d5d 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 @@ -23,8 +23,8 @@ import com.hegocre.nextcloudpasswords.R import com.hegocre.nextcloudpasswords.ui.activities.LockActivity import android.service.autofill.SaveInfo +@RequiresApi(Build.VERSION_CODES.O) object AutofillHelper { - @RequiresApi(Build.VERSION_CODES.O) fun buildDataset( context: Context, password: Triple?, @@ -51,12 +51,12 @@ object AutofillHelper { } } - @RequiresApi(Build.VERSION_CODES.O) + @RequiresApi(Build.VERSION_CODES.O_MR1) fun buildSaveInfo( helper: AssistStructureParser, ): SaveInfo { return SaveInfo.Builder(SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD).apply { - if(!helper.usernameAutofillIds.isEmpty() && helper.passwordAutofillIds.isEmpty()) { + if(!helper.usernameAutofillIds.isEmpty() && helper.passwordAutofillIds.isEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { setFlags(SaveInfo.FLAG_DELAY_SAVE) } else { setOptionalIds((helper.usernameAutofillIds + helper.passwordAutofillIds).toTypedArray()) @@ -126,7 +126,6 @@ object AutofillHelper { } } - @RequiresApi(Build.VERSION_CODES.O) private fun buildPresentationDataset( context: Context, password: Triple?, @@ -162,7 +161,6 @@ object AutofillHelper { } @SuppressLint("RestrictedApi") - @RequiresApi(Build.VERSION_CODES.O) private fun Dataset.Builder.addAutofillValue( context: Context, autofillId: AutofillId, 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 6213c10c..ef2fa92d 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 @@ -14,7 +14,7 @@ import android.service.autofill.SaveCallback import android.service.autofill.SaveRequest import androidx.compose.runtime.collectAsState import android.util.Log -import androidx.annotation.RequiresApi +import android.annotation.TargetApi import androidx.lifecycle.asFlow import com.hegocre.nextcloudpasswords.api.ApiController import com.hegocre.nextcloudpasswords.data.password.Password @@ -42,7 +42,7 @@ import com.hegocre.nextcloudpasswords.utils.encryptValue import com.hegocre.nextcloudpasswords.utils.sha1Hash import com.hegocre.nextcloudpasswords.api.FoldersApi -@RequiresApi(Build.VERSION_CODES.O) +@TargetApi(Build.VERSION_CODES.O) class NCPAutofillService : AutofillService() { private val serviceJob = SupervisorJob() @@ -225,7 +225,11 @@ class NCPAutofillService : AutofillService() { ) ) - return builder.setSaveInfo(AutofillHelper.buildSaveInfo(helper)).build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + builder.setSaveInfo(AutofillHelper.buildSaveInfo(helper)) + } + + return builder.build() } private suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt index df0ef250..18671c2e 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt @@ -14,7 +14,9 @@ import android.util.Log import android.view.autofill.AutofillManager import android.service.autofill.Dataset import android.os.Build +import android.annotation.TargetApi +@TargetApi(Build.VERSION_CODES.O) class LockActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) From 770d37da3e11409f1829acf6caefb6078820c431 Mon Sep 17 00:00:00 2001 From: difanta Date: Mon, 16 Feb 2026 21:34:50 +0100 Subject: [PATCH 03/24] complete autofills with authentication without exchanging sensible information in intents, so no need for a separate lock only activity but rather main activity is in charge of authenticating and answering; fix building Save Info, now should work in most common cases, including delayed username and password insertion; wip: dedicated UI to handle save requests (create/update) --- app/src/main/AndroidManifest.xml | 8 - .../autofill/AssistStructureParser.kt | 2 + .../services/autofill/AutofillHelper.kt | 192 ++++++++---- .../services/autofill/NCPAutofillService.kt | 291 +++++++----------- .../ui/activities/LockActivity.kt | 51 --- .../ui/activities/MainActivity.kt | 69 ++++- .../ui/components/NCPAppForAutofill.kt | 215 +++++++++++++ .../ui/components/NCPNavHostForAutofill.kt | 163 ++++++++++ 8 files changed, 680 insertions(+), 311 deletions(-) delete mode 100644 app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt create mode 100644 app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPAppForAutofill.kt create mode 100644 app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHostForAutofill.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 756e725b..8a26159f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,14 +63,6 @@ - - - - - - () val packageName = assistStructure.activityComponent.flattenToShortString().substringBefore("/") 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 241f9d5d..18cb6fac 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 @@ -20,14 +20,54 @@ 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.LockActivity +import com.hegocre.nextcloudpasswords.ui.activities.MainActivity import android.service.autofill.SaveInfo +import android.os.Parcel +import android.os.Parcelable +import android.os.Bundle +import android.util.Log +import android.view.autofill.AutofillManager + +data class SaveData( + val label: String, + val username: String, + val password: String, + val url: String +) : Parcelable { + constructor(parcel: Parcel) : this( + parcel.readString() ?: "", + parcel.readString() ?: "", + parcel.readString() ?: "", + parcel.readString() ?: "" + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(label) + parcel.writeString(username) + parcel.writeString(password) + parcel.writeString(url) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SaveData { + return SaveData(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} + +data class PasswordAutofillData(val id: String?, val label: String, val username: String?, val password: String?) @RequiresApi(Build.VERSION_CODES.O) object AutofillHelper { fun buildDataset( context: Context, - password: Triple?, + password: PasswordAutofillData?, helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec?, intent: IntentSender? = null, @@ -52,35 +92,75 @@ object AutofillHelper { } @RequiresApi(Build.VERSION_CODES.O_MR1) - fun buildSaveInfo( - helper: AssistStructureParser, - ): SaveInfo { - return SaveInfo.Builder(SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD).apply { - if(!helper.usernameAutofillIds.isEmpty() && helper.passwordAutofillIds.isEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - setFlags(SaveInfo.FLAG_DELAY_SAVE) - } else { - setOptionalIds((helper.usernameAutofillIds + helper.passwordAutofillIds).toTypedArray()) - } - }.build() + fun buildSaveInfo(helper: AssistStructureParser): Pair? { + val requiredIds = mutableListOf() + val optionalIds = mutableListOf() + + 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.isEmpty()) { + SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD + } else { + SaveInfo.SAVE_DATA_TYPE_PASSWORD + } + + val builder = if (!requiredIds.isEmpty()) { + 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.isEmpty() && 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.isEmpty()) { + return Pair( + builder.apply { + setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE) + if (!optionalIds.isEmpty()) 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?, + password: PasswordAutofillData?, helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec, intent: IntentSender? = null, needsAppLock: Boolean = false ): Dataset { - val dataset = Dataset.Builder() - .apply { + // build redacted dataset when app lock is needed + return if (needsAppLock && password != null && password.id != null) { + Dataset.Builder().apply { helper.usernameAutofillIds.forEach { autofillId -> addInlineAutofillValue( context, autofillId, - password?.first, - password?.second, + password.label, + null, inlinePresentationSpec ) } @@ -88,25 +168,21 @@ object AutofillHelper { addInlineAutofillValue( context, autofillId, - password?.first, - password?.third, + password.label, + null, inlinePresentationSpec ) } - if (intent != null) { - setAuthentication(intent) - } + setAuthentication(buildAppLockIntent(context, password.id, helper)) }.build() - - - if (needsAppLock) { - return Dataset.Builder().apply { + } else { + Dataset.Builder().apply { helper.usernameAutofillIds.forEach { autofillId -> addInlineAutofillValue( context, autofillId, - password?.first, - null, + password?.label, + password?.username, inlinePresentationSpec ) } @@ -114,49 +190,44 @@ object AutofillHelper { addInlineAutofillValue( context, autofillId, - password?.first, - null, + password?.label, + password?.password, inlinePresentationSpec ) } - setAuthentication(buildAppLockIntent(context, dataset)) + intent?.let { setAuthentication(it) } }.build() - } else { - return dataset } } private fun buildPresentationDataset( context: Context, - password: Triple?, + password: PasswordAutofillData?, helper: AssistStructureParser, intent: IntentSender? = null, needsAppLock: Boolean = false ): Dataset { - val dataset = 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 (intent != null) { - setAuthentication(intent) - } - }.build() - - if (needsAppLock) { - return Dataset.Builder().apply { + // build redacted dataset when app lock is needed + return if (needsAppLock && password != null && password.id != null) { + Dataset.Builder().apply { helper.usernameAutofillIds.forEach { autofillId -> - addAutofillValue(context, autofillId, password?.first, null) + addAutofillValue(context, autofillId, password.label, null) } helper.passwordAutofillIds.forEach { autofillId -> - addAutofillValue(context, autofillId, password?.first, null) + addAutofillValue(context, autofillId, password.label, null) } - setAuthentication(buildAppLockIntent(context, dataset)) + setAuthentication(buildAppLockIntent(context, password.id, helper)) }.build() } else { - return dataset + 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() } } @@ -269,21 +340,20 @@ object AutofillHelper { } } - fun buildAppLockIntent(context: Context, dataset: Dataset): IntentSender { - val authIntent = Intent(context, LockActivity::class.java).apply { - putExtra(NCPAutofillService.SELECTED_DATASET, dataset) + fun buildAppLockIntent(context: Context, passwordId: String, helper: AssistStructureParser): IntentSender { + val authIntent = Intent(context, MainActivity::class.java).apply { + putExtra(NCPAutofillService.AUTOFILL_REQUEST, true) + putExtra(NCPAutofillService.PASSWORD_ID, passwordId) + putExtra(AutofillManager.EXTRA_ASSIST_STRUCTURE, helper.structure) } - val intentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - } + val intentFlags = PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE return PendingIntent.getActivity( - context, 1001, authIntent, intentFlags + context, 1001, authIntent, intentFlags // TODO: unique code? ).intentSender } private const val AUTOFILL_INTENT_ID = "com.hegocre.nextcloudpasswords.intents.autofill" + const val USERNAME = "username" } \ 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 ef2fa92d..10f27148 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 @@ -44,7 +44,6 @@ import com.hegocre.nextcloudpasswords.api.FoldersApi @TargetApi(Build.VERSION_CODES.O) class NCPAutofillService : AutofillService() { - private val serviceJob = SupervisorJob() private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob) @@ -52,9 +51,11 @@ class NCPAutofillService : AutofillService() { private val apiController by lazy { ApiController.getInstance(applicationContext) } private val passwordController by lazy { PasswordController.getInstance(applicationContext) } private val userController by lazy { UserController.getInstance(applicationContext) } + private val appLockHelper by lazy { AppLockHelper.getInstance(applicationContext) } private val hasAppLock by lazy { preferencesManager.getHasAppLock() } - + private val isLocked by lazy { appLockHelper.isLocked } + val orderBy by lazy { preferencesManager.getOrderBy() } val searchByUsername by lazy { preferencesManager.getSearchByUsername() } val strictUrlMatching by lazy { preferencesManager.getUseStrictUrlMatching() } @@ -93,11 +94,12 @@ class NCPAutofillService : AutofillService() { val response = withContext(Dispatchers.Default) { processFillRequest(request) } - callback.onSuccess(response) + if (response != null) callback.onSuccess(response) + else callback.onFailure("Could not complete fill request") } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Error handling fill request ${e.message}") + Log.e(TAG, "Error handling fill request: ${e.message}") callback.onSuccess(null) } } @@ -108,6 +110,7 @@ class NCPAutofillService : AutofillService() { } private suspend fun processFillRequest(request: FillRequest): FillResponse? { + Log.d(TAG, "Processing fill request") val context = request.fillContexts.last() ?: return null val helper = AssistStructureParser(context.structure) @@ -115,6 +118,7 @@ class NCPAutofillService : AutofillService() { if (helper.packageName == packageName) return null if (helper.usernameAutofillIds.isEmpty() && helper.passwordAutofillIds.isEmpty()) { + Log.e(TAG, "No username or password fields detected, cannot autofill") return null } @@ -126,20 +130,25 @@ class NCPAutofillService : AutofillService() { return null } + Log.d(TAG, "User is logged in") + // Try to open Session - if (!apiController.sessionOpen.value) { - if (!apiController.openSession(preferencesManager.getMasterPassword())) { - Log.w(TAG, "Session is not open and cannot be opened") - } + if (!apiController.sessionOpen.value && !apiController.openSession(preferencesManager.getMasterPassword())) { + Log.w(TAG, "Session is not open and cannot be opened") + // TODO: stop if we need the decrypted keychain } + Log.d(TAG, "Session is open") - if (apiController.sessionOpen.value) { - passwordController.syncPasswords() - } + // TODO: when to update? + //if (apiController.sessionOpen.value) { + // passwordController.syncPasswords() + //} // Determine Search Hint val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) + Log.d(TAG, "Search hint determined: $searchHint") + // wait for passwords to be decrypted, then filter by search hint and sort them val filteredList = decryptedPasswordsState.value.filter { it.matches(searchHint, strictUrlMatching.first()) || @@ -153,11 +162,16 @@ class NCPAutofillService : AutofillService() { } } + Log.d(TAG, "Passwords filtered and sorted, count: ${filteredList.size}") + + val needsAuth = hasAppLock.first() && (isLocked.firstOrNull() ?: true) + return buildFillResponse( filteredList, helper, request, - searchHint + searchHint, + needsAuth ) } @@ -165,8 +179,10 @@ class NCPAutofillService : AutofillService() { passwords: List, helper: AssistStructureParser, request: FillRequest, - searchHint: String + searchHint: String, + needsAuth: Boolean ): FillResponse { + Log.d(TAG, "Building FillResponse with ${passwords.size} passwords, needsAuth: $needsAuth") val builder = FillResponse.Builder() val useInline = preferencesManager.getUseInlineAutofill() @@ -174,16 +190,17 @@ class NCPAutofillService : AutofillService() { request.inlineSuggestionsRequest } else null - val mainAppIntent = buildMainAppIntent(applicationContext, searchHint) - - val needsAuth = hasAppLock.first() - // Add one Dataset for each password for (password in passwords) { builder.addDataset( AutofillHelper.buildDataset( applicationContext, - Triple("${password.label} - ${password.username}", password.username, password.password), + 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, @@ -192,26 +209,29 @@ class NCPAutofillService : AutofillService() { ) } - // Add "Generate Password" option (only if there are no passwords?) - if (passwords.isEmpty() && apiController.sessionOpen.value) { - val options = preferencesManager.getPasswordGenerationOptions()?.split(";") ?: listOf() - apiController.generatePassword( - options.getOrNull(0)?.toIntOrNull() ?: 4, - options.getOrNull(1)?.toBooleanStrictOrNull() ?: true, - options.getOrNull(2)?.toBooleanStrictOrNull() ?: true - )?.let { + 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 = "", + password = "", + url = searchHint + ) builder.addDataset( AutofillHelper.buildDataset( applicationContext, - Triple("Generate new password", null, it), + PasswordAutofillData(label = "Create new password", id = null, username = null, password = null), helper, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, - null, + buildSaveIntent(applicationContext, saveData, true), false ) ) } - } + + Log.d(TAG, "Button to create new password added to FillResponse") // Option to conclude the autofill in the app builder.addDataset( @@ -220,15 +240,25 @@ class NCPAutofillService : AutofillService() { null, helper, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, - mainAppIntent, + buildMainAppIntent(applicationContext, searchHint), false ) ) + + Log.d(TAG, "Button to open app added to FillResponse") + // set Save Info, with an optional bundle if delaying the save if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - builder.setSaveInfo(AutofillHelper.buildSaveInfo(helper)) + AutofillHelper.buildSaveInfo(helper)?.let { pair -> + builder.setSaveInfo(pair.first) + pair.second?.let { bundle -> + builder.setClientState(bundle) + } + } } + Log.d(TAG, "SaveInfo set in FillResponse if applicable") + return builder.build() } @@ -248,30 +278,13 @@ class NCPAutofillService : AutofillService() { } } - private fun buildMainAppIntent(context: Context, searchHint: String): IntentSender { - val appIntent = Intent(context, MainActivity::class.java).apply { - putExtra(AUTOFILL_REQUEST, true) - putExtra(AUTOFILL_SEARCH_HINT, searchHint) - } - - val intentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - } - - return PendingIntent.getActivity( - context, 1001, appIntent, intentFlags - ).intentSender - } - override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { val job = serviceScope.launch { try { - val response: Boolean = withContext(Dispatchers.Default) { + val intent: IntentSender? = withContext(Dispatchers.Default) { processSaveRequest(request) } - if (response) callback.onSuccess() + if (intent != null) callback.onSuccess(intent) else callback.onFailure("Unable to complete Save Request") } catch (e: CancellationException) { throw e @@ -281,19 +294,19 @@ class NCPAutofillService : AutofillService() { } } - private suspend fun processSaveRequest(request: SaveRequest): Boolean { - val context = request.fillContexts.last() ?: return false + private suspend fun processSaveRequest(request: SaveRequest): IntentSender? { + val context = request.fillContexts.last() ?: return null val helper = AssistStructureParser(context.structure) // Do not autofill this application - if (helper.packageName == packageName) return false + if (helper.packageName == packageName) return null + + val delayedUsername: String? = request.clientState?.getCharSequence(AutofillHelper.USERNAME)?.toString() - val username: String = helper.usernameAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: "" + val username: String = helper.usernameAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: delayedUsername ?: "" val password: String = helper.passwordAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: "" if (password.isBlank()) { - val usernameIds = helper.usernameAutofillIds.map { it.toString() } - val passwordIds = helper.passwordAutofillIds.map { it.toString() } throw Exception("Blank password, cannot save") } @@ -305,143 +318,62 @@ class NCPAutofillService : AutofillService() { } // Ensure Session is open - if (!apiController.sessionOpen.value) { - if (!apiController.openSession(preferencesManager.getMasterPassword())) { - throw Exception("Session is not open and cannot be opened, Cannot save") - } + if (!apiController.sessionOpen.value && !apiController.openSession(preferencesManager.getMasterPassword())) { + throw Exception("Session is not open and cannot be opened, cannot save") } // Determine Search Hint val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) - // wait for passwords to be decrypted, then filter by search hint and sort them - val filteredList = decryptedPasswordsState.value.filter { - it.matches(searchHint, strictUrlMatching.first()) || - (searchByUsername.first() && it.username.contains(searchHint, ignoreCase = true)) - }.let { list -> - when (orderBy.first()) { - PreferencesManager.ORDER_BY_TITLE_DESCENDING -> list.sortedByDescending { it.label.lowercase() } - PreferencesManager.ORDER_BY_DATE_ASCENDING -> list.sortedBy { it.edited } - PreferencesManager.ORDER_BY_DATE_DESCENDING -> list.sortedByDescending { it.edited } - else -> list.sortedBy { it.label.lowercase() } - } - } + return buildSaveIntent(applicationContext, prepareSaveData(searchHint, username, password, searchHint)) + } - if (filteredList.isEmpty()) { - // prompt to choose label? - if (!createPassword(searchHint, username, password, searchHint)) { - throw Exception("Failed to create password") - } - } else { - // should prompt too choose which one(s) - filteredList.forEach { - if (!it.equals(username, password, searchHint)) { - if (!updatePassword(it, searchHint, username, password, searchHint)) { - throw Exception("Failed to update password") - } - } - } - } + private suspend fun prepareSaveData(label: String, username: String, password: String, url: String): SaveData { + val keychain = apiController.csEv1Keychain.asFlow().first() + val serverSettings = apiController.serverSettings.asFlow().first() - return true + return if(keychain != null && serverSettings.encryptionCse != 0) SaveData( + password = password.encryptValue(keychain.current, keychain), + label = label.encryptValue(keychain.current, keychain), + username = username.encryptValue(keychain.current, keychain), + url = url.encryptValue(keychain.current, keychain), + ) + else SaveData( + password = password, + label = label, + username = username, + url = url, + ) } - // check equality ignoring label - private fun Password.equals(username: String, password: String, url: String): Boolean { - return (this.username == username) - && (this.password == password) - && (this.url == url) - } + private fun buildMainAppIntent(context: Context, searchHint: String): IntentSender { + val appIntent = Intent(context, MainActivity::class.java).apply { + putExtra(AUTOFILL_REQUEST, true) + putExtra(AUTOFILL_SEARCH_HINT, searchHint) + } - private suspend fun createPassword(label: String, username: String, password: String, url: String): Boolean { - val keychain = apiController.csEv1Keychain.asFlow().first() - val serverSettings = apiController.serverSettings.asFlow().first() - - lateinit var newPassword: NewPassword - if(keychain != null && serverSettings.encryptionCse != 0) { - newPassword = NewPassword( - password = password.encryptValue(keychain.current, keychain), - label = label.encryptValue(keychain.current, keychain), - username = username.encryptValue(keychain.current, keychain), - url = url.encryptValue(keychain.current, keychain), - notes = "".encryptValue(keychain.current, keychain), - customFields = "[]".encryptValue(keychain.current, keychain), - hash = password.sha1Hash(), - cseType = "CSEv1r1", - cseKey = keychain.current, - folder = FoldersApi.DEFAULT_FOLDER_UUID, - edited = (System.currentTimeMillis() / 1000).toInt(), - hidden = false, - favorite = false - ) + val intentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE } else { - newPassword = NewPassword( - password = password, - label = label, - username = username, - url = url, - notes = "", - customFields = "[]", - hash = password.sha1Hash(), - cseType = "none", - cseKey = "", - folder = FoldersApi.DEFAULT_FOLDER_UUID, - edited = (System.currentTimeMillis() / 1000).toInt(), - hidden = false, - favorite = false - ) + PendingIntent.FLAG_CANCEL_CURRENT } - return apiController.createPassword(newPassword) - } - suspend fun updatePassword(oldPassword: Password, label: String, username: String, password: String, url: String): Boolean { - val keychain = apiController.csEv1Keychain.asFlow().first() - val serverSettings = apiController.serverSettings.asFlow().first() + return PendingIntent.getActivity( + context, 1001, appIntent, intentFlags + ).intentSender + } - val _label = oldPassword.label // do not change labels (we are just using searchHint for the label) - val _username = if (username.isBlank()) oldPassword.username else username - val _password = if (password.isBlank()) oldPassword.password else password - val _url = if (url.isBlank()) oldPassword.url else url - - lateinit var updatedPassword: UpdatedPassword - if(keychain != null && serverSettings.encryptionCse != 0) { - updatedPassword = UpdatedPassword( - id = oldPassword.id, - revision = oldPassword.revision, - password = _password.encryptValue(keychain.current, keychain) , - label = _label.encryptValue(keychain.current, keychain) , - username = _username.encryptValue(keychain.current, keychain) , - url = _url.encryptValue(keychain.current, keychain) , - notes = oldPassword.notes.encryptValue(keychain.current, keychain), - customFields = oldPassword.customFields.encryptValue(keychain.current, keychain), - hash = password.sha1Hash(), - cseType = "CSEv1r1", - cseKey = keychain.current, - folder = oldPassword.folder, - edited = (System.currentTimeMillis() / 1000).toInt(), - hidden = oldPassword.hidden, - favorite = oldPassword.favorite - ) - } else { - updatedPassword = UpdatedPassword( - id = oldPassword.id, - revision = oldPassword.revision, - password = _password, - label = _label, - username = _username, - url = _url, - notes = oldPassword.notes, - customFields = oldPassword.customFields, - hash = password.sha1Hash(), - cseType = "none", - cseKey = "", - folder = oldPassword.folder, - edited = (System.currentTimeMillis() / 1000).toInt(), - hidden = oldPassword.hidden, - favorite = oldPassword.favorite - ) + private fun buildSaveIntent(context: Context, saveData: SaveData, isAutofill: Boolean = false): IntentSender { + val appIntent = Intent(context, MainActivity::class.java).apply { + if (isAutofill) putExtra(AUTOFILL_REQUEST, true) + putExtra(SAVE_DATA, saveData) } - return apiController.updatePassword(updatedPassword) + + val intentFlags = PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + + return PendingIntent.getActivity( + context, 1001, appIntent, intentFlags + ).intentSender } companion object { @@ -449,6 +381,7 @@ class NCPAutofillService : AutofillService() { private const val TIMEOUT_MS = 2000L const val AUTOFILL_REQUEST = "autofill_request" const val AUTOFILL_SEARCH_HINT = "autofill_query" - const val SELECTED_DATASET = "selected_dataset" + const val PASSWORD_ID = "password_id" + const val SAVE_DATA = "save_data" } } \ No newline at end of file diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt deleted file mode 100644 index 18671c2e..00000000 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/activities/LockActivity.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.hegocre.nextcloudpasswords.ui.activities - -import com.hegocre.nextcloudpasswords.ui.components.NextcloudPasswordsAppLock -import com.hegocre.nextcloudpasswords.utils.AppLockHelper -import com.hegocre.nextcloudpasswords.services.autofill.NCPAutofillService -import android.content.Intent -import android.os.Bundle -import androidx.fragment.app.FragmentActivity -import androidx.activity.compose.setContent -import androidx.compose.ui.platform.LocalContext -import androidx.compose.runtime.remember -import android.app.Activity -import android.util.Log -import android.view.autofill.AutofillManager -import android.service.autofill.Dataset -import android.os.Build -import android.annotation.TargetApi - -@TargetApi(Build.VERSION_CODES.O) -class LockActivity : FragmentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - val context = LocalContext.current - val appLockHelper = remember { AppLockHelper.getInstance(context) } - - NextcloudPasswordsAppLock( - onCheckPasscode = appLockHelper::checkPasscode, - onCorrectPasscode = { - appLockHelper.disableLock() - - val dataset: Dataset? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - intent.getParcelableExtra( - NCPAutofillService.SELECTED_DATASET, - Dataset::class.java - ) - else - @Suppress("DEPRECATION") intent.getParcelableExtra(NCPAutofillService.SELECTED_DATASET) - - val resultIntent = Intent().apply { - putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) - } - - setResult(Activity.RESULT_OK, resultIntent) - finish() - } - ) - } - } -} \ No newline at end of file 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 d823d79c..977197c6 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 @@ -19,10 +19,13 @@ import com.hegocre.nextcloudpasswords.R import com.hegocre.nextcloudpasswords.api.ApiController import com.hegocre.nextcloudpasswords.data.user.UserController import com.hegocre.nextcloudpasswords.services.autofill.AutofillHelper +import com.hegocre.nextcloudpasswords.services.autofill.SaveData +import com.hegocre.nextcloudpasswords.services.autofill.PasswordAutofillData import com.hegocre.nextcloudpasswords.services.autofill.NCPAutofillService import com.hegocre.nextcloudpasswords.services.autofill.AssistStructureParser import com.hegocre.nextcloudpasswords.ui.components.NCPAppLockWrapper import com.hegocre.nextcloudpasswords.ui.components.NextcloudPasswordsApp +import com.hegocre.nextcloudpasswords.ui.components.NextcloudPasswordsAppForAutofill import com.hegocre.nextcloudpasswords.ui.viewmodels.PasswordsViewModel import com.hegocre.nextcloudpasswords.utils.LogHelper import com.hegocre.nextcloudpasswords.utils.OkHttpRequest @@ -30,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import android.util.Log class MainActivity : FragmentActivity() { @@ -44,12 +48,30 @@ class MainActivity : FragmentActivity() { val passwordsViewModel by viewModels() - val autofillRequested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val autofillRequested: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { intent.getBooleanExtra(NCPAutofillService.AUTOFILL_REQUEST, false) } else { false } + val passwordId: String? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.getStringExtra(NCPAutofillService.PASSWORD_ID) ?: null + } else { + null + } + + val saveData: SaveData? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + intent.getParcelableExtra( + NCPAutofillService.SAVE_DATA, + SaveData::class.java + ) + else + @Suppress("DEPRECATION") intent.getParcelableExtra(NCPAutofillService.SAVE_DATA) + } else { + null + } + val autofillSearchQuery = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && autofillRequested) { intent.getStringExtra(NCPAutofillService.AUTOFILL_SEARCH_HINT) ?: "" @@ -70,11 +92,18 @@ class MainActivity : FragmentActivity() { else @Suppress("DEPRECATION") intent.getParcelableExtra(AutofillManager.EXTRA_ASSIST_STRUCTURE) + Log.d("MainActivity", "Replying to autofill request with label: $label, structure: $structure") + if (structure == null) { setResult(RESULT_CANCELED) finish() } else { - autofillReply(Triple(label, username, password), structure) + autofillReply(PasswordAutofillData( + id = null, + label = label, + username = username, + password = password + ), structure) } } } else null @@ -99,15 +128,31 @@ class MainActivity : FragmentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) - setContent { - NCPAppLockWrapper { - NextcloudPasswordsApp( - passwordsViewModel = passwordsViewModel, - onLogOut = { logOut() }, - replyAutofill = replyAutofill, - isAutofillRequest = autofillRequested, - defaultSearchQuery = autofillSearchQuery - ) + if (passwordId != null || saveData != null) { + setContent { + NCPAppLockWrapper { + NextcloudPasswordsAppForAutofill( + passwordsViewModel = passwordsViewModel, + onLogOut = { logOut() }, + replyAutofill = replyAutofill, + passwordId = passwordId, + isAutofillRequest = autofillRequested, + saveData = saveData, + defaultSearchQuery = autofillSearchQuery + ) + } + } + } else { + setContent { + NCPAppLockWrapper { + NextcloudPasswordsApp( + passwordsViewModel = passwordsViewModel, + onLogOut = { logOut() }, + replyAutofill = replyAutofill, + isAutofillRequest = autofillRequested, + defaultSearchQuery = autofillSearchQuery + ) + } } } } @@ -139,7 +184,7 @@ class MainActivity : FragmentActivity() { @RequiresApi(Build.VERSION_CODES.O) private fun autofillReply( - password: Triple, + password: PasswordAutofillData, structure: AssistStructure ) { val dataset = AutofillHelper.buildDataset(this, password, AssistStructureParser(structure), null, null, false) diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPAppForAutofill.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPAppForAutofill.kt new file mode 100644 index 00000000..e5191e28 --- /dev/null +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPAppForAutofill.kt @@ -0,0 +1,215 @@ +package com.hegocre.nextcloudpasswords.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.hegocre.nextcloudpasswords.R +import com.hegocre.nextcloudpasswords.api.FoldersApi +import com.hegocre.nextcloudpasswords.ui.NCPScreen +import com.hegocre.nextcloudpasswords.ui.theme.NextcloudPasswordsTheme +import com.hegocre.nextcloudpasswords.ui.viewmodels.PasswordsViewModel +import kotlinx.coroutines.launch +import com.hegocre.nextcloudpasswords.services.autofill.SaveData + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NextcloudPasswordsAppForAutofill( + passwordsViewModel: PasswordsViewModel, + onLogOut: () -> Unit, + isAutofillRequest: Boolean = false, + defaultSearchQuery: String = "", + passwordId: String? = null, + saveData: SaveData? = null, + replyAutofill: ((String, String, String) -> Unit)? = null +) { + val coroutineScope = rememberCoroutineScope() + + val navController = rememberNavController() + val backstackEntry = navController.currentBackStackEntryAsState() + val currentScreen = NCPScreen.fromRoute( + backstackEntry.value?.destination?.route + ) + + var openBottomSheet by rememberSaveable { mutableStateOf(false) } + val modalSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val needsMasterPassword by passwordsViewModel.needsMasterPassword.collectAsState() + val masterPasswordInvalid by passwordsViewModel.masterPasswordInvalid.collectAsState() + + val sessionOpen by passwordsViewModel.sessionOpen.collectAsState() + val showSessionOpenError by passwordsViewModel.showSessionOpenError.collectAsState() + val isRefreshing by passwordsViewModel.isRefreshing.collectAsState() + + var showLogOutDialog by rememberSaveable { mutableStateOf(false) } + var showAddElementDialog by rememberSaveable { mutableStateOf(false) } + + val keyboardController = LocalSoftwareKeyboardController.current + + var searchExpanded by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (isAutofillRequest) searchExpanded = true + } + val (searchQuery, setSearchQuery) = rememberSaveable { mutableStateOf(defaultSearchQuery) } + + val server = remember { + passwordsViewModel.server + } + + NextcloudPasswordsTheme { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState() + ) + + Scaffold( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), + floatingActionButton = { + AnimatedVisibility( + visible = currentScreen != NCPScreen.PasswordEdit && + currentScreen != NCPScreen.FolderEdit && sessionOpen, + enter = scaleIn(), + exit = scaleOut(), + ) { + FloatingActionButton( + onClick = { showAddElementDialog = true }, + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(id = R.string.action_create_element) + ) + } + } + } + ) { innerPadding -> + NCPNavHostForAutofill( + modifier = Modifier.padding(innerPadding), + navController = navController, + passwordsViewModel = passwordsViewModel, + searchQuery = searchQuery, + passwordId = passwordId, + isAutofillRequest = isAutofillRequest, + saveData = saveData, + modalSheetState = modalSheetState, + replyAutofill = replyAutofill, + ) + + if (showLogOutDialog) { + LogOutDialog( + onDismissRequest = { showLogOutDialog = false }, + onConfirmButton = onLogOut + ) + } + + if (showAddElementDialog) { + AddElementDialog( + onPasswordAdd = { + navController.navigate("${NCPScreen.PasswordEdit.name}/none") + showAddElementDialog = false + }, + onFolderAdd = { + navController.navigate("${NCPScreen.FolderEdit.name}/none") + showAddElementDialog = false + }, + onDismissRequest = { + showAddElementDialog = false + } + ) + } + + if (needsMasterPassword) { + val (masterPassword, setMasterPassword) = rememberSaveable { + mutableStateOf("") + } + val (savePassword, setSavePassword) = rememberSaveable { + mutableStateOf(false) + } + MasterPasswordDialog( + masterPassword = masterPassword, + setMasterPassword = setMasterPassword, + savePassword = savePassword, + setSavePassword = setSavePassword, + onOkClick = { + passwordsViewModel.setMasterPassword(masterPassword, savePassword) + setMasterPassword("") + }, + errorText = if (masterPasswordInvalid) stringResource(R.string.error_invalid_password) else "", + onDismissRequest = { } + ) + } + + if (openBottomSheet) { + ModalBottomSheet( + onDismissRequest = { openBottomSheet = false }, + contentWindowInsets = { WindowInsets.navigationBars }, + sheetState = modalSheetState + ) { + PasswordItem( + passwordInfo = passwordsViewModel.visiblePassword.value, + onEditPassword = if (sessionOpen) { + { + coroutineScope.launch { + modalSheetState.hide() + }.invokeOnCompletion { + if (!modalSheetState.isVisible) { + openBottomSheet = false + } + } + navController.navigate("${NCPScreen.PasswordEdit.name}/${passwordsViewModel.visiblePassword.value?.first?.id ?: "none"}") + } + } else null, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHostForAutofill.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHostForAutofill.kt new file mode 100644 index 00000000..869f4ab3 --- /dev/null +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHostForAutofill.kt @@ -0,0 +1,163 @@ +package com.hegocre.nextcloudpasswords.ui.components + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.hegocre.nextcloudpasswords.R +import com.hegocre.nextcloudpasswords.api.FoldersApi +import com.hegocre.nextcloudpasswords.data.folder.DeletedFolder +import com.hegocre.nextcloudpasswords.data.folder.Folder +import com.hegocre.nextcloudpasswords.data.folder.NewFolder +import com.hegocre.nextcloudpasswords.data.folder.UpdatedFolder +import com.hegocre.nextcloudpasswords.data.password.DeletedPassword +import com.hegocre.nextcloudpasswords.data.password.NewPassword +import com.hegocre.nextcloudpasswords.data.password.Password +import com.hegocre.nextcloudpasswords.data.password.UpdatedPassword +import com.hegocre.nextcloudpasswords.data.serversettings.ServerSettings +import com.hegocre.nextcloudpasswords.ui.NCPScreen +import com.hegocre.nextcloudpasswords.ui.viewmodels.PasswordsViewModel +import com.hegocre.nextcloudpasswords.utils.PreferencesManager +import com.hegocre.nextcloudpasswords.utils.decryptFolders +import com.hegocre.nextcloudpasswords.utils.decryptPasswords +import com.hegocre.nextcloudpasswords.utils.encryptValue +import com.hegocre.nextcloudpasswords.utils.sha1Hash +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import com.hegocre.nextcloudpasswords.services.autofill.SaveData + +@ExperimentalMaterial3Api +@Composable +fun NCPNavHostForAutofill( + navController: NavHostController, + passwordsViewModel: PasswordsViewModel, + modifier: Modifier = Modifier, + passwordId: String? = null, + searchQuery: String = "", + isAutofillRequest: Boolean, + saveData: SaveData? = null, + replyAutofill: ((String, String, String) -> Unit)? = null, + modalSheetState: SheetState? = null, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val passwords by passwordsViewModel.passwords.observeAsState() + val folders by passwordsViewModel.folders.observeAsState() + val keychain by passwordsViewModel.csEv1Keychain.observeAsState() + val isRefreshing by passwordsViewModel.isRefreshing.collectAsState() + val isUpdating by passwordsViewModel.isUpdating.collectAsState() + val serverSettings by passwordsViewModel.serverSettings.observeAsState(initial = ServerSettings()) + val sessionOpen by passwordsViewModel.sessionOpen.collectAsState() + + val passwordsDecryptionState by produceState( + initialValue = ListDecryptionState(isLoading = true), + key1 = passwords, key2 = keychain + ) { + value = ListDecryptionState(decryptedList = passwords?.decryptPasswords(keychain) ?: emptyList()) + } + + val baseFolderName = stringResource(R.string.top_level_folder_name) + val reply: (Password) -> Unit = { password -> + if (isAutofillRequest && replyAutofill != null && passwordId != null) { + replyAutofill(password.label, password.username, password.password) + } + } + + val userStartDestination by PreferencesManager.getInstance(context).getStartScreen() + .collectAsState(NCPScreen.Passwords.name, context = Dispatchers.IO) + + val startDestination = remember(isAutofillRequest, userStartDestination) { + if (isAutofillRequest) NCPScreen.Passwords.name else userStartDestination + } + + val filteredPasswordList = remember(passwordsDecryptionState.decryptedList) { + passwordsDecryptionState.decryptedList?.filter { + !it.hidden && !it.trashed && it.id == passwordId + } + } + + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + enterTransition = { fadeIn(animationSpec = tween(300)) }, + exitTransition = { fadeOut(animationSpec = tween(300)) }, + ) { + composable(NCPScreen.Passwords.name) { + NCPNavHostComposable( + modalSheetState = modalSheetState, + ) { + when { + passwordsDecryptionState.isLoading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + passwordsDecryptionState.decryptedList != null -> { + PullToRefreshBody( + isRefreshing = isRefreshing, + onRefresh = { passwordsViewModel.sync() }, + ) { + if (filteredPasswordList?.isEmpty() == true) { + if (searchQuery.isBlank()) NoContentText() else NoResultsText() + } else { + reply(filteredPasswordList!![0]) + } + } + } + } + } + } + } +} + +@ExperimentalMaterial3Api +@Composable +fun NCPNavHostComposable( + modifier: Modifier = Modifier, + modalSheetState: SheetState? = null, + content: @Composable () -> Unit = { } +) { + val scope = rememberCoroutineScope() + BackHandler(enabled = modalSheetState?.isVisible ?: false) { + scope.launch { + modalSheetState?.hide() + } + } + Box(modifier = modifier) { + content() + } +} \ No newline at end of file From e71edc3aa63fdf4ffb77202ed663ab205ad3430f Mon Sep 17 00:00:00 2001 From: difanta Date: Tue, 17 Feb 2026 15:39:14 +0100 Subject: [PATCH 04/24] one interface to handle different autofill situations; only support saving from version P; --- app/build.gradle | 1 + .../services/autofill/AutofillHelper.kt | 62 +- .../services/autofill/NCPAutofillService.kt | 137 +- .../ui/activities/MainActivity.kt | 93 +- .../ui/components/NCPApp.kt | 184 +-- .../ui/components/NCPAppForAutofill.kt | 215 --- .../ui/components/NCPNavHost.kt | 1224 +++++++++-------- .../ui/components/NCPNavHostForAutofill.kt | 163 --- .../ui/components/NCPTopBar.kt | 5 +- .../ui/components/PasswordEditView.kt | 7 +- .../nextcloudpasswords/utils/AutofillUtils.kt | 60 + 11 files changed, 906 insertions(+), 1245 deletions(-) delete mode 100644 app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPAppForAutofill.kt delete mode 100644 app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHostForAutofill.kt create mode 100644 app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt diff --git a/app/build.gradle b/app/build.gradle index e5423d3e..ea16965d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" 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 18cb6fac..8cf5bf23 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 @@ -22,46 +22,11 @@ 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.Parcel -import android.os.Parcelable import android.os.Bundle import android.util.Log import android.view.autofill.AutofillManager - -data class SaveData( - val label: String, - val username: String, - val password: String, - val url: String -) : Parcelable { - constructor(parcel: Parcel) : this( - parcel.readString() ?: "", - parcel.readString() ?: "", - parcel.readString() ?: "", - parcel.readString() ?: "" - ) - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeString(label) - parcel.writeString(username) - parcel.writeString(password) - parcel.writeString(url) - } - - override fun describeContents(): Int = 0 - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): SaveData { - return SaveData(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} - -data class PasswordAutofillData(val id: String?, val label: String, val username: String?, val password: String?) +import com.hegocre.nextcloudpasswords.utils.AutofillData +import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData @RequiresApi(Build.VERSION_CODES.O) object AutofillHelper { @@ -91,7 +56,7 @@ object AutofillHelper { } } - @RequiresApi(Build.VERSION_CODES.O_MR1) + @RequiresApi(Build.VERSION_CODES.P) fun buildSaveInfo(helper: AssistStructureParser): Pair? { val requiredIds = mutableListOf() val optionalIds = mutableListOf() @@ -153,7 +118,7 @@ object AutofillHelper { needsAppLock: Boolean = false ): Dataset { // build redacted dataset when app lock is needed - return if (needsAppLock && password != null && password.id != null) { + return if (needsAppLock && password?.id != null) { Dataset.Builder().apply { helper.usernameAutofillIds.forEach { autofillId -> addInlineAutofillValue( @@ -173,7 +138,7 @@ object AutofillHelper { inlinePresentationSpec ) } - setAuthentication(buildAppLockIntent(context, password.id, helper)) + setAuthentication(buildIntent(context, 1002, AutofillData.FromId(id=password.id, structure=helper.structure))) }.build() } else { Dataset.Builder().apply { @@ -208,7 +173,7 @@ object AutofillHelper { needsAppLock: Boolean = false ): Dataset { // build redacted dataset when app lock is needed - return if (needsAppLock && password != null && password.id != null) { + return if (needsAppLock && password?.id != null) { Dataset.Builder().apply { helper.usernameAutofillIds.forEach { autofillId -> addAutofillValue(context, autofillId, password.label, null) @@ -216,7 +181,7 @@ object AutofillHelper { helper.passwordAutofillIds.forEach { autofillId -> addAutofillValue(context, autofillId, password.label, null) } - setAuthentication(buildAppLockIntent(context, password.id, helper)) + setAuthentication(buildIntent(context, 1002, AutofillData.FromId(id=password.id, structure=helper.structure))) }.build() } else { Dataset.Builder().apply { @@ -281,9 +246,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) { @@ -340,17 +304,15 @@ object AutofillHelper { } } - fun buildAppLockIntent(context: Context, passwordId: String, helper: AssistStructureParser): IntentSender { - val authIntent = Intent(context, MainActivity::class.java).apply { - putExtra(NCPAutofillService.AUTOFILL_REQUEST, true) - putExtra(NCPAutofillService.PASSWORD_ID, passwordId) - putExtra(AutofillManager.EXTRA_ASSIST_STRUCTURE, helper.structure) + 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, 1001, authIntent, intentFlags // TODO: unique code? + context, code, appIntent, intentFlags ).intentSender } 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 10f27148..0bcb5699 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 @@ -41,6 +41,14 @@ import android.content.IntentSender import com.hegocre.nextcloudpasswords.utils.encryptValue import com.hegocre.nextcloudpasswords.utils.sha1Hash import com.hegocre.nextcloudpasswords.api.FoldersApi +import com.hegocre.nextcloudpasswords.utils.AutofillData +import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData +import com.hegocre.nextcloudpasswords.utils.SaveData + +data class ListDecryptionStateNonNullable( + val decryptedList: List = emptyList(), + val isLoading: Boolean = false +) @TargetApi(Build.VERSION_CODES.O) class NCPAutofillService : AutofillService() { @@ -60,7 +68,9 @@ class NCPAutofillService : AutofillService() { val searchByUsername by lazy { preferencesManager.getSearchByUsername() } val strictUrlMatching by lazy { preferencesManager.getUseStrictUrlMatching() } - private lateinit var decryptedPasswordsState: StateFlow> + private lateinit var decryptedPasswordsState: StateFlow> + + private val passwordsDecrypted = MutableStateFlow(false) override fun onCreate() { super.onCreate() @@ -68,13 +78,14 @@ class NCPAutofillService : AutofillService() { passwordController.getPasswords().asFlow(), apiController.csEv1Keychain.asFlow() ) { passwords, keychain -> - passwords.filter { !it.trashed && !it.hidden }.decryptPasswords(keychain) + ListDecryptionStateNonNullable(isLoading = true) + ListDecryptionStateNonNullable(passwords.filter { !it.trashed && !it.hidden }.decryptPasswords(keychain), false) } .flowOn(Dispatchers.Default) .stateIn( scope = serviceScope, started = SharingStarted.Eagerly, - initialValue = emptyList() + initialValue = ListDecryptionStateNonNullable(isLoading = true) ) } @@ -122,24 +133,23 @@ class NCPAutofillService : AutofillService() { return null } + // TODO: when to sync with server? // Check Login Status - try { - userController.getServer() - } catch (_: UserException) { - Log.e(TAG, "User not logged in, cannot autofill") - return null - } + //try { + // userController.getServer() + //} catch (_: UserException) { + // Log.e(TAG, "User not logged in, cannot autofill") + // return null + //} - Log.d(TAG, "User is logged in") + //Log.d(TAG, "User is logged in") // Try to open Session - if (!apiController.sessionOpen.value && !apiController.openSession(preferencesManager.getMasterPassword())) { - Log.w(TAG, "Session is not open and cannot be opened") - // TODO: stop if we need the decrypted keychain - } - Log.d(TAG, "Session is open") + //if (!apiController.sessionOpen.value && !apiController.openSession(preferencesManager.getMasterPassword())) { + // Log.w(TAG, "Session is not open and cannot be opened") + //} + //Log.d(TAG, "Session is open") - // TODO: when to update? //if (apiController.sessionOpen.value) { // passwordController.syncPasswords() //} @@ -150,7 +160,9 @@ class NCPAutofillService : AutofillService() { Log.d(TAG, "Search hint determined: $searchHint") // wait for passwords to be decrypted, then filter by search hint and sort them - val filteredList = decryptedPasswordsState.value.filter { + decryptedPasswordsState.first { !it.isLoading } + + val filteredList = decryptedPasswordsState.value.decryptedList.filter { it.matches(searchHint, strictUrlMatching.first()) || (searchByUsername.first() && it.username.contains(searchHint, ignoreCase = true)) }.let { list -> @@ -222,10 +234,10 @@ class NCPAutofillService : AutofillService() { builder.addDataset( AutofillHelper.buildDataset( applicationContext, - PasswordAutofillData(label = "Create new password", id = null, username = null, password = null), + PasswordAutofillData(label = "Create new password", id = null, username = null, password = null), // TODO: translation helper, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, - buildSaveIntent(applicationContext, saveData, true), + AutofillHelper.buildIntent(applicationContext, 1003, AutofillData.SaveAutofill(searchHint, saveData, helper.structure)), false ) ) @@ -237,10 +249,10 @@ class NCPAutofillService : AutofillService() { builder.addDataset( AutofillHelper.buildDataset( applicationContext, - null, + PasswordAutofillData(label = ">", id = null, username = null, password = null), // TODO use icon helper, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, - buildMainAppIntent(applicationContext, searchHint), + AutofillHelper.buildIntent(applicationContext, 1004, AutofillData.ChoosePwd(searchHint, helper.structure)), false ) ) @@ -248,11 +260,11 @@ class NCPAutofillService : AutofillService() { Log.d(TAG, "Button to open app added to FillResponse") // set Save Info, with an optional bundle if delaying the save - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { AutofillHelper.buildSaveInfo(helper)?.let { pair -> builder.setSaveInfo(pair.first) pair.second?.let { bundle -> - builder.setClientState(bundle) + builder.setClientState(bundle) } } } @@ -279,18 +291,22 @@ class NCPAutofillService : AutofillService() { } override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { - val job = serviceScope.launch { - try { - val intent: IntentSender? = withContext(Dispatchers.Default) { - processSaveRequest(request) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val job = 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: Exception) { + callback.onFailure("Error handling save request: ${e.message}") } - if (intent != null) callback.onSuccess(intent) - else callback.onFailure("Unable to complete Save Request") - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - callback.onFailure("Error handling save request: ${e.message}") } + } else { + callback.onFailure("Saving not supported on android < 9.0") } } @@ -325,63 +341,12 @@ class NCPAutofillService : AutofillService() { // Determine Search Hint val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) - return buildSaveIntent(applicationContext, prepareSaveData(searchHint, username, password, searchHint)) - } - - private suspend fun prepareSaveData(label: String, username: String, password: String, url: String): SaveData { - val keychain = apiController.csEv1Keychain.asFlow().first() - val serverSettings = apiController.serverSettings.asFlow().first() - - return if(keychain != null && serverSettings.encryptionCse != 0) SaveData( - password = password.encryptValue(keychain.current, keychain), - label = label.encryptValue(keychain.current, keychain), - username = username.encryptValue(keychain.current, keychain), - url = url.encryptValue(keychain.current, keychain), - ) - else SaveData( - password = password, - label = label, - username = username, - url = url, - ) - } - - private fun buildMainAppIntent(context: Context, searchHint: String): IntentSender { - val appIntent = Intent(context, MainActivity::class.java).apply { - putExtra(AUTOFILL_REQUEST, true) - putExtra(AUTOFILL_SEARCH_HINT, searchHint) - } - - val intentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - } - - return PendingIntent.getActivity( - context, 1001, appIntent, intentFlags - ).intentSender - } - - private fun buildSaveIntent(context: Context, saveData: SaveData, isAutofill: Boolean = false): IntentSender { - val appIntent = Intent(context, MainActivity::class.java).apply { - if (isAutofill) putExtra(AUTOFILL_REQUEST, true) - putExtra(SAVE_DATA, saveData) - } - - val intentFlags = PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE - - return PendingIntent.getActivity( - context, 1001, appIntent, intentFlags - ).intentSender + return AutofillHelper.buildIntent(applicationContext, 1005, AutofillData.Save(searchHint, SaveData(searchHint, username, password, searchHint))) } companion object { const val TAG = "NCPAutofillService" private const val TIMEOUT_MS = 2000L - const val AUTOFILL_REQUEST = "autofill_request" - const val AUTOFILL_SEARCH_HINT = "autofill_query" - const val PASSWORD_ID = "password_id" - const val SAVE_DATA = "save_data" + const val AUTOFILL_DATA = "autofill_data" } } \ No newline at end of file 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 977197c6..602d75d6 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 @@ -19,13 +19,10 @@ import com.hegocre.nextcloudpasswords.R import com.hegocre.nextcloudpasswords.api.ApiController import com.hegocre.nextcloudpasswords.data.user.UserController import com.hegocre.nextcloudpasswords.services.autofill.AutofillHelper -import com.hegocre.nextcloudpasswords.services.autofill.SaveData -import com.hegocre.nextcloudpasswords.services.autofill.PasswordAutofillData import com.hegocre.nextcloudpasswords.services.autofill.NCPAutofillService import com.hegocre.nextcloudpasswords.services.autofill.AssistStructureParser import com.hegocre.nextcloudpasswords.ui.components.NCPAppLockWrapper import com.hegocre.nextcloudpasswords.ui.components.NextcloudPasswordsApp -import com.hegocre.nextcloudpasswords.ui.components.NextcloudPasswordsAppForAutofill import com.hegocre.nextcloudpasswords.ui.viewmodels.PasswordsViewModel import com.hegocre.nextcloudpasswords.utils.LogHelper import com.hegocre.nextcloudpasswords.utils.OkHttpRequest @@ -34,9 +31,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import android.util.Log +import com.hegocre.nextcloudpasswords.utils.AutofillData +import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData class MainActivity : FragmentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { if (BuildConfig.DEBUG) LogHelper.getInstance() @@ -48,56 +46,25 @@ class MainActivity : FragmentActivity() { val passwordsViewModel by viewModels() - val autofillRequested: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - intent.getBooleanExtra(NCPAutofillService.AUTOFILL_REQUEST, false) - } else { - false - } - - val passwordId: String? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - intent.getStringExtra(NCPAutofillService.PASSWORD_ID) ?: null - } else { - null - } - - val saveData: SaveData? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val autofillData: AutofillData? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) intent.getParcelableExtra( - NCPAutofillService.SAVE_DATA, - SaveData::class.java + NCPAutofillService.AUTOFILL_DATA, + AutofillData::class.java ) else - @Suppress("DEPRECATION") intent.getParcelableExtra(NCPAutofillService.SAVE_DATA) + @Suppress("DEPRECATION") intent.getParcelableExtra(NCPAutofillService.AUTOFILL_DATA) } else { null } - val autofillSearchQuery = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && autofillRequested) { - intent.getStringExtra(NCPAutofillService.AUTOFILL_SEARCH_HINT) ?: "" - } else { - "" - } - val replyAutofill: ((String, String, String) -> Unit)? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - && autofillRequested ) { - { label, username, password -> - val structure = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - intent.getParcelableExtra( - AutofillManager.EXTRA_ASSIST_STRUCTURE, - AssistStructure::class.java - ) - else - @Suppress("DEPRECATION") intent.getParcelableExtra(AutofillManager.EXTRA_ASSIST_STRUCTURE) - - Log.d("MainActivity", "Replying to autofill request with label: $label, structure: $structure") - - if (structure == null) { - setResult(RESULT_CANCELED) - finish() - } else { + val creator = { structure: AssistStructure -> + { label: String, username: String, password: String -> + Log.d("MainActivity", "Replying to autofill request with label: $label, structure: ${structure}") + autofillReply(PasswordAutofillData( id = null, label = label, @@ -106,6 +73,13 @@ class MainActivity : FragmentActivity() { ), structure) } } + + when (autofillData) { + is AutofillData.FromId -> creator(autofillData.structure) + is AutofillData.ChoosePwd -> creator(autofillData.structure) + is AutofillData.SaveAutofill -> creator(autofillData.structure) + else -> null + } } else null @@ -128,31 +102,14 @@ class MainActivity : FragmentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) - if (passwordId != null || saveData != null) { - setContent { - NCPAppLockWrapper { - NextcloudPasswordsAppForAutofill( - passwordsViewModel = passwordsViewModel, - onLogOut = { logOut() }, - replyAutofill = replyAutofill, - passwordId = passwordId, - isAutofillRequest = autofillRequested, - saveData = saveData, - defaultSearchQuery = autofillSearchQuery - ) - } - } - } else { - setContent { - NCPAppLockWrapper { - NextcloudPasswordsApp( - passwordsViewModel = passwordsViewModel, - onLogOut = { logOut() }, - replyAutofill = replyAutofill, - isAutofillRequest = autofillRequested, - defaultSearchQuery = autofillSearchQuery - ) - } + setContent { + NCPAppLockWrapper { + NextcloudPasswordsApp( + passwordsViewModel = passwordsViewModel, + onLogOut = { logOut() }, + replyAutofill = replyAutofill, + autofillData = autofillData, + ) } } } diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPApp.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPApp.kt index 7f9994d6..2d7cb769 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPApp.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPApp.kt @@ -57,14 +57,14 @@ import com.hegocre.nextcloudpasswords.ui.NCPScreen import com.hegocre.nextcloudpasswords.ui.theme.NextcloudPasswordsTheme import com.hegocre.nextcloudpasswords.ui.viewmodels.PasswordsViewModel import kotlinx.coroutines.launch +import com.hegocre.nextcloudpasswords.utils.AutofillData @OptIn(ExperimentalMaterial3Api::class) @Composable fun NextcloudPasswordsApp( passwordsViewModel: PasswordsViewModel, onLogOut: () -> Unit, - isAutofillRequest: Boolean = false, - defaultSearchQuery: String = "", + autofillData: AutofillData?, replyAutofill: ((String, String, String) -> Unit)? = null ) { val coroutineScope = rememberCoroutineScope() @@ -92,9 +92,16 @@ fun NextcloudPasswordsApp( var searchExpanded by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { - if (isAutofillRequest) searchExpanded = true + if (autofillData != null && autofillData is AutofillData.ChoosePwd) searchExpanded = true } - val (searchQuery, setSearchQuery) = rememberSaveable { mutableStateOf(defaultSearchQuery) } + val (searchQuery, setSearchQuery) = rememberSaveable { mutableStateOf( + when (autofillData) { + is AutofillData.ChoosePwd -> autofillData.searchHint + is AutofillData.SaveAutofill -> autofillData.searchHint + is AutofillData.Save -> autofillData.searchHint + else -> "" + } + )} val server = remember { passwordsViewModel.server @@ -110,97 +117,100 @@ fun NextcloudPasswordsApp( .nestedScroll(scrollBehavior.nestedScrollConnection) .imePadding(), topBar = { - if (currentScreen != NCPScreen.PasswordEdit && currentScreen != NCPScreen.FolderEdit) { - NCPSearchTopBar( - username = server.username, - serverAddress = server.url, - title = when (currentScreen) { - NCPScreen.Passwords, NCPScreen.Favorites -> stringResource(currentScreen.title) - NCPScreen.Folders -> { - passwordsViewModel.visibleFolder.value?.let { - if (it.id == FoldersApi.DEFAULT_FOLDER_UUID) - stringResource(currentScreen.title) - else - it.label - } ?: stringResource(currentScreen.title) - } - - else -> "" - }, - userAvatar = { size -> - Image( - painter = passwordsViewModel.getPainterForAvatar(), - contentDescription = "", - modifier = Modifier - .clip(CircleShape) - .size(size) - ) - }, - searchQuery = searchQuery, - setSearchQuery = setSearchQuery, - isAutofill = isAutofillRequest, - searchExpanded = searchExpanded, - onSearchClick = { searchExpanded = true }, - onSearchCloseClick = { - searchExpanded = false - setSearchQuery("") - }, - onLogoutClick = { showLogOutDialog = true }, - scrollBehavior = scrollBehavior - ) - } else { - TopAppBar( - title = { Text(text = stringResource(id = currentScreen.title)) }, - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(id = R.string.navigation_back) + if (autofillData == null || autofillData is AutofillData.ChoosePwd) { + if (currentScreen != NCPScreen.PasswordEdit && currentScreen != NCPScreen.FolderEdit) { + NCPSearchTopBar( + username = server.username, + serverAddress = server.url, + title = when (currentScreen) { + NCPScreen.Passwords, NCPScreen.Favorites -> stringResource(currentScreen.title) + NCPScreen.Folders -> { + passwordsViewModel.visibleFolder.value?.let { + if (it.id == FoldersApi.DEFAULT_FOLDER_UUID) + stringResource(currentScreen.title) + else + it.label + } ?: stringResource(currentScreen.title) + } + else -> "" + }, + userAvatar = { size -> + Image( + painter = passwordsViewModel.getPainterForAvatar(), + contentDescription = "", + modifier = Modifier + .clip(CircleShape) + .size(size) ) + }, + searchQuery = searchQuery, + setSearchQuery = setSearchQuery, + autofillData = autofillData, + searchExpanded = searchExpanded, + onSearchClick = { searchExpanded = true }, + onSearchCloseClick = { + searchExpanded = false + setSearchQuery("") + }, + onLogoutClick = { showLogOutDialog = true }, + scrollBehavior = scrollBehavior + ) + } else { + TopAppBar( + title = { Text(text = stringResource(id = currentScreen.title)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.navigation_back) + ) + } } - } - ) + ) + } } }, bottomBar = { - Column { - AnimatedVisibility(visible = !sessionOpen && showSessionOpenError && !isRefreshing) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - modifier = Modifier.clickable { (passwordsViewModel.sync()) } + if (autofillData == null || autofillData is AutofillData.ChoosePwd) { + Column { + AnimatedVisibility(visible = !sessionOpen && showSessionOpenError && !isRefreshing) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.clickable { (passwordsViewModel.sync()) } + ) { + Text( + text = stringResource(id = R.string.error_cannot_connect_to_server), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } + } + val navigationHeight = + WindowInsets.navigationBars.getBottom(LocalDensity.current) + AnimatedVisibility( + visible = currentScreen != NCPScreen.PasswordEdit + && currentScreen != NCPScreen.FolderEdit, + enter = slideInVertically(initialOffsetY = { (it + navigationHeight) }), + exit = slideOutVertically(targetOffsetY = { (it + navigationHeight) }) ) { - Text( - text = stringResource(id = R.string.error_cannot_connect_to_server), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + NCPBottomNavigation( + allScreens = NCPScreen.entries.filter { !it.hidden }, + currentScreen = currentScreen, + onScreenSelected = { screen -> + navController.navigate(screen.name) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, ) } } - val navigationHeight = - WindowInsets.navigationBars.getBottom(LocalDensity.current) - AnimatedVisibility( - visible = currentScreen != NCPScreen.PasswordEdit - && currentScreen != NCPScreen.FolderEdit, - enter = slideInVertically(initialOffsetY = { (it + navigationHeight) }), - exit = slideOutVertically(targetOffsetY = { (it + navigationHeight) }) - ) { - NCPBottomNavigation( - allScreens = NCPScreen.entries.filter { !it.hidden }, - currentScreen = currentScreen, - onScreenSelected = { screen -> - navController.navigate(screen.name) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - }, - ) - } } }, floatingActionButton = { @@ -226,7 +236,7 @@ fun NextcloudPasswordsApp( navController = navController, passwordsViewModel = passwordsViewModel, searchQuery = searchQuery, - isAutofillRequest = isAutofillRequest, + autofillData = autofillData, modalSheetState = modalSheetState, openPasswordDetails = { password, folderPath -> passwordsViewModel.setVisiblePassword(password, folderPath) diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPAppForAutofill.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPAppForAutofill.kt deleted file mode 100644 index e5191e28..00000000 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPAppForAutofill.kt +++ /dev/null @@ -1,215 +0,0 @@ -package com.hegocre.nextcloudpasswords.ui.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import com.hegocre.nextcloudpasswords.R -import com.hegocre.nextcloudpasswords.api.FoldersApi -import com.hegocre.nextcloudpasswords.ui.NCPScreen -import com.hegocre.nextcloudpasswords.ui.theme.NextcloudPasswordsTheme -import com.hegocre.nextcloudpasswords.ui.viewmodels.PasswordsViewModel -import kotlinx.coroutines.launch -import com.hegocre.nextcloudpasswords.services.autofill.SaveData - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NextcloudPasswordsAppForAutofill( - passwordsViewModel: PasswordsViewModel, - onLogOut: () -> Unit, - isAutofillRequest: Boolean = false, - defaultSearchQuery: String = "", - passwordId: String? = null, - saveData: SaveData? = null, - replyAutofill: ((String, String, String) -> Unit)? = null -) { - val coroutineScope = rememberCoroutineScope() - - val navController = rememberNavController() - val backstackEntry = navController.currentBackStackEntryAsState() - val currentScreen = NCPScreen.fromRoute( - backstackEntry.value?.destination?.route - ) - - var openBottomSheet by rememberSaveable { mutableStateOf(false) } - val modalSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - val needsMasterPassword by passwordsViewModel.needsMasterPassword.collectAsState() - val masterPasswordInvalid by passwordsViewModel.masterPasswordInvalid.collectAsState() - - val sessionOpen by passwordsViewModel.sessionOpen.collectAsState() - val showSessionOpenError by passwordsViewModel.showSessionOpenError.collectAsState() - val isRefreshing by passwordsViewModel.isRefreshing.collectAsState() - - var showLogOutDialog by rememberSaveable { mutableStateOf(false) } - var showAddElementDialog by rememberSaveable { mutableStateOf(false) } - - val keyboardController = LocalSoftwareKeyboardController.current - - var searchExpanded by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(Unit) { - if (isAutofillRequest) searchExpanded = true - } - val (searchQuery, setSearchQuery) = rememberSaveable { mutableStateOf(defaultSearchQuery) } - - val server = remember { - passwordsViewModel.server - } - - NextcloudPasswordsTheme { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( - rememberTopAppBarState() - ) - - Scaffold( - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), - floatingActionButton = { - AnimatedVisibility( - visible = currentScreen != NCPScreen.PasswordEdit && - currentScreen != NCPScreen.FolderEdit && sessionOpen, - enter = scaleIn(), - exit = scaleOut(), - ) { - FloatingActionButton( - onClick = { showAddElementDialog = true }, - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = stringResource(id = R.string.action_create_element) - ) - } - } - } - ) { innerPadding -> - NCPNavHostForAutofill( - modifier = Modifier.padding(innerPadding), - navController = navController, - passwordsViewModel = passwordsViewModel, - searchQuery = searchQuery, - passwordId = passwordId, - isAutofillRequest = isAutofillRequest, - saveData = saveData, - modalSheetState = modalSheetState, - replyAutofill = replyAutofill, - ) - - if (showLogOutDialog) { - LogOutDialog( - onDismissRequest = { showLogOutDialog = false }, - onConfirmButton = onLogOut - ) - } - - if (showAddElementDialog) { - AddElementDialog( - onPasswordAdd = { - navController.navigate("${NCPScreen.PasswordEdit.name}/none") - showAddElementDialog = false - }, - onFolderAdd = { - navController.navigate("${NCPScreen.FolderEdit.name}/none") - showAddElementDialog = false - }, - onDismissRequest = { - showAddElementDialog = false - } - ) - } - - if (needsMasterPassword) { - val (masterPassword, setMasterPassword) = rememberSaveable { - mutableStateOf("") - } - val (savePassword, setSavePassword) = rememberSaveable { - mutableStateOf(false) - } - MasterPasswordDialog( - masterPassword = masterPassword, - setMasterPassword = setMasterPassword, - savePassword = savePassword, - setSavePassword = setSavePassword, - onOkClick = { - passwordsViewModel.setMasterPassword(masterPassword, savePassword) - setMasterPassword("") - }, - errorText = if (masterPasswordInvalid) stringResource(R.string.error_invalid_password) else "", - onDismissRequest = { } - ) - } - - if (openBottomSheet) { - ModalBottomSheet( - onDismissRequest = { openBottomSheet = false }, - contentWindowInsets = { WindowInsets.navigationBars }, - sheetState = modalSheetState - ) { - PasswordItem( - passwordInfo = passwordsViewModel.visiblePassword.value, - onEditPassword = if (sessionOpen) { - { - coroutineScope.launch { - modalSheetState.hide() - }.invokeOnCompletion { - if (!modalSheetState.isVisible) { - openBottomSheet = false - } - } - navController.navigate("${NCPScreen.PasswordEdit.name}/${passwordsViewModel.visiblePassword.value?.first?.id ?: "none"}") - } - } else null, - modifier = Modifier.padding(bottom = 16.dp) - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt index 79d691a1..74e74c73 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt @@ -25,6 +25,10 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -56,6 +60,7 @@ import com.hegocre.nextcloudpasswords.utils.sha1Hash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import com.hegocre.nextcloudpasswords.utils.AutofillData @ExperimentalMaterial3Api @Composable @@ -64,7 +69,7 @@ fun NCPNavHost( passwordsViewModel: PasswordsViewModel, modifier: Modifier = Modifier, searchQuery: String = "", - isAutofillRequest: Boolean, + autofillData: AutofillData?, openPasswordDetails: (Password, List) -> Unit, replyAutofill: ((String, String, String) -> Unit)? = null, modalSheetState: SheetState? = null, @@ -82,11 +87,13 @@ fun NCPNavHost( val serverSettings by passwordsViewModel.serverSettings.observeAsState(initial = ServerSettings()) val sessionOpen by passwordsViewModel.sessionOpen.collectAsState() - val passwordsDecryptionState by produceState( - initialValue = ListDecryptionState(isLoading = true), - key1 = passwords, key2 = keychain - ) { - value = ListDecryptionState(decryptedList = passwords?.decryptPasswords(keychain) ?: emptyList()) + var passwordsDecryptionState by remember { + mutableStateOf(ListDecryptionState(isLoading = true)) + } + + LaunchedEffect(passwords, keychain) { + passwordsDecryptionState = ListDecryptionState(isLoading = true) + passwordsDecryptionState = ListDecryptionState(decryptedList = passwords?.decryptPasswords(keychain) ?: emptyList()) } val foldersDecryptionState by produceState( @@ -98,21 +105,28 @@ fun NCPNavHost( val baseFolderName = stringResource(R.string.top_level_folder_name) val onPasswordClick: (Password) -> Unit = { password -> - if (isAutofillRequest && replyAutofill != null) { - replyAutofill(password.label, password.username, password.password) - } else { - val folderPath = mutableListOf() - var nextFolderUuid = password.folder - while (nextFolderUuid != FoldersApi.DEFAULT_FOLDER_UUID) { - val nextFolder = - foldersDecryptionState.decryptedList?.find { it.id == nextFolderUuid } - nextFolder?.label?.let { - folderPath.add(it) + when (autofillData) { + is AutofillData.ChoosePwd if replyAutofill != null -> { + replyAutofill(password.label, password.username, password.password) + } + is AutofillData.Save, is AutofillData.SaveAutofill -> { + if (sessionOpen && password.editable) + navController.navigate("${NCPScreen.PasswordEdit.name}/${password.id}") + } + else -> { + val folderPath = mutableListOf() + var nextFolderUuid = password.folder + while (nextFolderUuid != FoldersApi.DEFAULT_FOLDER_UUID) { + val nextFolder = + foldersDecryptionState.decryptedList?.find { it.id == nextFolderUuid } + nextFolder?.label?.let { + folderPath.add(it) + } + nextFolderUuid = nextFolder?.parent ?: FoldersApi.DEFAULT_FOLDER_UUID } - nextFolderUuid = nextFolder?.parent ?: FoldersApi.DEFAULT_FOLDER_UUID + folderPath.add(baseFolderName) + openPasswordDetails(password, folderPath.toList()) } - folderPath.add(baseFolderName) - openPasswordDetails(password, folderPath.toList()) } } @@ -123,8 +137,8 @@ fun NCPNavHost( val userStartDestination by PreferencesManager.getInstance(context).getStartScreen() .collectAsState(NCPScreen.Passwords.name, context = Dispatchers.IO) - val startDestination = remember(isAutofillRequest, userStartDestination) { - if (isAutofillRequest) NCPScreen.Passwords.name else userStartDestination + val startDestination = remember(autofillData, userStartDestination) { + if (autofillData != null) NCPScreen.Passwords.name else userStartDestination } val orderBy by PreferencesManager.getInstance(context).getOrderBy() @@ -168,616 +182,684 @@ fun NCPNavHost( enterTransition = { fadeIn(animationSpec = tween(300)) }, exitTransition = { fadeOut(animationSpec = tween(300)) }, ) { - composable(NCPScreen.Passwords.name) { - NCPNavHostComposable( - modalSheetState = modalSheetState, - searchVisibility = searchVisibility, - closeSearch = closeSearch - ) { - when { - passwordsDecryptionState.isLoading -> { - Box(modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - } - passwordsDecryptionState.decryptedList != null -> { - PullToRefreshBody( - isRefreshing = isRefreshing, - onRefresh = { passwordsViewModel.sync() }, - ) { - if (filteredPasswordList?.isEmpty() == true) { - if (searchQuery.isBlank()) NoContentText() else NoResultsText() - } else { - MixedLazyColumn( - passwords = filteredPasswordList, - onPasswordClick = onPasswordClick, - onPasswordLongClick = { - if (sessionOpen && !isAutofillRequest && it.editable) - navController.navigate("${NCPScreen.PasswordEdit.name}/${it.id}") - }, - getPainterForUrl = { passwordsViewModel.getPainterForUrl(url = it) } - ) + when (autofillData) { + is AutofillData.FromId -> { + composable(NCPScreen.Passwords.name) { + NCPNavHostComposable( + modalSheetState = modalSheetState, + searchVisibility = searchVisibility, + closeSearch = closeSearch + ) { + when { + passwordsDecryptionState.isLoading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + !passwordsDecryptionState.isLoading && passwordsDecryptionState.decryptedList != null -> { + PullToRefreshBody( + isRefreshing = isRefreshing, + onRefresh = { passwordsViewModel.sync() }, + ) { + if (filteredPasswordList == null || filteredPasswordList.isEmpty() == true) { + if (searchQuery.isBlank()) NoContentText() else NoResultsText() + } else if (replyAutofill != null) { + // Reply to the autofill right away without showing any UI + filteredPasswordList + .firstOrNull { it.id == autofillData.id } + ?.let { + replyAutofill(it.label, it.username, it.password) + } + ?: NoContentText() + } else { + NoContentText() // autofill not supported + } + } } } } } } - } - - composable(NCPScreen.Favorites.name) { - val filteredFavoritePasswords = remember(filteredPasswordList) { - filteredPasswordList?.filter { it.favorite } - } - NCPNavHostComposable( - modalSheetState = modalSheetState, - searchVisibility = searchVisibility, - closeSearch = closeSearch - ) { - when { - passwordsDecryptionState.isLoading -> { - Box(modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - } - passwordsDecryptionState.decryptedList != null -> { - PullToRefreshBody( - isRefreshing = isRefreshing, - onRefresh = { passwordsViewModel.sync() }, - ) { - if (filteredFavoritePasswords?.isEmpty() == true) { - if (searchQuery.isBlank()) - NoContentText() - else - NoResultsText { navController.navigate(NCPScreen.Passwords.name) } - } else { - MixedLazyColumn( - passwords = filteredFavoritePasswords, - onPasswordClick = onPasswordClick, - onPasswordLongClick = { - if (sessionOpen && !isAutofillRequest && it.editable) - navController.navigate("${NCPScreen.PasswordEdit.name}/${it.id}") - }, - getPainterForUrl = { passwordsViewModel.getPainterForUrl(url = it) } - ) + else -> { + composable(NCPScreen.Passwords.name) { + NCPNavHostComposable( + modalSheetState = modalSheetState, + searchVisibility = searchVisibility, + closeSearch = closeSearch + ) { + when { + passwordsDecryptionState.isLoading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + !passwordsDecryptionState.isLoading && passwordsDecryptionState.decryptedList != null -> { + PullToRefreshBody( + isRefreshing = isRefreshing, + onRefresh = { passwordsViewModel.sync() }, + ) { + if (filteredPasswordList == null || filteredPasswordList.isEmpty() == true) { + // TODO: open automatically a new password or the only updatable password if isSave + //if (sessionOpen && autofillData != null && autofillData.isSave()) + // navController.navigate("${NCPScreen.PasswordEdit.name}/") + if (searchQuery.isBlank()) NoContentText() + else NoResultsText() + } else { + MixedLazyColumn( + passwords = filteredPasswordList, + onPasswordClick = onPasswordClick, + onPasswordLongClick = { + if (sessionOpen && (autofillData == null || autofillData.isSave()) && it.editable) + navController.navigate("${NCPScreen.PasswordEdit.name}/${it.id}") + }, + getPainterForUrl = { passwordsViewModel.getPainterForUrl(url = it) } + ) + } + } } } } } - } - } - composable(NCPScreen.Folders.name) { - NCPNavHostComposable( - modalSheetState = modalSheetState, - searchVisibility = searchVisibility, - closeSearch = closeSearch - ) { - val filteredPasswordsParentFolder = remember(filteredPasswordList) { - filteredPasswordList?.filter { - it.folder == FoldersApi.DEFAULT_FOLDER_UUID - } - } - val filteredFoldersParentFolder = remember(filteredFolderList) { - filteredFolderList?.filter { - it.parent == FoldersApi.DEFAULT_FOLDER_UUID + composable(NCPScreen.Favorites.name) { + val filteredFavoritePasswords = remember(filteredPasswordList) { + filteredPasswordList?.filter { it.favorite } } - } - when { - foldersDecryptionState.isLoading || passwordsDecryptionState.isLoading -> { - Box(modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + NCPNavHostComposable( + modalSheetState = modalSheetState, + searchVisibility = searchVisibility, + closeSearch = closeSearch + ) { + when { + passwordsDecryptionState.isLoading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + passwordsDecryptionState.decryptedList != null -> { + PullToRefreshBody( + isRefreshing = isRefreshing, + onRefresh = { passwordsViewModel.sync() }, + ) { + if (filteredFavoritePasswords?.isEmpty() == true) { + if (searchQuery.isBlank()) + NoContentText() + else + NoResultsText { navController.navigate(NCPScreen.Passwords.name) } + } else { + MixedLazyColumn( + passwords = filteredFavoritePasswords, + onPasswordClick = onPasswordClick, + onPasswordLongClick = { + if (sessionOpen && (autofillData == null || autofillData.isSave()) && it.editable) + navController.navigate("${NCPScreen.PasswordEdit.name}/${it.id}") + }, + getPainterForUrl = { passwordsViewModel.getPainterForUrl(url = it) } + ) + } + } + } } } - foldersDecryptionState.decryptedList != null - && passwordsDecryptionState.decryptedList != null -> { + } - LaunchedEffect(Unit) { - passwordsViewModel.setVisibleFolder(null) + composable(NCPScreen.Folders.name) { + NCPNavHostComposable( + modalSheetState = modalSheetState, + searchVisibility = searchVisibility, + closeSearch = closeSearch + ) { + val filteredPasswordsParentFolder = remember(filteredPasswordList) { + filteredPasswordList?.filter { + it.folder == FoldersApi.DEFAULT_FOLDER_UUID + } } + val filteredFoldersParentFolder = remember(filteredFolderList) { + filteredFolderList?.filter { + it.parent == FoldersApi.DEFAULT_FOLDER_UUID + } + } + when { + foldersDecryptionState.isLoading || passwordsDecryptionState.isLoading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + foldersDecryptionState.decryptedList != null + && passwordsDecryptionState.decryptedList != null -> { - PullToRefreshBody( - isRefreshing = isRefreshing, - onRefresh = { passwordsViewModel.sync() }, - ) { - if (filteredFoldersParentFolder?.isEmpty() == true - && filteredPasswordsParentFolder?.isEmpty() == true - ) { - if (searchQuery.isBlank()) - NoContentText() - else - NoResultsText { navController.navigate(NCPScreen.Passwords.name) } - } else { - MixedLazyColumn( - passwords = filteredPasswordsParentFolder, - folders = filteredFoldersParentFolder, - onPasswordClick = onPasswordClick, - onPasswordLongClick = { - if (sessionOpen && !isAutofillRequest && it.editable) - navController.navigate("${NCPScreen.PasswordEdit.name}/${it.id}") - }, - onFolderClick = onFolderClick, - onFolderLongClick = { - if (sessionOpen && !isAutofillRequest) - navController.navigate("${NCPScreen.FolderEdit.name}/${it.id}") - }, - getPainterForUrl = { passwordsViewModel.getPainterForUrl(url = it) } - ) + LaunchedEffect(Unit) { + passwordsViewModel.setVisibleFolder(null) + } + + PullToRefreshBody( + isRefreshing = isRefreshing, + onRefresh = { passwordsViewModel.sync() }, + ) { + if (filteredFoldersParentFolder?.isEmpty() == true + && filteredPasswordsParentFolder?.isEmpty() == true + ) { + if (searchQuery.isBlank()) + NoContentText() + else + NoResultsText { navController.navigate(NCPScreen.Passwords.name) } + } else { + MixedLazyColumn( + passwords = filteredPasswordsParentFolder, + folders = filteredFoldersParentFolder, + onPasswordClick = onPasswordClick, + onPasswordLongClick = { + if (sessionOpen && (autofillData == null || autofillData.isSave()) && it.editable) + navController.navigate("${NCPScreen.PasswordEdit.name}/${it.id}") + }, + onFolderClick = onFolderClick, + onFolderLongClick = { + if (sessionOpen && (autofillData == null || autofillData.isSave())) + navController.navigate("${NCPScreen.FolderEdit.name}/${it.id}") + }, + getPainterForUrl = { passwordsViewModel.getPainterForUrl(url = it) } + ) + } + } } } } } - } - } - composable( - route = "${NCPScreen.Folders.name}/{folder_uuid}", - arguments = listOf( - navArgument("folder_uuid") { - type = NavType.StringType - } - ) - ) { entry -> - val folderUuid = - entry.arguments?.getString("folder_uuid") ?: FoldersApi.DEFAULT_FOLDER_UUID - val filteredPasswordsSelectedFolder = remember(filteredPasswordList) { - filteredPasswordList?.filter { - it.folder == folderUuid - } - } - val filteredFoldersSelectedFolder = remember(filteredFolderList) { - filteredFolderList?.filter { - it.parent == folderUuid - } - } - NCPNavHostComposable( - modalSheetState = modalSheetState, - searchVisibility = searchVisibility, - closeSearch = closeSearch - ) { - when { - passwordsDecryptionState.isLoading -> { - Box(modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + composable( + route = "${NCPScreen.Folders.name}/{folder_uuid}", + arguments = listOf( + navArgument("folder_uuid") { + type = NavType.StringType + } + ) + ) { entry -> + val folderUuid = + entry.arguments?.getString("folder_uuid") ?: FoldersApi.DEFAULT_FOLDER_UUID + val filteredPasswordsSelectedFolder = remember(filteredPasswordList) { + filteredPasswordList?.filter { + it.folder == folderUuid } } - passwordsDecryptionState.decryptedList != null -> { - DisposableEffect(folderUuid) { - if (foldersDecryptionState.decryptedList?.isEmpty() == false) { - passwordsViewModel.setVisibleFolder(foldersDecryptionState.decryptedList - ?.firstOrNull { it.id == folderUuid }) - } - onDispose { - if (passwordsViewModel.visibleFolder.value?.id == folderUuid) { - passwordsViewModel.setVisibleFolder(null) + val filteredFoldersSelectedFolder = remember(filteredFolderList) { + filteredFolderList?.filter { + it.parent == folderUuid + } + } + NCPNavHostComposable( + modalSheetState = modalSheetState, + searchVisibility = searchVisibility, + closeSearch = closeSearch + ) { + when { + passwordsDecryptionState.isLoading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } - } + passwordsDecryptionState.decryptedList != null -> { + DisposableEffect(folderUuid) { + if (foldersDecryptionState.decryptedList?.isEmpty() == false) { + passwordsViewModel.setVisibleFolder(foldersDecryptionState.decryptedList + ?.firstOrNull { it.id == folderUuid }) + } + onDispose { + if (passwordsViewModel.visibleFolder.value?.id == folderUuid) { + passwordsViewModel.setVisibleFolder(null) + } + } + } - PullToRefreshBody( - isRefreshing = isRefreshing, - onRefresh = { passwordsViewModel.sync() }, - ) { - if (filteredFoldersSelectedFolder?.isEmpty() == true - && filteredPasswordsSelectedFolder?.isEmpty() == true - ) { - if (searchQuery.isBlank()) - NoContentText() - else - NoResultsText { navController.navigate(NCPScreen.Passwords.name) } - } else { - MixedLazyColumn( - passwords = filteredPasswordsSelectedFolder, - folders = filteredFoldersSelectedFolder, - onPasswordClick = onPasswordClick, - onPasswordLongClick = { - if (sessionOpen && !isAutofillRequest && it.editable) - navController.navigate("${NCPScreen.PasswordEdit.name}/${it.id}") - }, - onFolderClick = onFolderClick, - onFolderLongClick = { - if (sessionOpen && !isAutofillRequest) - navController.navigate("${NCPScreen.FolderEdit.name}/${it.id}") - }, - getPainterForUrl = { passwordsViewModel.getPainterForUrl(url = it) } - ) + PullToRefreshBody( + isRefreshing = isRefreshing, + onRefresh = { passwordsViewModel.sync() }, + ) { + if (filteredFoldersSelectedFolder?.isEmpty() == true + && filteredPasswordsSelectedFolder?.isEmpty() == true + ) { + if (searchQuery.isBlank()) + NoContentText() + else + NoResultsText { navController.navigate(NCPScreen.Passwords.name) } + } else { + MixedLazyColumn( + passwords = filteredPasswordsSelectedFolder, + folders = filteredFoldersSelectedFolder, + onPasswordClick = onPasswordClick, + onPasswordLongClick = { + if (sessionOpen && (autofillData == null || autofillData.isSave()) && it.editable) + navController.navigate("${NCPScreen.PasswordEdit.name}/${it.id}") + }, + onFolderClick = onFolderClick, + onFolderLongClick = { + if (sessionOpen && (autofillData == null || autofillData.isSave())) + navController.navigate("${NCPScreen.FolderEdit.name}/${it.id}") + }, + getPainterForUrl = { passwordsViewModel.getPainterForUrl(url = it) } + ) + } + } } } } } - } - } - - composable( - route = "${NCPScreen.PasswordEdit.name}/{password_uuid}", - arguments = listOf( - navArgument("password_uuid") { - type = NavType.StringType - } - ) - ) { entry -> - BackHandler(enabled = isUpdating) { - // Block back gesture when updating to avoid data loss - return@BackHandler - } - val passwordUuid = entry.arguments?.getString("password_uuid") - val selectedPassword = remember(passwordsDecryptionState.decryptedList, passwordUuid) { - if (passwordUuid == "none") { - null - } else { - passwordsDecryptionState.decryptedList?.firstOrNull { - it.id == passwordUuid - } - } - } - NCPNavHostComposable( - modalSheetState = modalSheetState, - searchVisibility = searchVisibility, - closeSearch = closeSearch - ) { - when { - passwordsDecryptionState.isLoading || foldersDecryptionState.isLoading -> { - Box(modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + composable( + route = "${NCPScreen.PasswordEdit.name}/{password_uuid}", + arguments = listOf( + navArgument("password_uuid") { + type = NavType.StringType } + ) + ) { entry -> + BackHandler(enabled = isUpdating) { + // Block back gesture when updating to avoid data loss + return@BackHandler } - passwordsDecryptionState.decryptedList != null && foldersDecryptionState.decryptedList != null -> { - val editablePasswordState = - rememberEditablePasswordState(selectedPassword).apply { - if (selectedPassword == null) { - folder = passwordsViewModel.visibleFolder.value?.id ?: folder + val passwordUuid = entry.arguments?.getString("password_uuid") + val selectedPassword = remember(passwordsDecryptionState.decryptedList, passwordUuid) { + if (passwordUuid == "none") { + null + } else { + passwordsDecryptionState.decryptedList?.firstOrNull { + it.id == passwordUuid + } + } + } + NCPNavHostComposable( + modalSheetState = modalSheetState, + searchVisibility = searchVisibility, + closeSearch = closeSearch + ) { + when { + passwordsDecryptionState.isLoading || foldersDecryptionState.isLoading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } - EditablePasswordView( - editablePasswordState = editablePasswordState, - folders = foldersDecryptionState.decryptedList ?: listOf(), - onSavePassword = { - val currentKeychain = keychain - - val customFields = - Json.encodeToString(editablePasswordState.customFields.toList()) - - if (selectedPassword == null) { - // New password - val newPassword = - if (currentKeychain != null && serverSettings.encryptionCse != 0) { - NewPassword( - password = editablePasswordState.password.encryptValue( - currentKeychain.current, - currentKeychain - ), - label = editablePasswordState.label.encryptValue( - currentKeychain.current, - currentKeychain - ), - username = editablePasswordState.username.encryptValue( - currentKeychain.current, - currentKeychain - ), - url = editablePasswordState.url.encryptValue( - currentKeychain.current, - currentKeychain - ), - notes = editablePasswordState.notes.encryptValue( - currentKeychain.current, - currentKeychain - ), - customFields = customFields.encryptValue( - currentKeychain.current, - currentKeychain - ), - hash = editablePasswordState.password.sha1Hash() - .take(serverSettings.passwordSecurityHash), - cseType = "CSEv1r1", - cseKey = currentKeychain.current, - folder = editablePasswordState.folder, - edited = 0, - hidden = false, - favorite = editablePasswordState.favorite - ) - } else { - NewPassword( - password = editablePasswordState.password, - label = editablePasswordState.label, - username = editablePasswordState.username, - url = editablePasswordState.url, - notes = editablePasswordState.notes, - customFields = customFields, - hash = editablePasswordState.password.sha1Hash() - .take(serverSettings.passwordSecurityHash), - cseType = "none", - cseKey = "", - folder = editablePasswordState.folder, - edited = 0, - hidden = false, - favorite = editablePasswordState.favorite - ) - } - coroutineScope.launch { - if (passwordsViewModel.createPassword(newPassword) - .await() - ) { - if (editablePasswordState.replyAutofill && replyAutofill != null) { - replyAutofill( - editablePasswordState.label, - editablePasswordState.username, - editablePasswordState.password - ) + passwordsDecryptionState.decryptedList != null && foldersDecryptionState.decryptedList != null -> { + val editablePasswordState = rememberEditablePasswordState(selectedPassword).apply { + if (selectedPassword == null) { + folder = passwordsViewModel.visibleFolder.value?.id ?: folder + } + when (autofillData) { + is AutofillData.SaveAutofill, is AutofillData.Save -> { + // workaround as the compiler not allow accessing saveData in this dual branch for some reason + val saveData = (autofillData as? AutofillData.Save)?.saveData ?: (autofillData as AutofillData.SaveAutofill).saveData + + if (selectedPassword == null) { + label = saveData.label + username = saveData.username + password = saveData.password + url = saveData.url } else { - navController.navigateUp() + // prioritize existing label and url fields + label = if(label.isNullOrBlank()) saveData.label else label + url = if(url.isNullOrBlank()) saveData.url else url + // prioritize new username and password fields + username = if(saveData.username.isNullOrBlank()) username else saveData.username + password = if(saveData.password.isNullOrBlank()) password else saveData.password } - } else { - Toast.makeText( - context, - R.string.error_password_saving_failed, - Toast.LENGTH_LONG - ).show() } + else -> {} } - } else { - val updatedPassword = - if (currentKeychain != null && selectedPassword.cseType == "CSEv1r1") { - UpdatedPassword( - id = selectedPassword.id, - revision = selectedPassword.revision, - password = editablePasswordState.password.encryptValue( - currentKeychain.current, - currentKeychain - ), - label = editablePasswordState.label.encryptValue( - currentKeychain.current, - currentKeychain - ), - username = editablePasswordState.username.encryptValue( - currentKeychain.current, - currentKeychain - ), - url = editablePasswordState.url.encryptValue( - currentKeychain.current, - currentKeychain - ), - notes = editablePasswordState.notes.encryptValue( - currentKeychain.current, - currentKeychain - ), - customFields = customFields.encryptValue( - currentKeychain.current, - currentKeychain - ), - hash = editablePasswordState.password.sha1Hash() - .take(serverSettings.passwordSecurityHash), - cseType = "CSEv1r1", - cseKey = currentKeychain.current, - folder = editablePasswordState.folder, - edited = if (editablePasswordState.password == selectedPassword.password) selectedPassword.edited else 0, - hidden = selectedPassword.hidden, - favorite = editablePasswordState.favorite - ) + } + + EditablePasswordView( + editablePasswordState = editablePasswordState, + folders = foldersDecryptionState.decryptedList ?: listOf(), + onSavePassword = { + val currentKeychain = keychain + + val customFields = + Json.encodeToString(editablePasswordState.customFields.toList()) + + if (selectedPassword == null) { + // New password + val newPassword = + if (currentKeychain != null && serverSettings.encryptionCse != 0) { + NewPassword( + password = editablePasswordState.password.encryptValue( + currentKeychain.current, + currentKeychain + ), + label = editablePasswordState.label.encryptValue( + currentKeychain.current, + currentKeychain + ), + username = editablePasswordState.username.encryptValue( + currentKeychain.current, + currentKeychain + ), + url = editablePasswordState.url.encryptValue( + currentKeychain.current, + currentKeychain + ), + notes = editablePasswordState.notes.encryptValue( + currentKeychain.current, + currentKeychain + ), + customFields = customFields.encryptValue( + currentKeychain.current, + currentKeychain + ), + hash = editablePasswordState.password.sha1Hash() + .take(serverSettings.passwordSecurityHash), + cseType = "CSEv1r1", + cseKey = currentKeychain.current, + folder = editablePasswordState.folder, + edited = 0, + hidden = false, + favorite = editablePasswordState.favorite + ) + } else { + NewPassword( + password = editablePasswordState.password, + label = editablePasswordState.label, + username = editablePasswordState.username, + url = editablePasswordState.url, + notes = editablePasswordState.notes, + customFields = customFields, + hash = editablePasswordState.password.sha1Hash() + .take(serverSettings.passwordSecurityHash), + cseType = "none", + cseKey = "", + folder = editablePasswordState.folder, + edited = 0, + hidden = false, + favorite = editablePasswordState.favorite + ) + } + coroutineScope.launch { + if (passwordsViewModel.createPassword(newPassword) + .await() + ) { + if (editablePasswordState.replyAutofill && replyAutofill != null) { + replyAutofill( + editablePasswordState.label, + editablePasswordState.username, + editablePasswordState.password + ) + } else { + passwordsDecryptionState = ListDecryptionState(isLoading = true) + navController.navigateUp() + } + } else { + Toast.makeText( + context, + R.string.error_password_saving_failed, + Toast.LENGTH_LONG + ).show() + } + } } else { - UpdatedPassword( + val updatedPassword = + if (currentKeychain != null && selectedPassword.cseType == "CSEv1r1") { + UpdatedPassword( + id = selectedPassword.id, + revision = selectedPassword.revision, + password = editablePasswordState.password.encryptValue( + currentKeychain.current, + currentKeychain + ), + label = editablePasswordState.label.encryptValue( + currentKeychain.current, + currentKeychain + ), + username = editablePasswordState.username.encryptValue( + currentKeychain.current, + currentKeychain + ), + url = editablePasswordState.url.encryptValue( + currentKeychain.current, + currentKeychain + ), + notes = editablePasswordState.notes.encryptValue( + currentKeychain.current, + currentKeychain + ), + customFields = customFields.encryptValue( + currentKeychain.current, + currentKeychain + ), + hash = editablePasswordState.password.sha1Hash() + .take(serverSettings.passwordSecurityHash), + cseType = "CSEv1r1", + cseKey = currentKeychain.current, + folder = editablePasswordState.folder, + edited = if (editablePasswordState.password == selectedPassword.password) selectedPassword.edited else 0, + hidden = selectedPassword.hidden, + favorite = editablePasswordState.favorite + ) + } else { + UpdatedPassword( + id = selectedPassword.id, + revision = selectedPassword.revision, + password = editablePasswordState.password, + label = editablePasswordState.label, + username = editablePasswordState.username, + url = editablePasswordState.url, + notes = editablePasswordState.notes, + customFields = customFields, + hash = editablePasswordState.password.sha1Hash() + .take(serverSettings.passwordSecurityHash), + cseType = "none", + cseKey = "", + folder = editablePasswordState.folder, + edited = if (editablePasswordState.password == selectedPassword.password) selectedPassword.edited else 0, + hidden = selectedPassword.hidden, + favorite = editablePasswordState.favorite + ) + } + coroutineScope.launch { + if (passwordsViewModel.updatePassword(updatedPassword) + .await() + ) { + if (editablePasswordState.replyAutofill && replyAutofill != null) { + replyAutofill( + editablePasswordState.label, + editablePasswordState.username, + editablePasswordState.password + ) + } else { + passwordsDecryptionState = ListDecryptionState(isLoading = true) + navController.navigateUp() + } + } else { + Toast.makeText( + context, + R.string.error_password_saving_failed, + Toast.LENGTH_LONG + ).show() + } + } + } + }, + onDeletePassword = if (selectedPassword == null) null + else { + { + val deletedPassword = DeletedPassword( id = selectedPassword.id, - revision = selectedPassword.revision, - password = editablePasswordState.password, - label = editablePasswordState.label, - username = editablePasswordState.username, - url = editablePasswordState.url, - notes = editablePasswordState.notes, - customFields = customFields, - hash = editablePasswordState.password.sha1Hash() - .take(serverSettings.passwordSecurityHash), - cseType = "none", - cseKey = "", - folder = editablePasswordState.folder, - edited = if (editablePasswordState.password == selectedPassword.password) selectedPassword.edited else 0, - hidden = selectedPassword.hidden, - favorite = editablePasswordState.favorite + revision = selectedPassword.revision ) - } - coroutineScope.launch { - if (passwordsViewModel.updatePassword(updatedPassword) - .await() - ) { - if (editablePasswordState.replyAutofill && replyAutofill != null) { - replyAutofill( - editablePasswordState.label, - editablePasswordState.username, - editablePasswordState.password - ) - } else { - navController.navigateUp() + coroutineScope.launch { + if (passwordsViewModel.deletePassword(deletedPassword) + .await() + ) { + passwordsDecryptionState = ListDecryptionState(isLoading = true) + navController.navigateUp() + } else { + Toast.makeText( + context, + R.string.error_password_deleting_failed, + Toast.LENGTH_LONG + ).show() + } } - } else { - Toast.makeText( - context, - R.string.error_password_saving_failed, - Toast.LENGTH_LONG - ).show() - } - } - } - }, - onDeletePassword = if (selectedPassword == null) null - else { - { - val deletedPassword = DeletedPassword( - id = selectedPassword.id, - revision = selectedPassword.revision - ) - coroutineScope.launch { - if (passwordsViewModel.deletePassword(deletedPassword) - .await() - ) { - navController.navigateUp() - } else { - Toast.makeText( - context, - R.string.error_password_deleting_failed, - Toast.LENGTH_LONG - ).show() } - } - } - }, - isUpdating = isUpdating, - isAutofillRequest = isAutofillRequest, - onGeneratePassword = passwordsViewModel::generatePassword - ) + }, + isUpdating = isUpdating, + autofillData = autofillData, + onGeneratePassword = passwordsViewModel::generatePassword + ) + } + } } } - } - } - composable( - route = "${NCPScreen.FolderEdit.name}/{folder_uuid}", - arguments = listOf( - navArgument("folder_uuid") { - type = NavType.StringType - } - ) - ) { entry -> - BackHandler(enabled = isUpdating) { - // Block back gesture when updating to avoid data loss - return@BackHandler - } - - val folderUuid = entry.arguments?.getString("folder_uuid") - val selectedFolder = remember(foldersDecryptionState.decryptedList, folderUuid) { - if (folderUuid == "none") { - null - } else { - foldersDecryptionState.decryptedList?.firstOrNull { - it.id == folderUuid - } - } - } - NCPNavHostComposable( - modalSheetState = modalSheetState, - searchVisibility = searchVisibility, - closeSearch = closeSearch - ) { - when { - foldersDecryptionState.isLoading -> { - Box(modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + composable( + route = "${NCPScreen.FolderEdit.name}/{folder_uuid}", + arguments = listOf( + navArgument("folder_uuid") { + type = NavType.StringType } + ) + ) { entry -> + BackHandler(enabled = isUpdating) { + // Block back gesture when updating to avoid data loss + return@BackHandler } - foldersDecryptionState.decryptedList != null -> { - val editableFolderState = - rememberEditableFolderState(selectedFolder).apply { - if (selectedFolder == null) { - parent = passwordsViewModel.visibleFolder.value?.id ?: parent + val folderUuid = entry.arguments?.getString("folder_uuid") + val selectedFolder = remember(foldersDecryptionState.decryptedList, folderUuid) { + if (folderUuid == "none") { + null + } else { + foldersDecryptionState.decryptedList?.firstOrNull { + it.id == folderUuid + } + } + } + NCPNavHostComposable( + modalSheetState = modalSheetState, + searchVisibility = searchVisibility, + closeSearch = closeSearch + ) { + when { + foldersDecryptionState.isLoading -> { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } - EditableFolderView( - editableFolderState = editableFolderState, - folders = foldersDecryptionState.decryptedList ?: listOf(), - onSaveFolder = { - if (selectedFolder == null) { - val newFolder = keychain?.let { - NewFolder( - label = editableFolderState.label.encryptValue( - it.current, - it - ), - cseType = "CSEv1r1", - cseKey = it.current, - parent = editableFolderState.parent, - edited = 0, - hidden = false, - favorite = editableFolderState.favorite - ) - } ?: NewFolder( - label = editableFolderState.label, - cseType = "none", - cseKey = "", - parent = editableFolderState.parent, - edited = 0, - hidden = false, - favorite = editableFolderState.favorite - ) - coroutineScope.launch { - if (passwordsViewModel.createFolder(newFolder) - .await() - ) { - navController.navigateUp() - } else { - Toast.makeText( - context, - R.string.error_folder_saving_failed, - Toast.LENGTH_LONG - ).show() + foldersDecryptionState.decryptedList != null -> { + val editableFolderState = + rememberEditableFolderState(selectedFolder).apply { + if (selectedFolder == null) { + parent = passwordsViewModel.visibleFolder.value?.id ?: parent } } - } else { - val updatedFolder = keychain?.let { - UpdatedFolder( - id = selectedFolder.id, - revision = selectedFolder.revision, - label = editableFolderState.label.encryptValue( - it.current, - it - ), - cseType = "CSEv1r1", - cseKey = it.current, - parent = editableFolderState.parent, - edited = if (editableFolderState.label == selectedFolder.label) selectedFolder.edited else 0, - hidden = selectedFolder.hidden, - favorite = editableFolderState.favorite - ) - } ?: UpdatedFolder( - id = selectedFolder.id, - revision = selectedFolder.revision, - label = editableFolderState.label, - cseType = "none", - cseKey = "", - parent = editableFolderState.parent, - edited = if (editableFolderState.label == selectedFolder.label) selectedFolder.edited else 0, - hidden = selectedFolder.hidden, - favorite = editableFolderState.favorite - ) - coroutineScope.launch { - if (passwordsViewModel.updateFolder(updatedFolder) - .await() - ) { - navController.navigateUp() + + EditableFolderView( + editableFolderState = editableFolderState, + folders = foldersDecryptionState.decryptedList ?: listOf(), + onSaveFolder = { + if (selectedFolder == null) { + val newFolder = keychain?.let { + NewFolder( + label = editableFolderState.label.encryptValue( + it.current, + it + ), + cseType = "CSEv1r1", + cseKey = it.current, + parent = editableFolderState.parent, + edited = 0, + hidden = false, + favorite = editableFolderState.favorite + ) + } ?: NewFolder( + label = editableFolderState.label, + cseType = "none", + cseKey = "", + parent = editableFolderState.parent, + edited = 0, + hidden = false, + favorite = editableFolderState.favorite + ) + coroutineScope.launch { + if (passwordsViewModel.createFolder(newFolder) + .await() + ) { + navController.navigateUp() + } else { + Toast.makeText( + context, + R.string.error_folder_saving_failed, + Toast.LENGTH_LONG + ).show() + } + } } else { - Toast.makeText( - context, - R.string.error_folder_saving_failed, - Toast.LENGTH_LONG - ).show() + val updatedFolder = keychain?.let { + UpdatedFolder( + id = selectedFolder.id, + revision = selectedFolder.revision, + label = editableFolderState.label.encryptValue( + it.current, + it + ), + cseType = "CSEv1r1", + cseKey = it.current, + parent = editableFolderState.parent, + edited = if (editableFolderState.label == selectedFolder.label) selectedFolder.edited else 0, + hidden = selectedFolder.hidden, + favorite = editableFolderState.favorite + ) + } ?: UpdatedFolder( + id = selectedFolder.id, + revision = selectedFolder.revision, + label = editableFolderState.label, + cseType = "none", + cseKey = "", + parent = editableFolderState.parent, + edited = if (editableFolderState.label == selectedFolder.label) selectedFolder.edited else 0, + hidden = selectedFolder.hidden, + favorite = editableFolderState.favorite + ) + coroutineScope.launch { + if (passwordsViewModel.updateFolder(updatedFolder) + .await() + ) { + navController.navigateUp() + } else { + Toast.makeText( + context, + R.string.error_folder_saving_failed, + Toast.LENGTH_LONG + ).show() + } + } } - } - } - }, - onDeleteFolder = if (selectedFolder == null) null - else { - { - val deletedFolder = DeletedFolder( - id = selectedFolder.id, - revision = selectedFolder.revision - ) - coroutineScope.launch { - if (passwordsViewModel.deleteFolder(deletedFolder) - .await() - ) { - navController.navigateUp() - } else { - Toast.makeText( - context, - R.string.error_folder_deleting_failed, - Toast.LENGTH_LONG - ).show() + }, + onDeleteFolder = if (selectedFolder == null) null + else { + { + val deletedFolder = DeletedFolder( + id = selectedFolder.id, + revision = selectedFolder.revision + ) + coroutineScope.launch { + if (passwordsViewModel.deleteFolder(deletedFolder) + .await() + ) { + navController.navigateUp() + } else { + Toast.makeText( + context, + R.string.error_folder_deleting_failed, + Toast.LENGTH_LONG + ).show() + } + } } - } - } - }, - isUpdating = isUpdating, - ) + }, + isUpdating = isUpdating, + ) + } + } } } } diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHostForAutofill.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHostForAutofill.kt deleted file mode 100644 index 869f4ab3..00000000 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHostForAutofill.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.hegocre.nextcloudpasswords.ui.components - -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import com.hegocre.nextcloudpasswords.R -import com.hegocre.nextcloudpasswords.api.FoldersApi -import com.hegocre.nextcloudpasswords.data.folder.DeletedFolder -import com.hegocre.nextcloudpasswords.data.folder.Folder -import com.hegocre.nextcloudpasswords.data.folder.NewFolder -import com.hegocre.nextcloudpasswords.data.folder.UpdatedFolder -import com.hegocre.nextcloudpasswords.data.password.DeletedPassword -import com.hegocre.nextcloudpasswords.data.password.NewPassword -import com.hegocre.nextcloudpasswords.data.password.Password -import com.hegocre.nextcloudpasswords.data.password.UpdatedPassword -import com.hegocre.nextcloudpasswords.data.serversettings.ServerSettings -import com.hegocre.nextcloudpasswords.ui.NCPScreen -import com.hegocre.nextcloudpasswords.ui.viewmodels.PasswordsViewModel -import com.hegocre.nextcloudpasswords.utils.PreferencesManager -import com.hegocre.nextcloudpasswords.utils.decryptFolders -import com.hegocre.nextcloudpasswords.utils.decryptPasswords -import com.hegocre.nextcloudpasswords.utils.encryptValue -import com.hegocre.nextcloudpasswords.utils.sha1Hash -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import com.hegocre.nextcloudpasswords.services.autofill.SaveData - -@ExperimentalMaterial3Api -@Composable -fun NCPNavHostForAutofill( - navController: NavHostController, - passwordsViewModel: PasswordsViewModel, - modifier: Modifier = Modifier, - passwordId: String? = null, - searchQuery: String = "", - isAutofillRequest: Boolean, - saveData: SaveData? = null, - replyAutofill: ((String, String, String) -> Unit)? = null, - modalSheetState: SheetState? = null, -) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - - val passwords by passwordsViewModel.passwords.observeAsState() - val folders by passwordsViewModel.folders.observeAsState() - val keychain by passwordsViewModel.csEv1Keychain.observeAsState() - val isRefreshing by passwordsViewModel.isRefreshing.collectAsState() - val isUpdating by passwordsViewModel.isUpdating.collectAsState() - val serverSettings by passwordsViewModel.serverSettings.observeAsState(initial = ServerSettings()) - val sessionOpen by passwordsViewModel.sessionOpen.collectAsState() - - val passwordsDecryptionState by produceState( - initialValue = ListDecryptionState(isLoading = true), - key1 = passwords, key2 = keychain - ) { - value = ListDecryptionState(decryptedList = passwords?.decryptPasswords(keychain) ?: emptyList()) - } - - val baseFolderName = stringResource(R.string.top_level_folder_name) - val reply: (Password) -> Unit = { password -> - if (isAutofillRequest && replyAutofill != null && passwordId != null) { - replyAutofill(password.label, password.username, password.password) - } - } - - val userStartDestination by PreferencesManager.getInstance(context).getStartScreen() - .collectAsState(NCPScreen.Passwords.name, context = Dispatchers.IO) - - val startDestination = remember(isAutofillRequest, userStartDestination) { - if (isAutofillRequest) NCPScreen.Passwords.name else userStartDestination - } - - val filteredPasswordList = remember(passwordsDecryptionState.decryptedList) { - passwordsDecryptionState.decryptedList?.filter { - !it.hidden && !it.trashed && it.id == passwordId - } - } - - NavHost( - navController = navController, - startDestination = startDestination, - modifier = modifier, - enterTransition = { fadeIn(animationSpec = tween(300)) }, - exitTransition = { fadeOut(animationSpec = tween(300)) }, - ) { - composable(NCPScreen.Passwords.name) { - NCPNavHostComposable( - modalSheetState = modalSheetState, - ) { - when { - passwordsDecryptionState.isLoading -> { - Box(modifier = Modifier.fillMaxSize()) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - } - passwordsDecryptionState.decryptedList != null -> { - PullToRefreshBody( - isRefreshing = isRefreshing, - onRefresh = { passwordsViewModel.sync() }, - ) { - if (filteredPasswordList?.isEmpty() == true) { - if (searchQuery.isBlank()) NoContentText() else NoResultsText() - } else { - reply(filteredPasswordList!![0]) - } - } - } - } - } - } - } -} - -@ExperimentalMaterial3Api -@Composable -fun NCPNavHostComposable( - modifier: Modifier = Modifier, - modalSheetState: SheetState? = null, - content: @Composable () -> Unit = { } -) { - val scope = rememberCoroutineScope() - BackHandler(enabled = modalSheetState?.isVisible ?: false) { - scope.launch { - modalSheetState?.hide() - } - } - Box(modifier = modifier) { - content() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPTopBar.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPTopBar.kt index 9e61cb64..e9e26832 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPTopBar.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPTopBar.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.window.DialogProperties import com.hegocre.nextcloudpasswords.R import com.hegocre.nextcloudpasswords.ui.theme.ContentAlpha import com.hegocre.nextcloudpasswords.ui.theme.NextcloudPasswordsTheme +import com.hegocre.nextcloudpasswords.utils.AutofillData import kotlinx.coroutines.job object AppBarDefaults { @@ -103,7 +104,7 @@ fun NCPSearchTopBar( }, searchQuery: String = "", setSearchQuery: (String) -> Unit = {}, - isAutofill: Boolean = false, + autofillData: AutofillData? = null, onLogoutClick: () -> Unit = {}, searchExpanded: Boolean = false, onSearchClick: () -> Unit = {}, @@ -127,7 +128,7 @@ fun NCPSearchTopBar( onSearchClick = onSearchClick, onLogoutClick = onLogoutClick, scrollBehavior = scrollBehavior, - showMenu = !isAutofill, + showMenu = autofillData == null, userAvatar = userAvatar ) } diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/PasswordEditView.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/PasswordEditView.kt index 3bb91131..2dd3c946 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/PasswordEditView.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/PasswordEditView.kt @@ -71,6 +71,7 @@ import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlin.reflect.KFunction3 +import com.hegocre.nextcloudpasswords.utils.AutofillData class EditablePasswordState(originalPassword: Password?) { var password by mutableStateOf(originalPassword?.password ?: "") @@ -147,7 +148,7 @@ fun EditablePasswordView( editablePasswordState: EditablePasswordState, folders: List, isUpdating: Boolean, - isAutofillRequest: Boolean, + autofillData: AutofillData?, onGeneratePassword: KFunction3>?, onSavePassword: () -> Unit, onDeletePassword: (() -> Unit)? = null @@ -544,7 +545,7 @@ fun EditablePasswordView( ) } - if (isAutofillRequest) { + if (autofillData != null && autofillData.isAutofill()) { item(key = "password_save_autofill") { Button( onClick = { @@ -673,7 +674,7 @@ fun PasswordEditPreview() { }, folders = listOf(), isUpdating = false, - isAutofillRequest = true, + autofillData = null, onSavePassword = { }, onDeletePassword = { }, onGeneratePassword = null diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt new file mode 100644 index 00000000..959f46a5 --- /dev/null +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt @@ -0,0 +1,60 @@ +package com.hegocre.nextcloudpasswords.utils + +import android.os.Parcel +import android.os.Parcelable +import android.app.assist.AssistStructure +import kotlinx.parcelize.Parcelize + +data class PasswordAutofillData(val id: String?, val label: String, val username: String?, val password: String?) + +@Parcelize +data class SaveData( + val label: String, + val username: String, + val password: String, + val url: String, +) : Parcelable {} + +sealed class AutofillData : Parcelable { + @Parcelize + data class FromId( + val id: String, + val structure: AssistStructure + ) : AutofillData() + + @Parcelize + data class ChoosePwd( + val searchHint: String, + val structure: AssistStructure + ) : AutofillData() + + @Parcelize + data class SaveAutofill( + val searchHint: String, + val saveData: SaveData, + val structure: AssistStructure, + ) : AutofillData() + + @Parcelize + data class Save( + val searchHint: String, + val saveData: SaveData + ) : AutofillData() + + fun isAutofill(): Boolean { + return when (this) { + is FromId -> true + is ChoosePwd -> true + is SaveAutofill -> true + is Save -> false + } + } + + fun isSave(): Boolean { + return when (this) { + is SaveAutofill -> true + is Save -> true + else -> false + } + } +} \ No newline at end of file From fa2c778a9e1caeb69e20c9e3ce644c281380032a Mon Sep 17 00:00:00 2001 From: difanta Date: Sat, 21 Feb 2026 00:57:30 +0100 Subject: [PATCH 05/24] maybe fix domain matching --- .../services/autofill/NCPAutofillService.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 0bcb5699..0e3d5a3e 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 @@ -79,7 +79,7 @@ class NCPAutofillService : AutofillService() { apiController.csEv1Keychain.asFlow() ) { passwords, keychain -> ListDecryptionStateNonNullable(isLoading = true) - ListDecryptionStateNonNullable(passwords.filter { !it.trashed && !it.hidden }.decryptPasswords(keychain), false) + ListDecryptionStateNonNullable(passwords.decryptPasswords(keychain), false) } .flowOn(Dispatchers.Default) .stateIn( @@ -163,8 +163,8 @@ class NCPAutofillService : AutofillService() { decryptedPasswordsState.first { !it.isLoading } val filteredList = decryptedPasswordsState.value.decryptedList.filter { - it.matches(searchHint, strictUrlMatching.first()) || - (searchByUsername.first() && it.username.contains(searchHint, ignoreCase = true)) + !it.hidden && !it.trashed && (it.matches(searchHint, strictUrlMatching.first()) + || (searchByUsername.first() && it.username.contains(searchHint, ignoreCase = true))) }.let { list -> when (orderBy.first()) { PreferencesManager.ORDER_BY_TITLE_DESCENDING -> list.sortedByDescending { it.label.lowercase() } From 4fe1f1fea4877876561f5b707ed51013399a7b42 Mon Sep 17 00:00:00 2001 From: difanta Date: Sat, 21 Feb 2026 01:44:35 +0100 Subject: [PATCH 06/24] unique autofill intent codes --- .../services/autofill/AutofillHelper.kt | 20 +++++++++++-------- .../services/autofill/NCPAutofillService.kt | 11 +++++----- .../ui/activities/MainActivity.kt | 2 +- 3 files changed, 19 insertions(+), 14 deletions(-) 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 8cf5bf23..1b3fd301 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 @@ -36,7 +36,8 @@ object AutofillHelper { helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec?, intent: IntentSender? = null, - needsAppLock: Boolean = false + needsAppLock: Boolean = false, + datasetIdx: Int = 0 ): Dataset { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (inlinePresentationSpec != null) { @@ -46,13 +47,14 @@ object AutofillHelper { helper, inlinePresentationSpec, intent, - needsAppLock + needsAppLock, + datasetIdx ) } else { - buildPresentationDataset(context, password, helper, intent, needsAppLock) + buildPresentationDataset(context, password, helper, intent, needsAppLock, datasetIdx) } } else { - buildPresentationDataset(context, password, helper, intent, needsAppLock) + buildPresentationDataset(context, password, helper, intent, needsAppLock, datasetIdx) } } @@ -115,7 +117,8 @@ object AutofillHelper { helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec, intent: IntentSender? = null, - needsAppLock: Boolean = false + needsAppLock: Boolean = false, + datasetIdx: Int ): Dataset { // build redacted dataset when app lock is needed return if (needsAppLock && password?.id != null) { @@ -138,7 +141,7 @@ object AutofillHelper { inlinePresentationSpec ) } - setAuthentication(buildIntent(context, 1002, AutofillData.FromId(id=password.id, structure=helper.structure))) + setAuthentication(buildIntent(context, 1004+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) }.build() } else { Dataset.Builder().apply { @@ -170,7 +173,8 @@ object AutofillHelper { password: PasswordAutofillData?, helper: AssistStructureParser, intent: IntentSender? = null, - needsAppLock: Boolean = false + needsAppLock: Boolean = false, + datasetIdx: Int ): Dataset { // build redacted dataset when app lock is needed return if (needsAppLock && password?.id != null) { @@ -181,7 +185,7 @@ object AutofillHelper { helper.passwordAutofillIds.forEach { autofillId -> addAutofillValue(context, autofillId, password.label, null) } - setAuthentication(buildIntent(context, 1002, AutofillData.FromId(id=password.id, structure=helper.structure))) + setAuthentication(buildIntent(context, 1004+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) }.build() } else { Dataset.Builder().apply { 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 0e3d5a3e..854ecb78 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 @@ -203,7 +203,7 @@ class NCPAutofillService : AutofillService() { } else null // Add one Dataset for each password - for (password in passwords) { + for ((idx, password) in passwords.withIndex()) { builder.addDataset( AutofillHelper.buildDataset( applicationContext, @@ -216,7 +216,8 @@ class NCPAutofillService : AutofillService() { helper, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, null, - needsAuth + needsAuth, + idx ) ) } @@ -237,7 +238,7 @@ class NCPAutofillService : AutofillService() { PasswordAutofillData(label = "Create new password", id = null, username = null, password = null), // TODO: translation helper, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, - AutofillHelper.buildIntent(applicationContext, 1003, AutofillData.SaveAutofill(searchHint, saveData, helper.structure)), + AutofillHelper.buildIntent(applicationContext, 1002, AutofillData.SaveAutofill(searchHint, saveData, helper.structure)), false ) ) @@ -252,7 +253,7 @@ class NCPAutofillService : AutofillService() { PasswordAutofillData(label = ">", id = null, username = null, password = null), // TODO use icon helper, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, - AutofillHelper.buildIntent(applicationContext, 1004, AutofillData.ChoosePwd(searchHint, helper.structure)), + AutofillHelper.buildIntent(applicationContext, 1003, AutofillData.ChoosePwd(searchHint, helper.structure)), false ) ) @@ -341,7 +342,7 @@ class NCPAutofillService : AutofillService() { // Determine Search Hint val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) - return AutofillHelper.buildIntent(applicationContext, 1005, AutofillData.Save(searchHint, SaveData(searchHint, username, password, searchHint))) + return AutofillHelper.buildIntent(applicationContext, 1004, AutofillData.Save(searchHint, SaveData(searchHint, username, password, searchHint))) } 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 602d75d6..25db7b50 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 @@ -144,7 +144,7 @@ class MainActivity : FragmentActivity() { password: PasswordAutofillData, structure: AssistStructure ) { - val dataset = AutofillHelper.buildDataset(this, password, AssistStructureParser(structure), null, null, false) + val dataset = AutofillHelper.buildDataset(this, password, AssistStructureParser(structure), null) val replyIntent = Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) From 7779e2516da05a4be2eed51140b378fbf51f611a Mon Sep 17 00:00:00 2001 From: difanta Date: Sun, 22 Feb 2026 21:33:50 +0100 Subject: [PATCH 07/24] never use searchByUsername in autofill service because it always searches with the domain --- app/build.gradle | 2 +- .../services/autofill/NCPAutofillService.kt | 4 +--- gradle.properties | 1 + 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ea16965d..407b83c1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,7 @@ plugins { java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } 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 854ecb78..75385b21 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 @@ -65,7 +65,6 @@ class NCPAutofillService : AutofillService() { private val isLocked by lazy { appLockHelper.isLocked } val orderBy by lazy { preferencesManager.getOrderBy() } - val searchByUsername by lazy { preferencesManager.getSearchByUsername() } val strictUrlMatching by lazy { preferencesManager.getUseStrictUrlMatching() } private lateinit var decryptedPasswordsState: StateFlow> @@ -163,8 +162,7 @@ class NCPAutofillService : AutofillService() { decryptedPasswordsState.first { !it.isLoading } val filteredList = decryptedPasswordsState.value.decryptedList.filter { - !it.hidden && !it.trashed && (it.matches(searchHint, strictUrlMatching.first()) - || (searchByUsername.first() && it.username.contains(searchHint, ignoreCase = true))) + !it.hidden && !it.trashed && it.matches(searchHint, strictUrlMatching.first()) }.let { list -> when (orderBy.first()) { PreferencesManager.ORDER_BY_TITLE_DESCENDING -> list.sortedByDescending { it.label.lowercase() } diff --git a/gradle.properties b/gradle.properties index 44fadca5..c5277e26 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.java.home=C:\\Program Files\\Java\\latest\\jdk-21 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects From 2f41b7dcb836fbb532b8ca93ce11deaae894d6db Mon Sep 17 00:00:00 2001 From: difanta Date: Sun, 22 Feb 2026 22:09:08 +0100 Subject: [PATCH 08/24] fix create password autofill bug by remembering the state correctly --- .../ui/components/NCPNavHost.kt | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt index 74e74c73..63eec618 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt @@ -61,6 +61,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import com.hegocre.nextcloudpasswords.utils.AutofillData +import androidx.compose.runtime.saveable.rememberSaveable @ExperimentalMaterial3Api @Composable @@ -480,30 +481,32 @@ fun NCPNavHost( } passwordsDecryptionState.decryptedList != null && foldersDecryptionState.decryptedList != null -> { - val editablePasswordState = rememberEditablePasswordState(selectedPassword).apply { - if (selectedPassword == null) { - folder = passwordsViewModel.visibleFolder.value?.id ?: folder - } - when (autofillData) { - is AutofillData.SaveAutofill, is AutofillData.Save -> { - // workaround as the compiler not allow accessing saveData in this dual branch for some reason - val saveData = (autofillData as? AutofillData.Save)?.saveData ?: (autofillData as AutofillData.SaveAutofill).saveData - - if (selectedPassword == null) { - label = saveData.label - username = saveData.username - password = saveData.password - url = saveData.url - } else { - // prioritize existing label and url fields - label = if(label.isNullOrBlank()) saveData.label else label - url = if(url.isNullOrBlank()) saveData.url else url - // prioritize new username and password fields - username = if(saveData.username.isNullOrBlank()) username else saveData.username - password = if(saveData.password.isNullOrBlank()) password else saveData.password + val editablePasswordState = rememberSaveable(selectedPassword, saver = EditablePasswordState.Saver) { + EditablePasswordState(selectedPassword).apply { + if (selectedPassword == null) { + folder = passwordsViewModel.visibleFolder.value?.id ?: folder + } + when (autofillData) { + is AutofillData.SaveAutofill, is AutofillData.Save -> { + // workaround as the compiler not allow accessing saveData in this dual branch for some reason + val saveData = (autofillData as? AutofillData.Save)?.saveData ?: (autofillData as AutofillData.SaveAutofill).saveData + + if (selectedPassword == null) { + label = saveData.label + username = saveData.username + password = saveData.password + url = saveData.url + } else { + // prioritize existing label and url fields + label = if(label.isNullOrBlank()) saveData.label else label + url = if(url.isNullOrBlank()) saveData.url else url + // prioritize new username and password fields + username = if(saveData.username.isNullOrBlank()) username else saveData.username + password = if(saveData.password.isNullOrBlank()) password else saveData.password + } } + else -> {} } - else -> {} } } From 44a5b7fa16fff3499799a7e93c05afa3426edac2 Mon Sep 17 00:00:00 2001 From: difanta Date: Sun, 22 Feb 2026 22:14:29 +0100 Subject: [PATCH 09/24] provide initial values when creating a password during ChoosePwd --- .../hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt index 63eec618..b6540ea6 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt @@ -505,6 +505,12 @@ fun NCPNavHost( password = if(saveData.password.isNullOrBlank()) password else saveData.password } } + is AutofillData.ChoosePwd -> { + if (selectedPassword == null) { + label = autofillData.searchHint + url = autofillData.searchHint + } + } else -> {} } } From 9809add14a3b8a0dbcb43cf5b1f99cbb17094efa Mon Sep 17 00:00:00 2001 From: difanta Date: Sun, 22 Feb 2026 22:20:08 +0100 Subject: [PATCH 10/24] remove leftover log --- .../hegocre/nextcloudpasswords/ui/activities/MainActivity.kt | 2 -- 1 file changed, 2 deletions(-) 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 25db7b50..d7b0512a 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 @@ -63,8 +63,6 @@ class MainActivity : FragmentActivity() { ) { val creator = { structure: AssistStructure -> { label: String, username: String, password: String -> - Log.d("MainActivity", "Replying to autofill request with label: $label, structure: ${structure}") - autofillReply(PasswordAutofillData( id = null, label = label, From 2c6153be8f8debb3d1ea71462085539b58b876d5 Mon Sep 17 00:00:00 2001 From: difanta Date: Sun, 22 Feb 2026 23:01:32 +0100 Subject: [PATCH 11/24] fix the case where master password is required but not saved --- .../services/autofill/NCPAutofillService.kt | 97 ++++++++++--------- 1 file changed, 52 insertions(+), 45 deletions(-) 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 75385b21..8c933be2 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 @@ -47,7 +47,8 @@ import com.hegocre.nextcloudpasswords.utils.SaveData data class ListDecryptionStateNonNullable( val decryptedList: List = emptyList(), - val isLoading: Boolean = false + val isLoading: Boolean = false, + val notAllDecrypted: Boolean = false ) @TargetApi(Build.VERSION_CODES.O) @@ -78,7 +79,9 @@ class NCPAutofillService : AutofillService() { apiController.csEv1Keychain.asFlow() ) { passwords, keychain -> ListDecryptionStateNonNullable(isLoading = true) - ListDecryptionStateNonNullable(passwords.decryptPasswords(keychain), false) + passwords.decryptPasswords(keychain).let { decryptedPasswords -> + ListDecryptionStateNonNullable(decryptedPasswords, false, decryptedPasswords.size < passwords.size) + } } .flowOn(Dispatchers.Default) .stateIn( @@ -144,15 +147,11 @@ class NCPAutofillService : AutofillService() { //Log.d(TAG, "User is logged in") // Try to open Session - //if (!apiController.sessionOpen.value && !apiController.openSession(preferencesManager.getMasterPassword())) { + //if (!apiController.sessionOpen.value && !passwordController.openSession(preferencesManager.getMasterPassword())) { // Log.w(TAG, "Session is not open and cannot be opened") //} //Log.d(TAG, "Session is open") - //if (apiController.sessionOpen.value) { - // passwordController.syncPasswords() - //} - // Determine Search Hint val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) @@ -172,7 +171,11 @@ class NCPAutofillService : AutofillService() { } } - Log.d(TAG, "Passwords filtered and sorted, count: ${filteredList.size}") + // must go to the main app only if there are no passwords to show, and some were not decrypted + val needsAppForMasterPassword = if (filteredList.size == 0) decryptedPasswordsState.value.notAllDecrypted + else false + + Log.d(TAG, "Passwords filtered and sorted, count: ${filteredList.size}, needs input for master password: $needsAppForMasterPassword") val needsAuth = hasAppLock.first() && (isLocked.firstOrNull() ?: true) @@ -181,7 +184,8 @@ class NCPAutofillService : AutofillService() { helper, request, searchHint, - needsAuth + needsAuth, + needsAppForMasterPassword ) } @@ -190,7 +194,8 @@ class NCPAutofillService : AutofillService() { helper: AssistStructureParser, request: FillRequest, searchHint: String, - needsAuth: Boolean + needsAuth: Boolean, + needsAppForMasterPassword: Boolean ): FillResponse { Log.d(TAG, "Building FillResponse with ${passwords.size} passwords, needsAuth: $needsAuth") val builder = FillResponse.Builder() @@ -200,49 +205,51 @@ class NCPAutofillService : AutofillService() { request.inlineSuggestionsRequest } else null - // Add one Dataset for each password - for ((idx, password) in passwords.withIndex()) { - builder.addDataset( - AutofillHelper.buildDataset( - applicationContext, - 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 - ) - ) - } - - 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 = "", - password = "", - url = searchHint - ) - builder.addDataset( + if (!needsAppForMasterPassword) { + // Add one Dataset for each password + for ((idx, password) in passwords.withIndex()) { + builder.addDataset( AutofillHelper.buildDataset( applicationContext, - PasswordAutofillData(label = "Create new password", id = null, username = null, password = null), // TODO: translation + 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, - AutofillHelper.buildIntent(applicationContext, 1002, AutofillData.SaveAutofill(searchHint, saveData, helper.structure)), - false + null, + needsAuth, + idx ) ) } - Log.d(TAG, "Button to create new password added to FillResponse") + 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 = "", + password = "", + url = searchHint + ) + builder.addDataset( + AutofillHelper.buildDataset( + applicationContext, + PasswordAutofillData(label = "Create new password", id = null, username = null, password = null), // TODO: translation + 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)), + false + ) + ) + } + + Log.d(TAG, "Button to create new password added to FillResponse") + } // Option to conclude the autofill in the app builder.addDataset( From f180d9db13c29d341fb3d7fbaf100efb7ce7fe61 Mon Sep 17 00:00:00 2001 From: difanta Date: Wed, 25 Feb 2026 16:51:50 +0100 Subject: [PATCH 12/24] restore build --- app/build.gradle | 2 +- gradle.properties | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 407b83c1..ea16965d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,7 @@ plugins { java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(17) } } diff --git a/gradle.properties b/gradle.properties index c5277e26..44fadca5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,6 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC -org.gradle.java.home=C:\\Program Files\\Java\\latest\\jdk-21 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects From 19a37eadc0fd9bad7dd55733bede528e7d99c9e9 Mon Sep 17 00:00:00 2001 From: difanta Date: Sat, 28 Feb 2026 19:11:06 +0100 Subject: [PATCH 13/24] clean up imports; better label for the "More" button; add isAutofill/isSave interfaces for clarity --- .../services/autofill/AutofillHelper.kt | 2 - .../services/autofill/NCPAutofillService.kt | 20 +-------- .../ui/activities/MainActivity.kt | 28 +++++------- .../ui/components/NCPApp.kt | 2 +- .../ui/components/NCPNavHost.kt | 11 ++--- .../ui/components/PasswordEditView.kt | 2 +- .../nextcloudpasswords/utils/AutofillUtils.kt | 44 ++++++++++++------- 7 files changed, 45 insertions(+), 64 deletions(-) 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 1b3fd301..160f8a82 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 @@ -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 @@ -24,7 +23,6 @@ import com.hegocre.nextcloudpasswords.ui.activities.MainActivity import android.service.autofill.SaveInfo import android.os.Bundle import android.util.Log -import android.view.autofill.AutofillManager import com.hegocre.nextcloudpasswords.utils.AutofillData import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData 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 8c933be2..a16f1d6f 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 @@ -1,8 +1,6 @@ package com.hegocre.nextcloudpasswords.services.autofill import android.annotation.SuppressLint -import android.app.PendingIntent -import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.CancellationSignal @@ -12,21 +10,17 @@ import android.service.autofill.FillRequest import android.service.autofill.FillResponse import android.service.autofill.SaveCallback import android.service.autofill.SaveRequest -import androidx.compose.runtime.collectAsState import android.util.Log import android.annotation.TargetApi import androidx.lifecycle.asFlow import com.hegocre.nextcloudpasswords.api.ApiController import com.hegocre.nextcloudpasswords.data.password.Password import com.hegocre.nextcloudpasswords.data.password.PasswordController -import com.hegocre.nextcloudpasswords.data.password.NewPassword -import com.hegocre.nextcloudpasswords.data.password.UpdatedPassword import com.hegocre.nextcloudpasswords.data.user.UserController import com.hegocre.nextcloudpasswords.data.user.UserException import com.hegocre.nextcloudpasswords.utils.PreferencesManager import com.hegocre.nextcloudpasswords.utils.decryptPasswords import com.hegocre.nextcloudpasswords.utils.AppLockHelper -import com.hegocre.nextcloudpasswords.ui.activities.MainActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -34,22 +28,12 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.CancellationException -import android.content.Context import android.content.IntentSender -import com.hegocre.nextcloudpasswords.utils.encryptValue -import com.hegocre.nextcloudpasswords.utils.sha1Hash -import com.hegocre.nextcloudpasswords.api.FoldersApi import com.hegocre.nextcloudpasswords.utils.AutofillData import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData import com.hegocre.nextcloudpasswords.utils.SaveData - -data class ListDecryptionStateNonNullable( - val decryptedList: List = emptyList(), - val isLoading: Boolean = false, - val notAllDecrypted: Boolean = false -) +import com.hegocre.nextcloudpasswords.utils.ListDecryptionStateNonNullable @TargetApi(Build.VERSION_CODES.O) class NCPAutofillService : AutofillService() { @@ -255,7 +239,7 @@ class NCPAutofillService : AutofillService() { builder.addDataset( AutofillHelper.buildDataset( applicationContext, - PasswordAutofillData(label = ">", id = null, username = null, password = null), // TODO use icon + PasswordAutofillData(label = "More", id = null, username = null, password = null), // TODO translation helper, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, AutofillHelper.buildIntent(applicationContext, 1003, AutofillData.ChoosePwd(searchHint, helper.structure)), 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 d7b0512a..f7cb2387 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 @@ -30,7 +30,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import android.util.Log import com.hegocre.nextcloudpasswords.utils.AutofillData import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData @@ -60,23 +59,18 @@ class MainActivity : FragmentActivity() { val replyAutofill: ((String, String, String) -> Unit)? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - ) { - val creator = { structure: AssistStructure -> - { label: String, username: String, password: String -> - autofillReply(PasswordAutofillData( - id = null, - label = label, - username = username, - password = password - ), structure) - } - } - + ) { when (autofillData) { - is AutofillData.FromId -> creator(autofillData.structure) - is AutofillData.ChoosePwd -> creator(autofillData.structure) - is AutofillData.SaveAutofill -> creator(autofillData.structure) - else -> null + is AutofillData.isAutofill -> + { label: String, username: String, password: String -> + autofillReply(PasswordAutofillData( + id = null, + label = label, + username = username, + password = password + ), autofillData.structure) + } + else -> null } } else null diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPApp.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPApp.kt index 2d7cb769..f294b3ed 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPApp.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPApp.kt @@ -56,8 +56,8 @@ import com.hegocre.nextcloudpasswords.api.FoldersApi import com.hegocre.nextcloudpasswords.ui.NCPScreen import com.hegocre.nextcloudpasswords.ui.theme.NextcloudPasswordsTheme import com.hegocre.nextcloudpasswords.ui.viewmodels.PasswordsViewModel -import kotlinx.coroutines.launch import com.hegocre.nextcloudpasswords.utils.AutofillData +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt index b6540ea6..8735cca5 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt @@ -20,15 +20,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -57,11 +56,10 @@ import com.hegocre.nextcloudpasswords.utils.decryptFolders import com.hegocre.nextcloudpasswords.utils.decryptPasswords import com.hegocre.nextcloudpasswords.utils.encryptValue import com.hegocre.nextcloudpasswords.utils.sha1Hash +import com.hegocre.nextcloudpasswords.utils.AutofillData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import com.hegocre.nextcloudpasswords.utils.AutofillData -import androidx.compose.runtime.saveable.rememberSaveable @ExperimentalMaterial3Api @Composable @@ -487,10 +485,7 @@ fun NCPNavHost( folder = passwordsViewModel.visibleFolder.value?.id ?: folder } when (autofillData) { - is AutofillData.SaveAutofill, is AutofillData.Save -> { - // workaround as the compiler not allow accessing saveData in this dual branch for some reason - val saveData = (autofillData as? AutofillData.Save)?.saveData ?: (autofillData as AutofillData.SaveAutofill).saveData - + is AutofillData.isSave -> { if (selectedPassword == null) { label = saveData.label username = saveData.username diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/PasswordEditView.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/PasswordEditView.kt index 2dd3c946..a1383b7b 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/PasswordEditView.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/PasswordEditView.kt @@ -66,12 +66,12 @@ import com.hegocre.nextcloudpasswords.ui.theme.NextcloudPasswordsTheme import com.hegocre.nextcloudpasswords.ui.theme.favoriteColor import com.hegocre.nextcloudpasswords.utils.isValidEmail import com.hegocre.nextcloudpasswords.utils.isValidURL +import com.hegocre.nextcloudpasswords.utils.AutofillData import kotlinx.coroutines.Deferred import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlin.reflect.KFunction3 -import com.hegocre.nextcloudpasswords.utils.AutofillData class EditablePasswordState(originalPassword: Password?) { var password by mutableStateOf(originalPassword?.password ?: "") 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 959f46a5..7ebf1bd2 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt @@ -1,6 +1,5 @@ package com.hegocre.nextcloudpasswords.utils -import android.os.Parcel import android.os.Parcelable import android.app.assist.AssistStructure import kotlinx.parcelize.Parcelize @@ -16,45 +15,56 @@ data class SaveData( ) : Parcelable {} sealed class AutofillData : Parcelable { + interface isAutofill { + val structure: AssistStructure + } + + interface isSave { + val saveData: SaveData + } + @Parcelize data class FromId( val id: String, - val structure: AssistStructure - ) : AutofillData() + override val structure: AssistStructure + ) : AutofillData(), isAutofill @Parcelize data class ChoosePwd( val searchHint: String, - val structure: AssistStructure - ) : AutofillData() + override val structure: AssistStructure + ) : AutofillData(), isAutofill @Parcelize data class SaveAutofill( val searchHint: String, - val saveData: SaveData, - val structure: AssistStructure, - ) : AutofillData() + override val saveData: SaveData, + override val structure: AssistStructure, + ) : AutofillData(), isAutofill, isSave @Parcelize data class Save( val searchHint: String, - val saveData: SaveData - ) : AutofillData() + override val saveData: SaveData + ) : AutofillData(), isSave fun isAutofill(): Boolean { return when (this) { - is FromId -> true - is ChoosePwd -> true - is SaveAutofill -> true - is Save -> false + is isAutofill -> true + else -> false } } fun isSave(): Boolean { return when (this) { - is SaveAutofill -> true - is Save -> true + is isSave -> true else -> false } } -} \ No newline at end of file +} + +data class ListDecryptionStateNonNullable( + val decryptedList: List = emptyList(), + val isLoading: Boolean = false, + val notAllDecrypted: Boolean = false +) \ No newline at end of file From dfbcd6ff3cf487607b0ee0740ecd5533959981cc Mon Sep 17 00:00:00 2001 From: difanta Date: Sat, 28 Feb 2026 19:24:25 +0100 Subject: [PATCH 14/24] prevent intent code conflicts --- .../nextcloudpasswords/services/autofill/AutofillHelper.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 160f8a82..3f9089ad 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 @@ -139,7 +139,7 @@ object AutofillHelper { inlinePresentationSpec ) } - setAuthentication(buildIntent(context, 1004+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) + setAuthentication(buildIntent(context, 1005+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) }.build() } else { Dataset.Builder().apply { @@ -183,7 +183,7 @@ object AutofillHelper { helper.passwordAutofillIds.forEach { autofillId -> addAutofillValue(context, autofillId, password.label, null) } - setAuthentication(buildIntent(context, 1004+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) + setAuthentication(buildIntent(context, 1005+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) }.build() } else { Dataset.Builder().apply { From fd4199b2041b6c9873420fea2f238313015b96d6 Mon Sep 17 00:00:00 2001 From: difanta Date: Sat, 28 Feb 2026 19:41:44 +0100 Subject: [PATCH 15/24] cleanup autofill service; decrypt passwords lazily to save battery --- .../services/autofill/NCPAutofillService.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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 a16f1d6f..644e8258 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 @@ -62,7 +62,6 @@ class NCPAutofillService : AutofillService() { passwordController.getPasswords().asFlow(), apiController.csEv1Keychain.asFlow() ) { passwords, keychain -> - ListDecryptionStateNonNullable(isLoading = true) passwords.decryptPasswords(keychain).let { decryptedPasswords -> ListDecryptionStateNonNullable(decryptedPasswords, false, decryptedPasswords.size < passwords.size) } @@ -70,7 +69,7 @@ class NCPAutofillService : AutofillService() { .flowOn(Dispatchers.Default) .stateIn( scope = serviceScope, - started = SharingStarted.Eagerly, + started = SharingStarted.Lazily, initialValue = ListDecryptionStateNonNullable(isLoading = true) ) } @@ -96,8 +95,7 @@ class NCPAutofillService : AutofillService() { } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Error handling fill request: ${e.message}") - callback.onSuccess(null) + callback.onFailure("Error handling fill request: ${e.message}") } } @@ -282,7 +280,7 @@ class NCPAutofillService : AutofillService() { override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val job = serviceScope.launch { + serviceScope.launch { try { val intent: IntentSender? = withContext(Dispatchers.Default) { processSaveRequest(request) @@ -336,7 +334,6 @@ class NCPAutofillService : AutofillService() { companion object { const val TAG = "NCPAutofillService" - private const val TIMEOUT_MS = 2000L const val AUTOFILL_DATA = "autofill_data" } } \ No newline at end of file From 4d489825c8119518dd7fb309cb87fd8f36c39677 Mon Sep 17 00:00:00 2001 From: difanta Date: Sat, 28 Feb 2026 19:42:57 +0100 Subject: [PATCH 16/24] cleanup NavHost; undo manual state management --- .../ui/components/NCPNavHost.kt | 32 ++++++++----------- .../nextcloudpasswords/utils/AutofillUtils.kt | 2 +- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt index 8735cca5..50207fbe 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable @@ -86,13 +85,11 @@ fun NCPNavHost( val serverSettings by passwordsViewModel.serverSettings.observeAsState(initial = ServerSettings()) val sessionOpen by passwordsViewModel.sessionOpen.collectAsState() - var passwordsDecryptionState by remember { - mutableStateOf(ListDecryptionState(isLoading = true)) - } - - LaunchedEffect(passwords, keychain) { - passwordsDecryptionState = ListDecryptionState(isLoading = true) - passwordsDecryptionState = ListDecryptionState(decryptedList = passwords?.decryptPasswords(keychain) ?: emptyList()) + val passwordsDecryptionState by produceState( + initialValue = ListDecryptionState(isLoading = true), + key1 = passwords, key2 = keychain + ) { + value = ListDecryptionState(decryptedList = passwords?.decryptPasswords(keychain) ?: emptyList()) } val foldersDecryptionState by produceState( @@ -487,17 +484,17 @@ fun NCPNavHost( when (autofillData) { is AutofillData.isSave -> { if (selectedPassword == null) { - label = saveData.label - username = saveData.username - password = saveData.password - url = saveData.url + label = autofillData.saveData.label + username = autofillData.saveData.username + password = autofillData.saveData.password + url = autofillData.saveData.url } else { // prioritize existing label and url fields - label = if(label.isNullOrBlank()) saveData.label else label - url = if(url.isNullOrBlank()) saveData.url else url + label = if(label.isNullOrBlank()) autofillData.saveData.label else label + url = if(url.isNullOrBlank()) autofillData.saveData.url else url // prioritize new username and password fields - username = if(saveData.username.isNullOrBlank()) username else saveData.username - password = if(saveData.password.isNullOrBlank()) password else saveData.password + username = if(autofillData.saveData.username.isNullOrBlank()) username else autofillData.saveData.username + password = if(autofillData.saveData.password.isNullOrBlank()) password else autofillData.saveData.password } } is AutofillData.ChoosePwd -> { @@ -587,7 +584,6 @@ fun NCPNavHost( editablePasswordState.password ) } else { - passwordsDecryptionState = ListDecryptionState(isLoading = true) navController.navigateUp() } } else { @@ -668,7 +664,6 @@ fun NCPNavHost( editablePasswordState.password ) } else { - passwordsDecryptionState = ListDecryptionState(isLoading = true) navController.navigateUp() } } else { @@ -692,7 +687,6 @@ fun NCPNavHost( if (passwordsViewModel.deletePassword(deletedPassword) .await() ) { - passwordsDecryptionState = ListDecryptionState(isLoading = true) navController.navigateUp() } else { Toast.makeText( 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 7ebf1bd2..260997b8 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt @@ -12,7 +12,7 @@ data class SaveData( val username: String, val password: String, val url: String, -) : Parcelable {} +) : Parcelable sealed class AutofillData : Parcelable { interface isAutofill { From 2544c1c59c24f1dda0d2dccc7fef254b582d20a7 Mon Sep 17 00:00:00 2001 From: difanta Date: Sat, 28 Feb 2026 19:45:25 +0100 Subject: [PATCH 17/24] do not autofill textual fields as username --- .../services/autofill/AssistStructureParser.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 4ac59f32..e54425d9 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 @@ -133,7 +133,7 @@ class AssistStructureParser(assistStructure: AssistStructure) { 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") || @@ -157,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 } From 3b4083121edfbe2769656140cbc2cb32f4d90477 Mon Sep 17 00:00:00 2001 From: difanta Date: Thu, 2 Apr 2026 20:20:07 +0200 Subject: [PATCH 18/24] add translations for "More" button --- .../services/autofill/NCPAutofillService.kt | 4 ++-- app/src/main/res/values-ca-rES/strings.xml | 1 + app/src/main/res/values-cs-rCZ/strings.xml | 1 + app/src/main/res/values-de-rDE/strings.xml | 1 + app/src/main/res/values-en-rUS/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-et-rEE/strings.xml | 1 + app/src/main/res/values-fr-rFR/strings.xml | 1 + app/src/main/res/values-pl-rPL/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-tr-rTR/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 13 files changed, 14 insertions(+), 2 deletions(-) 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 644e8258..fe285cef 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 @@ -221,7 +221,7 @@ class NCPAutofillService : AutofillService() { builder.addDataset( AutofillHelper.buildDataset( applicationContext, - PasswordAutofillData(label = "Create new password", id = null, username = null, password = null), // TODO: translation + 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)), @@ -237,7 +237,7 @@ class NCPAutofillService : AutofillService() { builder.addDataset( AutofillHelper.buildDataset( applicationContext, - PasswordAutofillData(label = "More", id = null, username = null, password = null), // TODO translation + 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)), diff --git a/app/src/main/res/values-ca-rES/strings.xml b/app/src/main/res/values-ca-rES/strings.xml index 5186c3fa..1727ac96 100644 --- a/app/src/main/res/values-ca-rES/strings.xml +++ b/app/src/main/res/values-ca-rES/strings.xml @@ -64,6 +64,7 @@ Estat de seguretat Icona de la carpeta Nova contrasenya + Més Menú Netejar els termes de cerca Generar contrasenya diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml index cfc5ed01..1f9a33e2 100644 --- a/app/src/main/res/values-cs-rCZ/strings.xml +++ b/app/src/main/res/values-cs-rCZ/strings.xml @@ -64,6 +64,7 @@ Status zabezpečení Ikona složky Nové heslo + Více Menu Vymazat hledaný dotaz Vygenerovat heslo diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 05845f30..8ef4486d 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -64,6 +64,7 @@ Sicherheitsstatus Ordnersymbol Neues Passwort + Mehr Menü Suche löschen Passwort erzeugen diff --git a/app/src/main/res/values-en-rUS/strings.xml b/app/src/main/res/values-en-rUS/strings.xml index e670eaef..aa06972a 100644 --- a/app/src/main/res/values-en-rUS/strings.xml +++ b/app/src/main/res/values-en-rUS/strings.xml @@ -64,6 +64,7 @@ Security status Folder icon New password + More Menu Clear search query Generate password diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 2ebb8f9a..793083a2 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -64,6 +64,7 @@ Estado de seguridad Icono de la carpeta Nueva contraseña + Más Menú Limpiar los terminos de búsqueda Generar contraseña diff --git a/app/src/main/res/values-et-rEE/strings.xml b/app/src/main/res/values-et-rEE/strings.xml index f504c389..b0449ce8 100644 --- a/app/src/main/res/values-et-rEE/strings.xml +++ b/app/src/main/res/values-et-rEE/strings.xml @@ -64,6 +64,7 @@ Turvalisuse olek Kausta ikoon Uus parool + Rohkem Menüü Puhasta otsingu päring Genereeri parool diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index f537b303..30411814 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -64,6 +64,7 @@ État de la sécurité Icône de dossier Nouveau mot de passe + Plus Menu Effacer la recherche Générer un mot de passe diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 347430e4..3e98943d 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -64,6 +64,7 @@ Status bezpieczeństwa Ikona folderu Nowe hasło + Więcej Menu Wyczyść wyszukiwanie Generuj hasło diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 010f30f8..12366ae5 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -64,6 +64,7 @@ Безопасность Иконка папки Новый пароль + Более Меню Очистить поисковый запрос Сгенерировать пароль diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index 6fc30ec4..4f5c4624 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -64,6 +64,7 @@ Güvenlik durumu Klasör simgesi Yeni şifre + Daha Menü Arama sorgusunu temizle Şifre üret diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 655b6436..26c0e448 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -64,6 +64,7 @@ 安全狀態 資料夾圖示 新密碼 + 更多的 選單 清除搜尋 產生密碼 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c5ab6ce..c696e702 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,6 +63,7 @@ Security status Folder icon New password + More Menu Clear search query Generate password From 402b44342ffb868cfe9398715ab26b7926b69498 Mon Sep 17 00:00:00 2001 From: difanta Date: Thu, 2 Apr 2026 20:20:33 +0200 Subject: [PATCH 19/24] remove unused getAttribute --- .../services/autofill/AssistStructureParser.kt | 9 --------- 1 file changed, 9 deletions(-) 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 e54425d9..2ff10fd8 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 @@ -175,15 +175,6 @@ class AssistStructureParser(assistStructure: AssistStructure) { private fun AssistStructure.ViewNode?.hasAttribute(attr: String, value: String): Boolean = this?.htmlInfo?.attributes?.firstOrNull { it.first.lowercase() == attr && it.second.lowercase() == value } != null - /** - * Retrieve a HTML attribute value from a view node. - * - * @param attr The attribute to retrieve. - * @return The retrieved attribute value, or null if not found. - */ - private fun AssistStructure.ViewNode?.getAttribute(attr: String): String? = - this?.htmlInfo?.attributes?.firstOrNull { it.first.lowercase() == attr }?.second - /** * Check if a text field matches the [InputType.TYPE_CLASS_TEXT] input type. * From 53b7e5950f7e8a0180b3894d3e3a75655c397d6a Mon Sep 17 00:00:00 2001 From: difanta Date: Thu, 2 Apr 2026 20:21:20 +0200 Subject: [PATCH 20/24] fix null/empty checks --- .../services/autofill/AutofillHelper.kt | 10 +++++----- .../nextcloudpasswords/ui/components/NCPNavHost.kt | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) 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 3f9089ad..28af3c54 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 @@ -71,20 +71,20 @@ object AutofillHelper { Log.d(NCPAutofillService.TAG, "Required IDs: $requiredIds, Optional IDs: $optionalIds") - val type = if (!helper.usernameAutofillIds.isEmpty()) { + 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.isEmpty()) { + 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.isEmpty() && helper.passwordAutofillIds.isEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + 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 { @@ -94,11 +94,11 @@ object AutofillHelper { putCharSequence(USERNAME, helper.usernameAutofillContent.firstOrNull() ?: "") } ) - } else if (!helper.passwordAutofillIds.isEmpty()) { + } else if (helper.passwordAutofillIds.isNotEmpty()) { return Pair( builder.apply { setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE) - if (!optionalIds.isEmpty()) setOptionalIds(optionalIds.toTypedArray()) + if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds.toTypedArray()) }.build(), null ) diff --git a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt index 50207fbe..e1ddac54 100644 --- a/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt @@ -197,7 +197,7 @@ fun NCPNavHost( isRefreshing = isRefreshing, onRefresh = { passwordsViewModel.sync() }, ) { - if (filteredPasswordList == null || filteredPasswordList.isEmpty() == true) { + if (filteredPasswordList.isNullOrEmpty()) { if (searchQuery.isBlank()) NoContentText() else NoResultsText() } else if (replyAutofill != null) { // Reply to the autofill right away without showing any UI @@ -234,7 +234,7 @@ fun NCPNavHost( isRefreshing = isRefreshing, onRefresh = { passwordsViewModel.sync() }, ) { - if (filteredPasswordList == null || filteredPasswordList.isEmpty() == true) { + if (filteredPasswordList.isNullOrEmpty()) { // TODO: open automatically a new password or the only updatable password if isSave //if (sessionOpen && autofillData != null && autofillData.isSave()) // navController.navigate("${NCPScreen.PasswordEdit.name}/") From 19ce435f96c3ec8224ab8b10f073e62ce5c54839 Mon Sep 17 00:00:00 2001 From: difanta Date: Thu, 2 Apr 2026 21:25:22 +0200 Subject: [PATCH 21/24] fix import translations --- .../nextcloudpasswords/services/autofill/NCPAutofillService.kt | 1 + 1 file changed, 1 insertion(+) 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 fe285cef..4c0fa9bd 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 @@ -34,6 +34,7 @@ import com.hegocre.nextcloudpasswords.utils.AutofillData import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData import com.hegocre.nextcloudpasswords.utils.SaveData import com.hegocre.nextcloudpasswords.utils.ListDecryptionStateNonNullable +import com.hegocre.nextcloudpasswords.R @TargetApi(Build.VERSION_CODES.O) class NCPAutofillService : AutofillService() { From ead1304e13f71d33265e4231763c65af336517b2 Mon Sep 17 00:00:00 2001 From: difanta Date: Fri, 3 Apr 2026 00:46:18 +0200 Subject: [PATCH 22/24] improve lock check; remove some information from logs --- .../services/autofill/NCPAutofillService.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 4c0fa9bd..c163e941 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 @@ -55,8 +55,6 @@ class NCPAutofillService : AutofillService() { private lateinit var decryptedPasswordsState: StateFlow> - private val passwordsDecrypted = MutableStateFlow(false) - override fun onCreate() { super.onCreate() decryptedPasswordsState = combine( @@ -158,9 +156,9 @@ class NCPAutofillService : AutofillService() { val needsAppForMasterPassword = if (filteredList.size == 0) decryptedPasswordsState.value.notAllDecrypted else false - Log.d(TAG, "Passwords filtered and sorted, count: ${filteredList.size}, needs input for master password: $needsAppForMasterPassword") + Log.d(TAG, "Passwords filtered and sorted") - val needsAuth = hasAppLock.first() && (isLocked.firstOrNull() ?: true) + val needsAuth = hasAppLock.first() && isLocked.value return buildFillResponse( filteredList, @@ -180,7 +178,7 @@ class NCPAutofillService : AutofillService() { needsAuth: Boolean, needsAppForMasterPassword: Boolean ): FillResponse { - Log.d(TAG, "Building FillResponse with ${passwords.size} passwords, needsAuth: $needsAuth") + Log.d(TAG, "Building FillResponse, needsAuth: $needsAuth") val builder = FillResponse.Builder() val useInline = preferencesManager.getUseInlineAutofill() From a931cb2e5fd527fa244f541ef6e0066dab49415b Mon Sep 17 00:00:00 2001 From: difanta Date: Fri, 3 Apr 2026 01:40:30 +0200 Subject: [PATCH 23/24] handle exception thrown in construction of ApiController if not logged in; specialize Exception to be thrown; fix a race condition in the decryptedPasswordState flow --- .../services/autofill/NCPAutofillService.kt | 70 +++++++++---------- 1 file changed, 33 insertions(+), 37 deletions(-) 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 c163e941..ca36c501 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 @@ -28,6 +28,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow import java.util.concurrent.CancellationException import android.content.IntentSender import com.hegocre.nextcloudpasswords.utils.AutofillData @@ -54,23 +55,31 @@ class NCPAutofillService : AutofillService() { val strictUrlMatching by lazy { preferencesManager.getUseStrictUrlMatching() } private lateinit var decryptedPasswordsState: StateFlow> + + private var loginException: Throwable? = null override fun onCreate() { super.onCreate() - decryptedPasswordsState = combine( - passwordController.getPasswords().asFlow(), - apiController.csEv1Keychain.asFlow() - ) { passwords, keychain -> - passwords.decryptPasswords(keychain).let { decryptedPasswords -> - ListDecryptionStateNonNullable(decryptedPasswords, false, decryptedPasswords.size < passwords.size) + + try { + decryptedPasswordsState = combine( + passwordController.getPasswords().asFlow(), + apiController.csEv1Keychain.asFlow() + ) { passwords, keychain -> + passwords.decryptPasswords(keychain).let { decryptedPasswords -> + ListDecryptionStateNonNullable(decryptedPasswords, false, decryptedPasswords.size < passwords.size) + } } + .flowOn(Dispatchers.Default) + .stateIn( + scope = serviceScope, + started = SharingStarted.Lazily, + initialValue = ListDecryptionStateNonNullable(isLoading = true) + ) + } catch(e: Throwable) { + loginException = e + decryptedPasswordsState = MutableStateFlow(ListDecryptionStateNonNullable(isLoading = false)) } - .flowOn(Dispatchers.Default) - .stateIn( - scope = serviceScope, - started = SharingStarted.Lazily, - initialValue = ListDecryptionStateNonNullable(isLoading = true) - ) } override fun onDestroy() { @@ -93,7 +102,7 @@ class NCPAutofillService : AutofillService() { else callback.onFailure("Could not complete fill request") } catch (e: CancellationException) { throw e - } catch (e: Exception) { + } catch (e: Throwable) { callback.onFailure("Error handling fill request: ${e.message}") } } @@ -104,6 +113,10 @@ class NCPAutofillService : AutofillService() { } 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) @@ -116,32 +129,15 @@ class NCPAutofillService : AutofillService() { return null } - // TODO: when to sync with server? - // Check Login Status - //try { - // userController.getServer() - //} catch (_: UserException) { - // Log.e(TAG, "User not logged in, cannot autofill") - // return null - //} - - //Log.d(TAG, "User is logged in") - - // Try to open Session - //if (!apiController.sessionOpen.value && !passwordController.openSession(preferencesManager.getMasterPassword())) { - // Log.w(TAG, "Session is not open and cannot be opened") - //} - //Log.d(TAG, "Session is open") - // Determine Search Hint val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) Log.d(TAG, "Search hint determined: $searchHint") // wait for passwords to be decrypted, then filter by search hint and sort them - decryptedPasswordsState.first { !it.isLoading } + val currentState = decryptedPasswordsState.first { !it.isLoading } - val filteredList = decryptedPasswordsState.value.decryptedList.filter { + val filteredList = currentState.decryptedList.filter { !it.hidden && !it.trashed && it.matches(searchHint, strictUrlMatching.first()) }.let { list -> when (orderBy.first()) { @@ -153,7 +149,7 @@ class NCPAutofillService : AutofillService() { } // must go to the main app only if there are no passwords to show, and some were not decrypted - val needsAppForMasterPassword = if (filteredList.size == 0) decryptedPasswordsState.value.notAllDecrypted + val needsAppForMasterPassword = if (filteredList.isEmpty()) currentState.notAllDecrypted else false Log.d(TAG, "Passwords filtered and sorted") @@ -288,7 +284,7 @@ class NCPAutofillService : AutofillService() { else callback.onFailure("Unable to complete Save Request") } catch (e: CancellationException) { throw e - } catch (e: Exception) { + } catch (e: Throwable) { callback.onFailure("Error handling save request: ${e.message}") } } @@ -310,19 +306,19 @@ class NCPAutofillService : AutofillService() { val password: String = helper.passwordAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: "" if (password.isBlank()) { - throw Exception("Blank password, cannot save") + throw IllegalArgumentException("Blank password, cannot save") } // Check Login Status try { userController.getServer() } catch (_: UserException) { - throw Exception("User not logged in, cannot save") + throw IllegalStateException("User not logged in, cannot save") } // Ensure Session is open if (!apiController.sessionOpen.value && !apiController.openSession(preferencesManager.getMasterPassword())) { - throw Exception("Session is not open and cannot be opened, cannot save") + throw IllegalStateException("Session is not open and cannot be opened, cannot save") } // Determine Search Hint From ec8ef8ba1a3a88de034cc6005ccda34a582fe924 Mon Sep 17 00:00:00 2001 From: difanta Date: Fri, 3 Apr 2026 02:03:02 +0200 Subject: [PATCH 24/24] plug back cheap login check --- .../services/autofill/NCPAutofillService.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 ca36c501..88dc46ba 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 @@ -129,6 +129,13 @@ class NCPAutofillService : AutofillService() { return null } + // Check Login Status + try { + userController.getServer() + } catch (_: UserException) { + throw IllegalStateException("User not logged in, cannot autofill") + } + // Determine Search Hint val searchHint = helper.webDomain ?: getAppLabel(helper.packageName)