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/AssistStructureParser.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/services/autofill/AssistStructureParser.kt index 1e899c6c..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 @@ -20,9 +20,14 @@ import com.hegocre.nextcloudpasswords.BuildConfig class AssistStructureParser(assistStructure: AssistStructure) { val usernameAutofillIds = mutableListOf() val passwordAutofillIds = mutableListOf() + val usernameAutofillContent = mutableListOf() + val passwordAutofillContent = mutableListOf() private var lastTextAutofillId: AutofillId? = null + private var lastTextAutofillContent: String? = null private var candidateTextAutofillId: AutofillId? = null + val structure = assistStructure + private val webDomains = HashMap() val packageName = assistStructure.activityComponent.flattenToShortString().substringBefore("/") @@ -40,6 +45,7 @@ class AssistStructureParser(assistStructure: AssistStructure) { if (usernameAutofillIds.isEmpty()) candidateTextAutofillId?.let { usernameAutofillIds.add(it) + usernameAutofillContent.add(lastTextAutofillContent) } } @@ -55,15 +61,18 @@ class AssistStructureParser(assistStructure: AssistStructure) { when (fieldType) { FIELD_TYPE_USERNAME -> { usernameAutofillIds.add(autofillId) + usernameAutofillContent.add(node.text.toString()) } FIELD_TYPE_PASSWORD -> { passwordAutofillIds.add(autofillId) + passwordAutofillContent.add(node.text.toString()) // We save the autofillId of the field above the password field, // in case we don't find any explicit username field candidateTextAutofillId = lastTextAutofillId } FIELD_TYPE_TEXT -> { lastTextAutofillId = autofillId + lastTextAutofillContent = node.text.toString() } } } @@ -104,17 +113,27 @@ class AssistStructureParser(assistStructure: AssistStructure) { // Get by autofill hint node.autofillHints?.forEach { hint -> - if (hint == View.AUTOFILL_HINT_USERNAME || hint == View.AUTOFILL_HINT_EMAIL_ADDRESS) { + if (hint == View.AUTOFILL_HINT_USERNAME || + hint == View.AUTOFILL_HINT_EMAIL_ADDRESS || + hint.contains("user", true) || + hint.contains("mail", true) + ) { return FIELD_TYPE_USERNAME - } else if (hint == View.AUTOFILL_HINT_PASSWORD) { + } else if (hint == View.AUTOFILL_HINT_PASSWORD || hint.contains("password", true)) { return FIELD_TYPE_PASSWORD } } // Get by HTML attributes + if (node.hasAttribute("type", "password") || + node.hasAttribute("name", "password") + ) { + return FIELD_TYPE_PASSWORD + } + if (node.hasAttribute("type", "email") || node.hasAttribute("type", "tel") || - node.hasAttribute("type", "text") || + //node.hasAttribute("type", "text") || node.hasAttribute("name", "email") || node.hasAttribute("name", "mail") || node.hasAttribute("name", "user") || @@ -122,13 +141,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 } @@ -141,6 +157,10 @@ class AssistStructureParser(assistStructure: AssistStructure) { if (node.inputType.isTextType()) { return FIELD_TYPE_TEXT } + + if (node.hasAttribute("type", "text")) { + return FIELD_TYPE_TEXT + } } return null } @@ -153,7 +173,7 @@ class AssistStructureParser(assistStructure: AssistStructure) { * @return Whether the value of the provided attribute matches the provided value. */ private fun AssistStructure.ViewNode?.hasAttribute(attr: String, value: String): Boolean = - this?.htmlInfo?.attributes?.firstOrNull { it.first == attr && it.second == value } != null + this?.htmlInfo?.attributes?.firstOrNull { it.first.lowercase() == attr && it.second.lowercase() == value } != null /** * Check if a text field matches the [InputType.TYPE_CLASS_TEXT] input type. 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..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 @@ -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 @@ -20,50 +19,114 @@ import android.widget.inline.InlinePresentationSpec import androidx.annotation.RequiresApi import androidx.autofill.inline.v1.InlineSuggestionUi import com.hegocre.nextcloudpasswords.R +import com.hegocre.nextcloudpasswords.ui.activities.MainActivity +import android.service.autofill.SaveInfo +import android.os.Bundle +import android.util.Log +import com.hegocre.nextcloudpasswords.utils.AutofillData +import com.hegocre.nextcloudpasswords.utils.PasswordAutofillData +@RequiresApi(Build.VERSION_CODES.O) object AutofillHelper { - @RequiresApi(Build.VERSION_CODES.O) fun buildDataset( context: Context, - password: Triple?, - assistStructure: AssistStructure, + password: PasswordAutofillData?, + helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec?, - authenticationIntent: IntentSender? = null + intent: IntentSender? = null, + needsAppLock: Boolean = false, + datasetIdx: Int = 0 ): Dataset { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (inlinePresentationSpec != null) { buildInlineDataset( context, password, - assistStructure, + helper, inlinePresentationSpec, - authenticationIntent + intent, + needsAppLock, + datasetIdx ) } else { - buildPresentationDataset(context, password, assistStructure, authenticationIntent) + buildPresentationDataset(context, password, helper, intent, needsAppLock, datasetIdx) } } else { - buildPresentationDataset(context, password, assistStructure, authenticationIntent) + buildPresentationDataset(context, password, helper, intent, needsAppLock, datasetIdx) + } + } + + @RequiresApi(Build.VERSION_CODES.P) + fun buildSaveInfo(helper: AssistStructureParser): Pair? { + 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.isNotEmpty()) { + SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD + } else { + SaveInfo.SAVE_DATA_TYPE_PASSWORD + } + + val builder = if (requiredIds.isNotEmpty()) { + SaveInfo.Builder(type, requiredIds.toTypedArray()) + } else { + SaveInfo.Builder(type) + } + + // if there are only username views but no password views, then delay the save on supported devices + if(helper.usernameAutofillIds.isNotEmpty() && helper.passwordAutofillIds.isEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Log.d(NCPAutofillService.TAG, "Delaying save because only username views are detected") + return Pair( + builder.apply { + setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE or SaveInfo.FLAG_DELAY_SAVE) + }.build(), + Bundle().apply { + putCharSequence(USERNAME, helper.usernameAutofillContent.firstOrNull() ?: "") + } + ) + } else if (helper.passwordAutofillIds.isNotEmpty()) { + return Pair( + builder.apply { + setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE) + if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds.toTypedArray()) + }.build(), + null + ) + } else { + // if not delaying save and no password views, do not save + return null } } @RequiresApi(Build.VERSION_CODES.R) private fun buildInlineDataset( context: Context, - password: Triple?, - assistStructure: AssistStructure, + password: PasswordAutofillData?, + helper: AssistStructureParser, inlinePresentationSpec: InlinePresentationSpec, - authenticationIntent: IntentSender? = null + intent: IntentSender? = null, + needsAppLock: Boolean = false, + datasetIdx: Int ): Dataset { - val helper = AssistStructureParser(assistStructure) - return Dataset.Builder() - .apply { + // build redacted dataset when app lock is needed + return if (needsAppLock && password?.id != null) { + Dataset.Builder().apply { helper.usernameAutofillIds.forEach { autofillId -> addInlineAutofillValue( context, autofillId, - password?.first, - password?.second, + password.label, + null, inlinePresentationSpec ) } @@ -71,40 +134,71 @@ object AutofillHelper { addInlineAutofillValue( context, autofillId, - password?.first, - password?.third, + password.label, + null, inlinePresentationSpec ) } - if (authenticationIntent != null) { - setAuthentication(authenticationIntent) + setAuthentication(buildIntent(context, 1005+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) + }.build() + } else { + Dataset.Builder().apply { + helper.usernameAutofillIds.forEach { autofillId -> + addInlineAutofillValue( + context, + autofillId, + password?.label, + password?.username, + inlinePresentationSpec + ) + } + helper.passwordAutofillIds.forEach { autofillId -> + addInlineAutofillValue( + context, + autofillId, + password?.label, + password?.password, + inlinePresentationSpec + ) } + intent?.let { setAuthentication(it) } }.build() + } } - @RequiresApi(Build.VERSION_CODES.O) private fun buildPresentationDataset( context: Context, - password: Triple?, - assistStructure: AssistStructure, - authenticationIntent: IntentSender? = null + password: PasswordAutofillData?, + helper: AssistStructureParser, + intent: IntentSender? = null, + needsAppLock: Boolean = false, + datasetIdx: Int ): Dataset { - val helper = AssistStructureParser(assistStructure) - return Dataset.Builder().apply { - helper.usernameAutofillIds.forEach { autofillId -> - addAutofillValue(context, autofillId, password?.first, password?.second) - } - helper.passwordAutofillIds.forEach { autofillId -> - addAutofillValue(context, autofillId, password?.first, password?.third) - } - if (authenticationIntent != null) { - setAuthentication(authenticationIntent) - } - }.build() + // build redacted dataset when app lock is needed + return if (needsAppLock && password?.id != null) { + Dataset.Builder().apply { + helper.usernameAutofillIds.forEach { autofillId -> + addAutofillValue(context, autofillId, password.label, null) + } + helper.passwordAutofillIds.forEach { autofillId -> + addAutofillValue(context, autofillId, password.label, null) + } + setAuthentication(buildIntent(context, 1005+datasetIdx, AutofillData.FromId(id=password.id, structure=helper.structure))) + }.build() + } else { + Dataset.Builder().apply { + helper.usernameAutofillIds.forEach { autofillId -> + addAutofillValue(context, autofillId, password?.label, password?.username) + } + helper.passwordAutofillIds.forEach { autofillId -> + addAutofillValue(context, autofillId, password?.label, password?.password) + } + intent?.let { setAuthentication(it) } + }.build() + } } @SuppressLint("RestrictedApi") - @RequiresApi(Build.VERSION_CODES.O) private fun Dataset.Builder.addAutofillValue( context: Context, autofillId: AutofillId, @@ -154,9 +248,8 @@ object AutofillHelper { ) { val autofillLabel = label ?: context.getString(R.string.app_name) - val authIntent = Intent().apply { + val authIntent = Intent(AUTOFILL_INTENT_ID).apply { setPackage(context.packageName) - identifier = AUTOFILL_INTENT_ID } val intentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -213,5 +306,18 @@ object AutofillHelper { } } + fun buildIntent(context: Context, code: Int, autofillData: AutofillData): IntentSender { + val appIntent = Intent(context, MainActivity::class.java).apply { + putExtra(NCPAutofillService.AUTOFILL_DATA, autofillData) + } + + val intentFlags = PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + + return PendingIntent.getActivity( + context, code, appIntent, intentFlags + ).intentSender + } + private const val AUTOFILL_INTENT_ID = "com.hegocre.nextcloudpasswords.intents.autofill" + const val USERNAME = "username" } \ 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..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 @@ -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,132 +10,332 @@ import android.service.autofill.FillRequest import android.service.autofill.FillResponse import android.service.autofill.SaveCallback import android.service.autofill.SaveRequest -import androidx.annotation.RequiresApi +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.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 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.flow.MutableStateFlow +import java.util.concurrent.CancellationException +import android.content.IntentSender +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 -@RequiresApi(Build.VERSION_CODES.O) +@TargetApi(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 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 strictUrlMatching by lazy { preferencesManager.getUseStrictUrlMatching() } + + private lateinit var decryptedPasswordsState: StateFlow> + + private var loginException: Throwable? = null + + override fun onCreate() { + super.onCreate() + + 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)) + } + } + + 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) + } + if (response != null) callback.onSuccess(response) + else callback.onFailure("Could not complete fill request") + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + callback.onFailure("Error handling fill request: ${e.message}") + } + } + + cancellationSignal.setOnCancelListener { + job.cancel() + } + } - val helper = AssistStructureParser(structure) + 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) // 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()) { + Log.e(TAG, "No username or password fields detected, cannot autofill") + 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 + throw IllegalStateException("User not logged in, cannot autofill") } - val useInline = PreferencesManager.getInstance(applicationContext).getUseInlineAutofill() - val inlineSuggestionsRequest = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && useInline) { - request.inlineSuggestionsRequest - } else null + // Determine Search Hint + val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) - val searchHint: String? = when { - // If the structure contains a domain, use that (probably a web browser) - helper.webDomain != null -> { - helper.webDomain - } + Log.d(TAG, "Search hint determined: $searchHint") - 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 - ) + // wait for passwords to be decrypted, then filter by search hint and sort them + val currentState = decryptedPasswordsState.first { !it.isLoading } - getApplicationLabel(app).toString() - } catch (e: PackageManager.NameNotFoundException) { - e.printStackTrace() - null - } + val filteredList = currentState.decryptedList.filter { + !it.hidden && !it.trashed && it.matches(searchHint, strictUrlMatching.first()) + }.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() } } } - // Intent to open MainActivity and provide a response to the request - val authIntent = Intent("com.hegocre.nextcloudpasswords.action.main").apply { - setPackage(packageName) - putExtra(AUTOFILL_REQUEST, true) - searchHint?.let { - putExtra(AUTOFILL_SEARCH_HINT, it) - } - } + // must go to the main app only if there are no passwords to show, and some were not decrypted + val needsAppForMasterPassword = if (filteredList.isEmpty()) currentState.notAllDecrypted + else false - val intentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - } + Log.d(TAG, "Passwords filtered and sorted") - val intentSender = PendingIntent.getActivity( - this, - 1001, - authIntent, - 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 - ) + val needsAuth = hasAppLock.first() && isLocked.value + + return buildFillResponse( + filteredList, + helper, + request, + searchHint, + needsAuth, + needsAppForMasterPassword + ) + } + + private suspend fun buildFillResponse( + passwords: List, + helper: AssistStructureParser, + request: FillRequest, + searchHint: String, + needsAuth: Boolean, + needsAppForMasterPassword: Boolean + ): FillResponse { + Log.d(TAG, "Building FillResponse, needsAuth: $needsAuth") + val builder = FillResponse.Builder() + val useInline = preferencesManager.getUseInlineAutofill() + + val inlineRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && useInline) { + request.inlineSuggestionsRequest + } else null + + if (!needsAppForMasterPassword) { + // 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 ) - } else { - addDataset( + ) + } + + 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, - null, - structure, - null, - intentSender + 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)), + false ) ) } - }.build() + Log.d(TAG, "Button to create new password added to FillResponse") + } - callback.onSuccess(fillResponse) - } else { - // Do not return a response if there are no autofill fields. - callback.onSuccess(null) + // Option to conclude the autofill in the app + builder.addDataset( + AutofillHelper.buildDataset( + applicationContext, + PasswordAutofillData(label = applicationContext.getString(R.string.more), id = null, username = null, password = null), + helper, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) inlineRequest?.inlinePresentationSpecs?.first() else null, + AutofillHelper.buildIntent(applicationContext, 1003, AutofillData.ChoosePwd(searchHint, helper.structure)), + 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.P) { + 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() + } + + private suspend fun getAppLabel(packageName: String): String = withContext(Dispatchers.IO) { + try { + val app = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + packageManager.getApplicationInfo( + packageName, + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) + ) + else + packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + + packageManager.getApplicationLabel(app).toString() + } catch (e: PackageManager.NameNotFoundException) { + "" } } override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { - callback.onFailure("Not implemented") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + serviceScope.launch { + try { + val intent: IntentSender? = withContext(Dispatchers.Default) { + processSaveRequest(request) + } + if (intent != null) callback.onSuccess(intent) + else callback.onFailure("Unable to complete Save Request") + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + callback.onFailure("Error handling save request: ${e.message}") + } + } + } else { + callback.onFailure("Saving not supported on android < 9.0") + } + } + + private suspend fun processSaveRequest(request: SaveRequest): IntentSender? { + val context = request.fillContexts.last() ?: return null + val helper = AssistStructureParser(context.structure) + + // Do not autofill this application + if (helper.packageName == packageName) return null + + val delayedUsername: String? = request.clientState?.getCharSequence(AutofillHelper.USERNAME)?.toString() + + val username: String = helper.usernameAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: delayedUsername ?: "" + val password: String = helper.passwordAutofillContent.firstOrNull { !it.isNullOrBlank() } ?: "" + + if (password.isBlank()) { + throw IllegalArgumentException("Blank password, cannot save") + } + + // Check Login Status + try { + userController.getServer() + } catch (_: UserException) { + throw IllegalStateException("User not logged in, cannot save") + } + + // Ensure Session is open + if (!apiController.sessionOpen.value && !apiController.openSession(preferencesManager.getMasterPassword())) { + throw IllegalStateException("Session is not open and cannot be opened, cannot save") + } + + // Determine Search Hint + val searchHint = helper.webDomain ?: getAppLabel(helper.packageName) + + return AutofillHelper.buildIntent(applicationContext, 1004, AutofillData.Save(searchHint, SaveData(searchHint, username, password, searchHint))) } companion object { - const val AUTOFILL_REQUEST = "autofill_request" - const val AUTOFILL_SEARCH_HINT = "autofill_query" + const val TAG = "NCPAutofillService" + 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 f27cb1b5..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 @@ -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 @@ -29,9 +30,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +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() @@ -43,38 +45,32 @@ class MainActivity : FragmentActivity() { val passwordsViewModel by viewModels() - val autofillRequested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - intent.getBooleanExtra(NCPAutofillService.AUTOFILL_REQUEST, false) + val autofillData: AutofillData? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + intent.getParcelableExtra( + NCPAutofillService.AUTOFILL_DATA, + AutofillData::class.java + ) + else + @Suppress("DEPRECATION") intent.getParcelableExtra(NCPAutofillService.AUTOFILL_DATA) } else { - false + 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) - - if (structure == null) { - setResult(RESULT_CANCELED) - finish() - } else { - autofillReply(Triple(label, username, password), structure) - } + ) { + when (autofillData) { + 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 @@ -104,8 +100,7 @@ class MainActivity : FragmentActivity() { passwordsViewModel = passwordsViewModel, onLogOut = { logOut() }, replyAutofill = replyAutofill, - isAutofillRequest = autofillRequested, - defaultSearchQuery = autofillSearchQuery + autofillData = autofillData, ) } } @@ -138,10 +133,10 @@ class MainActivity : FragmentActivity() { @RequiresApi(Build.VERSION_CODES.O) private fun autofillReply( - password: Triple, + password: PasswordAutofillData, structure: AssistStructure ) { - val dataset = AutofillHelper.buildDataset(this, password, structure, null) + val dataset = AutofillHelper.buildDataset(this, password, AssistStructureParser(structure), null) val replyIntent = Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) 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..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,6 +56,7 @@ 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 com.hegocre.nextcloudpasswords.utils.AutofillData import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -63,8 +64,7 @@ import kotlinx.coroutines.launch 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/NCPNavHost.kt b/app/src/main/java/com/hegocre/nextcloudpasswords/ui/components/NCPNavHost.kt index 79d691a1..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 @@ -20,11 +20,13 @@ 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.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 @@ -53,6 +55,7 @@ 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 @@ -64,7 +67,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, @@ -98,21 +101,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 +133,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 +178,686 @@ 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.isNullOrEmpty()) { + 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.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}/") + 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 + composable(NCPScreen.Favorites.name) { + val filteredFavoritePasswords = remember(filteredPasswordList) { + filteredPasswordList?.filter { it.favorite } } - } - 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)) + 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 - ) + passwordsDecryptionState.decryptedList != null && foldersDecryptionState.decryptedList != null -> { + val editablePasswordState = rememberSaveable(selectedPassword, saver = EditablePasswordState.Saver) { + EditablePasswordState(selectedPassword).apply { + if (selectedPassword == null) { + folder = passwordsViewModel.visibleFolder.value?.id ?: folder } - coroutineScope.launch { - if (passwordsViewModel.createPassword(newPassword) - .await() - ) { - if (editablePasswordState.replyAutofill && replyAutofill != null) { - replyAutofill( - editablePasswordState.label, - editablePasswordState.username, - editablePasswordState.password - ) - } else { - navController.navigateUp() + when (autofillData) { + is AutofillData.isSave -> { + if (selectedPassword == null) { + 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()) autofillData.saveData.label else label + url = if(url.isNullOrBlank()) autofillData.saveData.url else url + // prioritize new username and password fields + username = if(autofillData.saveData.username.isNullOrBlank()) username else autofillData.saveData.username + password = if(autofillData.saveData.password.isNullOrBlank()) password else autofillData.saveData.password + } } - } else { - Toast.makeText( - context, - R.string.error_password_saving_failed, - Toast.LENGTH_LONG - ).show() + is AutofillData.ChoosePwd -> { + if (selectedPassword == null) { + label = autofillData.searchHint + url = autofillData.searchHint + } + } + 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 { + 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 { + 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() + ) { + 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/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..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,6 +66,7 @@ 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 @@ -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..260997b8 --- /dev/null +++ b/app/src/main/java/com/hegocre/nextcloudpasswords/utils/AutofillUtils.kt @@ -0,0 +1,70 @@ +package com.hegocre.nextcloudpasswords.utils + +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 { + interface isAutofill { + val structure: AssistStructure + } + + interface isSave { + val saveData: SaveData + } + + @Parcelize + data class FromId( + val id: String, + override val structure: AssistStructure + ) : AutofillData(), isAutofill + + @Parcelize + data class ChoosePwd( + val searchHint: String, + override val structure: AssistStructure + ) : AutofillData(), isAutofill + + @Parcelize + data class SaveAutofill( + val searchHint: String, + override val saveData: SaveData, + override val structure: AssistStructure, + ) : AutofillData(), isAutofill, isSave + + @Parcelize + data class Save( + val searchHint: String, + override val saveData: SaveData + ) : AutofillData(), isSave + + fun isAutofill(): Boolean { + return when (this) { + is isAutofill -> true + else -> false + } + } + + fun isSave(): Boolean { + return when (this) { + is isSave -> true + else -> false + } + } +} + +data class ListDecryptionStateNonNullable( + val decryptedList: List = emptyList(), + val isLoading: Boolean = false, + val notAllDecrypted: Boolean = false +) \ No newline at end of file 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