diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt index a6090f132..eadd7390c 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt @@ -49,6 +49,7 @@ import com.kunzisoft.keepass.database.element.Entry import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.education.EntryActivityEducation import com.kunzisoft.keepass.magikeyboard.MagikIME +import com.kunzisoft.keepass.model.CreditCardCustomFields import com.kunzisoft.keepass.model.EntryAttachmentState import com.kunzisoft.keepass.model.StreamDirection import com.kunzisoft.keepass.otp.OtpEntryFields @@ -322,6 +323,7 @@ class EntryActivity : LockingActivity() { // Assign custom fields if (mDatabase?.allowEntryCustomFields() == true) { entryContentsView?.clearExtraFields() + entryContentsView?.clearCreditCardFields() entryInfo.customFields.forEach { field -> val label = field.name // OTP field is already managed in dedicated view @@ -332,7 +334,7 @@ class EntryActivity : LockingActivity() { entryContentsView?.addExtraField(label, value, allowCopyProtectedField) { clipboardHelper?.timeoutCopyToClipboard( value.toString(), - getString(R.string.copy_field, label) + getString(R.string.copy_field, CreditCardCustomFields.getLocalizedName(applicationContext, field.name)) ) } } else { @@ -346,6 +348,7 @@ class EntryActivity : LockingActivity() { } } } + entryContentsView?.setHiddenProtectedValue(!mShowPassword) // Manage attachments @@ -439,15 +442,15 @@ class EntryActivity : LockingActivity() { val entryFieldCopyView = entryContentsView?.firstEntryFieldCopyView() val entryCopyEducationPerformed = entryFieldCopyView != null && entryActivityEducation.checkAndPerformedEntryCopyEducation( - entryFieldCopyView, - { - val appNameString = getString(R.string.app_name) - clipboardHelper?.timeoutCopyToClipboard(appNameString, - getString(R.string.copy_field, appNameString)) - }, - { - performedNextEducation(entryActivityEducation, menu) - }) + entryFieldCopyView, + { + val appNameString = getString(R.string.app_name) + clipboardHelper?.timeoutCopyToClipboard(appNameString, + getString(R.string.copy_field, appNameString)) + }, + { + performedNextEducation(entryActivityEducation, menu) + }) if (!entryCopyEducationPerformed) { val menuEditView = toolbar?.findViewById(R.id.menu_edit) diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt index 425f54e4f..dcb689b70 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt @@ -57,6 +57,7 @@ import com.kunzisoft.keepass.database.element.node.Node import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.education.EntryEditActivityEducation import com.kunzisoft.keepass.model.* +import com.kunzisoft.keepass.model.CreditCard import com.kunzisoft.keepass.otp.OtpElement import com.kunzisoft.keepass.otp.OtpEntryFields import com.kunzisoft.keepass.services.AttachmentFileNotificationService @@ -81,6 +82,7 @@ import kotlin.collections.ArrayList class EntryEditActivity : LockingActivity(), EntryCustomFieldDialogFragment.EntryCustomFieldListener, GeneratePasswordDialogFragment.GeneratePasswordListener, + CreditCardDetailsDialogFragment.EntryCCFieldListener, SetOTPDialogFragment.CreateOtpListener, DatePickerDialog.OnDateSetListener, TimePickerDialog.OnTimeSetListener, @@ -189,16 +191,15 @@ class EntryEditActivity : LockingActivity(), val registerInfo = EntrySelectionHelper.retrieveRegisterInfoFromIntent(intent) val searchInfo: SearchInfo? = registerInfo?.searchInfo ?: EntrySelectionHelper.retrieveSearchInfoFromIntent(intent) - registerInfo?.username?.let { - tempEntryInfo?.username = it - } - registerInfo?.password?.let { - tempEntryInfo?.password = it - } + searchInfo?.let { tempSearchInfo -> tempEntryInfo?.saveSearchInfo(mDatabase, tempSearchInfo) } + registerInfo?.let { regInfo -> + tempEntryInfo?.saveRegisterInfo(mDatabase, regInfo) + } + // Build fragment to manage entry modification entryEditFragment = supportFragmentManager.findFragmentByTag(ENTRY_EDIT_FRAGMENT_TAG) as? EntryEditFragment? if (entryEditFragment == null) { @@ -405,6 +406,29 @@ class EntryEditActivity : LockingActivity(), GeneratePasswordDialogFragment().show(supportFragmentManager, "PasswordGeneratorFragment") } + private fun addNewCreditCard() { + var cardholder: String? = null + var number: String? = null + var expiration: String? = null + var cvv: String? = null + + entryEditFragment?.getExtraFields()?.forEach() { field -> + when (field.name) { + CreditCardCustomFields.CC_CARDHOLDER_FIELD_NAME -> + cardholder = field.protectedValue.stringValue + CreditCardCustomFields.CC_NUMBER_FIELD_NAME -> + number = field.protectedValue.stringValue + CreditCardCustomFields.CC_EXP_FIELD_NAME -> + expiration = field.protectedValue.stringValue + CreditCardCustomFields.CC_CVV_FIELD_NAME -> + cvv = field.protectedValue.stringValue + } + } + + val cc = CreditCard(cardholder, number, expiration, cvv) + CreditCardDetailsDialogFragment.build(cc).show(supportFragmentManager, "CreditCardDialog") + } + /** * Add a new customized field */ @@ -455,6 +479,12 @@ class EntryEditActivity : LockingActivity(), entryEditFragment?.removeExtraField(oldField) } + override fun onNewCCFieldsApproved(ccFields: ArrayList) { + for (field in ccFields) { + entryEditFragment?.putExtraField(field) + } + } + /** * Add a new attachment */ @@ -609,8 +639,14 @@ class EntryEditActivity : LockingActivity(), override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + val allowCustomField = mDatabase?.allowEntryCustomFields() == true + menu?.findItem(R.id.menu_add_field)?.apply { - val allowCustomField = mDatabase?.allowEntryCustomFields() == true + isEnabled = allowCustomField + isVisible = allowCustomField + } + + menu?.findItem(R.id.menu_add_cc)?.apply { isEnabled = allowCustomField isVisible = allowCustomField } @@ -682,6 +718,10 @@ class EntryEditActivity : LockingActivity(), addNewCustomField() return true } + R.id.menu_add_cc -> { + addNewCreditCard() + return true + } R.id.menu_add_attachment -> { addNewAttachment() return true diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/CreditCardDetailsDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/CreditCardDetailsDialogFragment.kt new file mode 100644 index 000000000..ee4e29c3e --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/CreditCardDetailsDialogFragment.kt @@ -0,0 +1,179 @@ +package com.kunzisoft.keepass.activities.dialogs + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.view.WindowManager +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.Spinner +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.model.CreditCard +import com.kunzisoft.keepass.model.CreditCardCustomFields.buildAllFields +import com.kunzisoft.keepass.model.Field +import java.util.* + +class CreditCardDetailsDialogFragment : DialogFragment() { + private var mCreditCard: CreditCard? = null + private var entryCCFieldListener: EntryCCFieldListener? = null + + private var mCcCardholderName: EditText? = null + private var mCcCardNumber: EditText? = null + private var mCcSecurityCode: EditText? = null + + private var mCcExpirationMonthSpinner: Spinner? = null + private var mCcExpirationYearSpinner: Spinner? = null + + private var mPositiveButton: Button? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + try { + entryCCFieldListener = context as EntryCCFieldListener + } catch (e: ClassCastException) { + // The activity doesn't implement the interface, throw exception + throw ClassCastException(context.toString() + + " must implement " + EntryCCFieldListener::class.java.name) + } + } + + override fun onDetach() { + entryCCFieldListener = null + super.onDetach() + } + + override fun onResume() { + super.onResume() + + // To prevent auto dismiss + val d = dialog as AlertDialog? + if (d != null) { + mPositiveButton = d.getButton(Dialog.BUTTON_POSITIVE) as Button + mPositiveButton?.run { + setOnClickListener { + submitDialog() + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putParcelable(KEY_CREDIT_CARD, mCreditCard) + } + + private fun submitDialog() { + val ccNumber = mCcCardNumber?.text?.toString() ?: "" + + val month = mCcExpirationMonthSpinner?.selectedItem?.toString() ?: "" + val year = mCcExpirationYearSpinner?.selectedItem?.toString()?.substring(2,4) ?: "" + + val cvv = mCcSecurityCode?.text?.toString() ?: "" + val ccName = mCcCardholderName?.text?.toString() ?: "" + + entryCCFieldListener?.onNewCCFieldsApproved(buildAllFields(ccName, ccNumber, month + year, cvv)) + + (dialog as AlertDialog?)?.dismiss() + } + + interface EntryCCFieldListener { + fun onNewCCFieldsApproved(ccFields: ArrayList) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + // Retrieve credit card details if available + if (savedInstanceState != null) { + if (savedInstanceState.containsKey(KEY_CREDIT_CARD)) { + mCreditCard = savedInstanceState.getParcelable(KEY_CREDIT_CARD) + } + } else { + arguments?.apply { + if (containsKey(KEY_CREDIT_CARD)) { + mCreditCard = getParcelable(KEY_CREDIT_CARD) + } + } + } + + activity?.let { activity -> + val root = activity.layoutInflater.inflate(R.layout.entry_cc_details_dialog, null) + + root?.run { + mCcCardholderName = findViewById(R.id.creditCardholderNameField) + mCcCardNumber = findViewById(R.id.creditCardNumberField) + mCcSecurityCode = findViewById(R.id.creditCardSecurityCode) + mCcExpirationMonthSpinner = findViewById(R.id.expirationMonth) + mCcExpirationYearSpinner = findViewById(R.id.expirationYear) + + mCreditCard?.cardholder?.let { + mCcCardholderName?.setText(it) + } + mCreditCard?.number?.let { + mCcCardNumber?.setText(it) + } + mCreditCard?.cvv?.let { + mCcSecurityCode?.setText(it) + } + } + + val months = arrayOf("01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12") + mCcExpirationMonthSpinner?.let { spinner -> + spinner.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, months) + mCreditCard?.let { cc -> + spinner.setSelection(getIndex(spinner, cc.getExpirationMonth())) + } + } + + val years = arrayOfNulls(5) + val year = Calendar.getInstance()[Calendar.YEAR] + for (i in years.indices) { + years[i] = (year + i).toString() + } + mCcExpirationYearSpinner?.let { spinner -> + spinner.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, years) + mCreditCard?.let { cc -> + spinner.setSelection(getIndex(spinner, "20" + cc.getExpirationYear())) + } + } + + val builder = AlertDialog.Builder(activity) + + builder.setView(root).setTitle(R.string.entry_setup_cc) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel) { _, _ -> } + + val dialogCreated = builder.create() + + dialogCreated.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + + return dialogCreated + } + return super.onCreateDialog(savedInstanceState) + } + + private fun getIndex(spinner: Spinner, value: String?): Int { + for (i in 0 until spinner.count) { + if (spinner.getItemAtPosition(i).toString() == value) { + return i + } + } + return 0 + } + + companion object { + private const val KEY_CREDIT_CARD = "KEY_CREDIT_CARD" + + fun build(creditCard: CreditCard? = null): CreditCardDetailsDialogFragment { + return CreditCardDetailsDialogFragment().apply { + if (creditCard != null) { + arguments = Bundle().apply { + putParcelable(KEY_CREDIT_CARD, creditCard) + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryEditFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryEditFragment.kt index 2c8f1a4f8..46f5b710b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryEditFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryEditFragment.kt @@ -46,6 +46,7 @@ import com.kunzisoft.keepass.database.element.icon.IconImage import com.kunzisoft.keepass.education.EntryEditActivityEducation import com.kunzisoft.keepass.icons.IconDrawableFactory import com.kunzisoft.keepass.model.* +import com.kunzisoft.keepass.model.CreditCardCustomFields import com.kunzisoft.keepass.otp.OtpEntryFields import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.view.ExpirationView @@ -53,7 +54,7 @@ import com.kunzisoft.keepass.view.applyFontVisibility import com.kunzisoft.keepass.view.collapse import com.kunzisoft.keepass.view.expand -class EntryEditFragment: StylishFragment() { +class EntryEditFragment : StylishFragment() { private lateinit var entryTitleLayoutView: TextInputLayout private lateinit var entryTitleView: EditText @@ -91,7 +92,7 @@ class EntryEditFragment: StylishFragment() { super.onCreateView(inflater, container, savedInstanceState) val rootView = inflater.cloneInContext(contextThemed) - .inflate(R.layout.fragment_entry_edit_contents, container, false) + .inflate(R.layout.fragment_entry_edit_contents, container, false) fontInVisibility = PreferencesUtil.fieldFontIsInVisibility(requireContext()) @@ -152,7 +153,8 @@ class EntryEditFragment: StylishFragment() { } if (savedInstanceState?.containsKey(KEY_LAST_FOCUSED_FIELD) == true) { - mLastFocusedEditField = savedInstanceState.getParcelable(KEY_LAST_FOCUSED_FIELD) ?: mLastFocusedEditField + mLastFocusedEditField = savedInstanceState.getParcelable(KEY_LAST_FOCUSED_FIELD) + ?: mLastFocusedEditField } populateViewsWithEntry() @@ -185,7 +187,8 @@ class EntryEditFragment: StylishFragment() { { try { (activity as? EntryEditActivity?)?.performedNextEducation(entryEditActivityEducation) - } catch (ignore: Exception) {} + } catch (ignore: Exception) { + } } ) } @@ -306,7 +309,7 @@ class EntryEditFragment: StylishFragment() { */ private var mExtraFieldsList: MutableList = ArrayList() - private var mOnEditButtonClickListener: ((item: Field)->Unit)? = null + private var mOnEditButtonClickListener: ((item: Field) -> Unit)? = null private fun buildViewFromField(extraField: Field): View? { val inflater = context?.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater? @@ -316,7 +319,15 @@ class EntryEditFragment: StylishFragment() { val extraFieldValueContainer: TextInputLayout? = itemView?.findViewById(R.id.entry_extra_field_value_container) extraFieldValueContainer?.endIconMode = if (extraField.protectedValue.isProtected) TextInputLayout.END_ICON_PASSWORD_TOGGLE else TextInputLayout.END_ICON_NONE - extraFieldValueContainer?.hint = extraField.name + + when (extraField.name) { + CreditCardCustomFields.CC_CARDHOLDER_FIELD_NAME -> extraFieldValueContainer?.hint = context?.getString(R.string.cc_cardholder) + CreditCardCustomFields.CC_EXP_FIELD_NAME -> extraFieldValueContainer?.hint = context?.getString(R.string.cc_expiration) + CreditCardCustomFields.CC_NUMBER_FIELD_NAME -> extraFieldValueContainer?.hint = context?.getString(R.string.cc_number) + CreditCardCustomFields.CC_CVV_FIELD_NAME -> extraFieldValueContainer?.hint = context?.getString(R.string.cc_security_code) + else -> extraFieldValueContainer?.hint = extraField.name + } + extraFieldValueContainer?.id = View.NO_ID val extraFieldValue: TextInputEditText? = itemView?.findViewById(R.id.entry_extra_field_value) @@ -365,21 +376,24 @@ class EntryEditFragment: StylishFragment() { * Remove all children and add new views for each field */ fun assignExtraFields(fields: List, - onEditButtonClickListener: ((item: Field)->Unit)?) { + onEditButtonClickListener: ((item: Field) -> Unit)?) { extraFieldsContainerView.visibility = if (fields.isEmpty()) View.GONE else View.VISIBLE // Reinit focused field mExtraFieldsList.clear() mExtraFieldsList.addAll(fields) extraFieldsListView.removeAllViews() + + fields.forEach { extraFieldsListView.addView(buildViewFromField(it)) } + // Request last focus mLastFocusedEditField?.let { focusField -> mExtraViewToRequestFocus?.apply { requestFocus() setSelection(focusField.cursorSelectionStart, - focusField.cursorSelectionEnd) + focusField.cursorSelectionEnd) } } mLastFocusedEditField = null @@ -457,7 +471,7 @@ class EntryEditFragment: StylishFragment() { fun assignAttachments(attachments: List, streamDirection: StreamDirection, - onDeleteItem: (attachment: Attachment)->Unit) { + onDeleteItem: (attachment: Attachment) -> Unit) { attachmentsContainerView.visibility = if (attachments.isEmpty()) View.GONE else View.VISIBLE attachmentsAdapter.assignItems(attachments.map { EntryAttachmentState(it, streamDirection) }) attachmentsAdapter.onDeleteButtonClickListener = { item -> @@ -474,7 +488,7 @@ class EntryEditFragment: StylishFragment() { } fun putAttachment(attachment: EntryAttachmentState, - onPreviewLoaded: (()-> Unit)? = null) { + onPreviewLoaded: (() -> Unit)? = null) { attachmentsContainerView.visibility = View.VISIBLE attachmentsAdapter.putItem(attachment) attachmentsAdapter.onBinaryPreviewLoaded = { diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt b/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt index db75561ed..41692a15f 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt @@ -48,8 +48,11 @@ import com.kunzisoft.keepass.database.element.Database import com.kunzisoft.keepass.database.element.icon.IconImage import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.model.CreditCardCustomFields import com.kunzisoft.keepass.settings.AutofillSettingsActivity import com.kunzisoft.keepass.settings.PreferencesUtil +import java.util.* +import kotlin.collections.ArrayList @RequiresApi(api = Build.VERSION_CODES.O) @@ -103,9 +106,9 @@ object AutofillHelper { } private fun buildDataset(context: Context, - entryInfo: EntryInfo, - struct: StructureParser.Result, - inlinePresentation: InlinePresentation?): Dataset? { + entryInfo: EntryInfo, + struct: StructureParser.Result, + inlinePresentation: InlinePresentation?): Dataset? { val title = makeEntryTitle(entryInfo) val views = newRemoteViews(context, title, entryInfo.icon) val builder = Dataset.Builder(views) @@ -114,8 +117,88 @@ object AutofillHelper { struct.usernameId?.let { usernameId -> builder.setValue(usernameId, AutofillValue.forText(entryInfo.username)) } - struct.passwordId?.let { password -> - builder.setValue(password, AutofillValue.forText(entryInfo.password)) + struct.passwordId?.let { passwordId -> + builder.setValue(passwordId, AutofillValue.forText(entryInfo.password)) + } + + for (field in entryInfo.customFields) { + if (field.name == CreditCardCustomFields.CC_CARDHOLDER_FIELD_NAME) { + struct.ccNameId?.let { ccNameId -> + builder.setValue(ccNameId, AutofillValue.forText(field.protectedValue.stringValue)) + } + } + if (field.name == CreditCardCustomFields.CC_NUMBER_FIELD_NAME) { + struct.ccnId?.let { ccnId -> + builder.setValue(ccnId, AutofillValue.forText(field.protectedValue.stringValue)) + } + } + if (field.name == CreditCardCustomFields.CC_EXP_FIELD_NAME) { + // the database stores the expiration month and year as a String + // of length four in the format MMYY + if (field.protectedValue.stringValue.length != 4) continue + + // get month (month in database entry is stored as String in the format MM) + val monthString = field.protectedValue.stringValue.substring(0, 2) + val month = monthString.toIntOrNull() ?: 0 + if (month < 1 || month > 12) continue + + // get year (year in database entry is stored as String in the format YY) + val yearString = field.protectedValue.stringValue.substring(2, 4) + val year = "20$yearString".toIntOrNull() ?: 0 + if (year == 0) continue + + struct.ccExpDateId?.let { + if (struct.isWebView) { + // set date string as defined in https://html.spec.whatwg.org + val dateString = "20$yearString\u002D$monthString" + builder.setValue(it, AutofillValue.forText(dateString)) + } else { + val calendar = Calendar.getInstance() + calendar.clear() + calendar[Calendar.YEAR] = year + // Month value is 0-based. e.g., 0 for January + calendar[Calendar.MONTH] = month - 1 + val date = calendar.timeInMillis + builder.setValue(it, AutofillValue.forDate(date)) + } + } + struct.ccExpDateMonthId?.let { + if (struct.isWebView) { + builder.setValue(it, AutofillValue.forText(monthString)) + } else { + if (struct.ccExpMonthOptions != null) { + // index starts at 0 + builder.setValue(it, AutofillValue.forList(month - 1)) + } else { + builder.setValue(it, AutofillValue.forText(monthString)) + } + } + } + struct.ccExpDateYearId?.let { + var autofillValue: AutofillValue? = null + + struct.ccExpYearOptions?.let { options -> + var yearIndex = options.indexOf(yearString) + + if (yearIndex == -1) { + yearIndex = options.indexOf("20$yearString") + } + if (yearIndex != -1) { + autofillValue = AutofillValue.forList(yearIndex) + builder.setValue(it, autofillValue) + } + } + + if (autofillValue == null) { + builder.setValue(it, AutofillValue.forText("20$yearString")) + } + } + } + if (field.name == CreditCardCustomFields.CC_CVV_FIELD_NAME) { + struct.cvvId?.let { cvvId -> + builder.setValue(cvvId, AutofillValue.forText(field.protectedValue.stringValue)) + } + } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -126,8 +209,8 @@ object AutofillHelper { return try { builder.build() - } catch (e: IllegalArgumentException) { - // if not value be set + } catch (e: Exception) { + // at least one value must be set null } } @@ -156,7 +239,7 @@ object AutofillHelper { val inlinePresentationSpecs = inlineSuggestionsRequest.inlinePresentationSpecs val maxSuggestion = inlineSuggestionsRequest.maxSuggestionCount - if (positionItem <= maxSuggestion-1 + if (positionItem <= maxSuggestion - 1 && inlinePresentationSpecs.size > positionItem) { val inlinePresentationSpec = inlinePresentationSpecs[positionItem] @@ -191,7 +274,7 @@ object AutofillHelper { fun buildResponse(context: Context, entriesInfo: List, parseResult: StructureParser.Result, - inlineSuggestionsRequest: InlineSuggestionsRequest?): FillResponse { + inlineSuggestionsRequest: InlineSuggestionsRequest?): FillResponse? { val responseBuilder = FillResponse.Builder() // Add Header if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -208,6 +291,7 @@ object AutofillHelper { } } } + // Add inline suggestion for new IME and dataset entriesInfo.forEachIndexed { index, entryInfo -> val inlinePresentation = inlineSuggestionsRequest?.let { @@ -217,9 +301,17 @@ object AutofillHelper { null } } - responseBuilder.addDataset(buildDataset(context, entryInfo, parseResult, inlinePresentation)) + val dataSet = buildDataset(context, entryInfo, parseResult, inlinePresentation) + dataSet?.let { + responseBuilder.addDataset(it) + } + } + + return try { + responseBuilder.build() + } catch (e: java.lang.Exception) { + null } - return responseBuilder.build() } /** diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt b/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt index 8f093e18d..db5455282 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt @@ -38,6 +38,7 @@ import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.AutofillLauncherActivity import com.kunzisoft.keepass.database.element.Database import com.kunzisoft.keepass.database.search.SearchHelper +import com.kunzisoft.keepass.model.CreditCard import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.settings.AutofillSettingsActivity @@ -101,6 +102,15 @@ class KeeAutofillService : AutofillService() { callback) } } +// TODO does it make sense to disable autofill here? how long? +// else { +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { +// val builder = FillResponse.Builder() +// // disable for a while (duration in ms) +// builder.disableAutofill(5*60*1000) +// callback.onSuccess(builder.build()) +// } +// } } } } @@ -155,23 +165,40 @@ class KeeAutofillService : AutofillService() { RemoteViews(packageName, R.layout.item_autofill_unlock) } - // Tell to service the interest to save credentials + // Tell the autofill framework the interest to save credentials if (askToSaveData) { var types: Int = SaveInfo.SAVE_DATA_TYPE_GENERIC - val info = ArrayList() + val requiredIds = ArrayList() + val optionalIds = ArrayList() + // Only if at least a password parseResult.passwordId?.let { passwordInfo -> parseResult.usernameId?.let { usernameInfo -> types = types or SaveInfo.SAVE_DATA_TYPE_USERNAME - info.add(usernameInfo) + requiredIds.add(usernameInfo) } types = types or SaveInfo.SAVE_DATA_TYPE_PASSWORD - info.add(passwordInfo) + requiredIds.add(passwordInfo) } - if (info.isNotEmpty()) { - responseBuilder.setSaveInfo( - SaveInfo.Builder(types, info.toTypedArray()).build() - ) + // or a credit card form + if (requiredIds.isEmpty()) { + parseResult.ccnId?.let { numberId -> + types = types or SaveInfo.SAVE_DATA_TYPE_CREDIT_CARD + requiredIds.add(numberId) + Log.d(TAG, "Asking to save credit card number") + } + parseResult.ccExpDateId?.let { id -> optionalIds.add(id) } + parseResult.ccExpDateYearId?.let { id -> optionalIds.add(id) } + parseResult.ccExpDateMonthId?.let { id -> optionalIds.add(id) } + parseResult.ccNameId?.let { id -> optionalIds.add(id) } + parseResult.cvvId?.let { id -> optionalIds.add(id) } + } + if (requiredIds.isNotEmpty()) { + val builder = SaveInfo.Builder(types, requiredIds.toTypedArray()) + if (optionalIds.isNotEmpty()) { + builder.setOptionalIds(optionalIds.toTypedArray()) + } + responseBuilder.setSaveInfo(builder.build()) } } @@ -223,14 +250,27 @@ class KeeAutofillService : AutofillService() { && autofillAllowedFor(parseResult.webDomain, webDomainBlocklist)) { Log.d(TAG, "autofill onSaveRequest password") + + if (parseResult.ccExpirationValue == null) { + if (parseResult.ccExpDateMonthValue != 0 && parseResult.ccExpDateYearValue != 0) { + parseResult.ccExpirationValue = parseResult.ccExpDateMonthValue.toString().padStart(2, '0') + parseResult.ccExpDateYearValue.toString() + } + } + + val creditCard = CreditCard(parseResult.ccName, parseResult.ccNumber, + parseResult.ccExpirationValue, parseResult.cvv) + // Show UI to save data - val registerInfo = RegisterInfo(SearchInfo().apply { - applicationId = parseResult.applicationId - webDomain = parseResult.webDomain - webScheme = parseResult.webScheme - }, + val registerInfo = RegisterInfo( + SearchInfo().apply { + applicationId = parseResult.applicationId + webDomain = parseResult.webDomain + webScheme = parseResult.webScheme + }, parseResult.usernameValue?.textValue?.toString(), - parseResult.passwordValue?.textValue?.toString()) + parseResult.passwordValue?.textValue?.toString(), + creditCard) + // TODO Callback in each activity #765 //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // callback.onSuccess(AutofillLauncherActivity.getAuthIntentSenderForRegistration(this, diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt b/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt index df0b99ffe..12fdbb754 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt +++ b/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt @@ -21,12 +21,13 @@ package com.kunzisoft.keepass.autofill import android.app.assist.AssistStructure import android.os.Build import android.text.InputType -import androidx.annotation.RequiresApi import android.util.Log import android.view.View import android.view.autofill.AutofillId import android.view.autofill.AutofillValue +import androidx.annotation.RequiresApi import java.util.* +import kotlin.collections.ArrayList /** @@ -36,9 +37,7 @@ import java.util.* class StructureParser(private val structure: AssistStructure) { private var result: Result? = null - private var usernameNeeded = true - - private var usernameCandidate: AutofillId? = null + private var usernameIdCandidate: AutofillId? = null private var usernameValueCandidate: AutofillValue? = null fun parse(saveValue: Boolean = false): Result? { @@ -46,7 +45,7 @@ class StructureParser(private val structure: AssistStructure) { result = Result() result?.apply { allowSaveValues = saveValue - usernameCandidate = null + usernameIdCandidate = null usernameValueCandidate = null mainLoop@ for (i in 0 until structure.windowNodeCount) { val windowNode = structure.getWindowNodeAt(i) @@ -57,26 +56,29 @@ class StructureParser(private val structure: AssistStructure) { break@mainLoop } // If not explicit username field found, add the field just before password field. - if (usernameId == null && passwordId != null && usernameCandidate != null) { - usernameId = usernameCandidate + if (usernameId == null && passwordId != null && usernameIdCandidate != null) { + usernameId = usernameIdCandidate if (allowSaveValues) { usernameValue = usernameValueCandidate } } } - // Return the result only if password field is retrieved - return if ((!usernameNeeded || result?.usernameId != null) - && result?.passwordId != null) - result - else - null + return if (result?.passwordId != null || result?.ccnId != null) + result + else + null } catch (e: Exception) { return null } } private fun parseViewNode(node: AssistStructure.ViewNode): Boolean { + // remember this + if (node.className == "android.webkit.WebView") { + result?.isWebView = true + } + // Get the domain of a web app node.webDomain?.let { webDomain -> if (webDomain.isNotEmpty()) { @@ -97,8 +99,7 @@ class StructureParser(private val structure: AssistStructure) { var returnValue = false // Only parse visible nodes if (node.visibility == View.VISIBLE) { - if (node.autofillId != null - && node.autofillType == View.AUTOFILL_TYPE_TEXT) { + if (node.autofillId != null) { // Parse methods val hints = node.autofillHints if (hints != null && hints.isNotEmpty()) { @@ -130,7 +131,7 @@ class StructureParser(private val structure: AssistStructure) { it.contains(View.AUTOFILL_HINT_USERNAME, true) || it.contains(View.AUTOFILL_HINT_EMAIL_ADDRESS, true) || it.contains("email", true) - || it.contains(View.AUTOFILL_HINT_PHONE, true)-> { + || it.contains(View.AUTOFILL_HINT_PHONE, true) -> { result?.usernameId = autofillId result?.usernameValue = node.autofillValue Log.d(TAG, "Autofill username hint") @@ -139,14 +140,96 @@ class StructureParser(private val structure: AssistStructure) { result?.passwordId = autofillId result?.passwordValue = node.autofillValue Log.d(TAG, "Autofill password hint") - // Username not needed in this case - usernameNeeded = false return true } + it == "cc-name" -> { + Log.d(TAG, "AUTOFILL cc-name hint") + result?.ccNameId = autofillId + result?.ccName = node.autofillValue?.textValue?.toString() + } + it == View.AUTOFILL_HINT_CREDIT_CARD_NUMBER || it == "cc-number" -> { + Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_NUMBER hint") + result?.ccnId = autofillId + result?.ccNumber = node.autofillValue?.textValue?.toString() + } + // expect date string as defined in https://html.spec.whatwg.org, e.g. 2014-12 + it == "cc-exp" -> { + Log.d(TAG, "AUTOFILL cc-exp hint") + result?.ccExpDateId = autofillId + node.autofillValue?.let { value -> + if (value.isText && value.textValue.length == 7) { + value.textValue.let { date -> + result?.ccExpirationValue = date.substring(5, 7) + date.substring(2, 4) + } + } + } + } + it == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE -> { + Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE hint") + result?.ccExpDateId = autofillId + node.autofillValue?.let { value -> + if (value.isDate) { + val calendar = Calendar.getInstance() + calendar.clear() + calendar.timeInMillis = value.dateValue + val year = calendar.get(Calendar.YEAR).toString().substring(2,4) + val month = calendar.get(Calendar.MONTH).inc().toString().padStart(2, '0') + result?.ccExpirationValue = month + year + } + } + } + it == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR || it == "cc-exp-year" -> { + Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR hint") + result?.ccExpDateYearId = autofillId + if (node.autofillOptions != null) { + result?.ccExpYearOptions = node.autofillOptions + } + node.autofillValue?.let { value -> + var year = 0 + try { + if (value.isText) { + year = value.textValue.toString().toInt() + } + if (value.isList) { + year = node.autofillOptions?.get(value.listValue).toString().toInt() + } + } catch (e: Exception) { + year = 0 + } + result?.ccExpDateYearValue = year % 100 + } + } + it == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH || it == "cc-exp-month" -> { + Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH hint") + result?.ccExpDateMonthId = autofillId + if (node.autofillOptions != null) { + result?.ccExpMonthOptions = node.autofillOptions + } + node.autofillValue?.let { value -> + var month = 0 + if (value.isText) { + try { + month = value.textValue.toString().toInt() + } catch (e: Exception) { + month = 0 + } + } + if (value.isList) { + // assume list starts with January (index 0) + month = value.listValue + 1 + } + result?.ccExpDateMonthValue = month + } + } + it == View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE || it == "cc-csc" -> { + Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE hint") + result?.cvvId = autofillId + result?.cvv = node.autofillValue?.textValue?.toString() + } // Ignore autocomplete="off" // https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion it.equals("off", true) || - it.equals("on", true) -> { + it.equals("on", true) -> { Log.d(TAG, "Autofill web hint") return parseNodeByHtmlAttributes(node) } @@ -171,7 +254,7 @@ class StructureParser(private val structure: AssistStructure) { Log.d(TAG, "Autofill username web type: ${node.htmlInfo?.tag} ${node.htmlInfo?.attributes}") } "text" -> { - usernameCandidate = autofillId + usernameIdCandidate = autofillId usernameValueCandidate = node.autofillValue Log.d(TAG, "Autofill username candidate web type: ${node.htmlInfo?.tag} ${node.htmlInfo?.attributes}") } @@ -219,7 +302,7 @@ class StructureParser(private val structure: AssistStructure) { InputType.TYPE_TEXT_VARIATION_NORMAL, InputType.TYPE_TEXT_VARIATION_PERSON_NAME, InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT) -> { - usernameCandidate = autofillId + usernameIdCandidate = autofillId usernameValueCandidate = node.autofillValue Log.d(TAG, "Autofill username candidate android text type: ${showHexInputType(inputType)}") } @@ -243,7 +326,6 @@ class StructureParser(private val structure: AssistStructure) { result?.passwordId = autofillId result?.passwordValue = node.autofillValue Log.d(TAG, "Autofill password android text type: ${showHexInputType(inputType)}") - usernameNeeded = false return true } inputIsVariationType(inputType, @@ -265,16 +347,15 @@ class StructureParser(private val structure: AssistStructure) { when { inputIsVariationType(inputType, InputType.TYPE_NUMBER_VARIATION_NORMAL) -> { - usernameCandidate = autofillId + usernameIdCandidate = autofillId usernameValueCandidate = node.autofillValue - Log.d(TAG, "Autofill usernale candidate android number type: ${showHexInputType(inputType)}") + Log.d(TAG, "Autofill username candidate android number type: ${showHexInputType(inputType)}") } inputIsVariationType(inputType, InputType.TYPE_NUMBER_VARIATION_PASSWORD) -> { result?.passwordId = autofillId result?.passwordValue = node.autofillValue Log.d(TAG, "Autofill password android number type: ${showHexInputType(inputType)}") - usernameNeeded = false return true } else -> { @@ -288,6 +369,7 @@ class StructureParser(private val structure: AssistStructure) { @RequiresApi(api = Build.VERSION_CODES.O) class Result { + var isWebView: Boolean = false var applicationId: String? = null var webDomain: String? = null @@ -302,6 +384,11 @@ class StructureParser(private val structure: AssistStructure) { field = value } + // if the user selects the credit card expiration date from a list of options + // all options are stored here + var ccExpMonthOptions: Array? = null + var ccExpYearOptions: Array? = null + var usernameId: AutofillId? = null set(value) { if (field == null) @@ -314,6 +401,42 @@ class StructureParser(private val structure: AssistStructure) { field = value } + var ccNameId: AutofillId? = null + set(value) { + if (field == null) + field = value + } + + var ccnId: AutofillId? = null + set(value) { + if (field == null) + field = value + } + + var ccExpDateId: AutofillId? = null + set(value) { + if (field == null) + field = value + } + + var ccExpDateYearId: AutofillId? = null + set(value) { + if (field == null) + field = value + } + + var ccExpDateMonthId: AutofillId? = null + set(value) { + if (field == null) + field = value + } + + var cvvId: AutofillId? = null + set(value) { + if (field == null) + field = value + } + fun allAutofillIds(): Array { val all = ArrayList() usernameId?.let { @@ -322,6 +445,15 @@ class StructureParser(private val structure: AssistStructure) { passwordId?.let { all.add(it) } + ccNameId?.let { + all.add(it) + } + ccnId?.let { + all.add(it) + } + cvvId?.let { + all.add(it) + } return all.toTypedArray() } @@ -339,6 +471,46 @@ class StructureParser(private val structure: AssistStructure) { if (allowSaveValues && field == null) field = value } + + var ccName: String? = null + set(value) { + if (allowSaveValues) + field = value + } + + var ccNumber: String? = null + set(value) { + if (allowSaveValues) + field = value + } + + // format MMYY + var ccExpirationValue: String? = null + set(value) { + if (allowSaveValues) + field = value + } + + // for year of CC expiration date: YY + var ccExpDateYearValue = 0 + set(value) { + if (allowSaveValues) + field = value + } + + // for month of CC expiration date: MM + var ccExpDateMonthValue = 0 + set(value) { + if (allowSaveValues) + field = value + } + + // the security code for the credit card (also called CVV) + var cvv: String? = null + set(value) { + if (allowSaveValues) + field = value + } } companion object { diff --git a/app/src/main/java/com/kunzisoft/keepass/model/CreditCard.kt b/app/src/main/java/com/kunzisoft/keepass/model/CreditCard.kt new file mode 100644 index 000000000..5a6d875e0 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/model/CreditCard.kt @@ -0,0 +1,52 @@ +package com.kunzisoft.keepass.model + +import android.os.Parcel +import android.os.Parcelable + +data class CreditCard(val cardholder: String?, val number: String?, + val expiration: String?, val cvv: String?) : Parcelable { + + constructor(parcel: Parcel) : this( + parcel.readString(), + parcel.readString(), + parcel.readString(), + parcel.readString()) { + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(cardholder) + parcel.writeString(number) + parcel.writeString(expiration) + parcel.writeString(cvv) + } + + fun getExpirationMonth(): String { + return if (expiration?.length == 4) { + expiration.substring(0, 2) + } else { + "" + } + } + + fun getExpirationYear(): String { + return if (expiration?.length == 4) { + expiration.substring(2, 4) + } else { + "" + } + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): CreditCard { + return CreditCard(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/model/CreditCardCustomFields.kt b/app/src/main/java/com/kunzisoft/keepass/model/CreditCardCustomFields.kt new file mode 100644 index 000000000..2dbdeb352 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/model/CreditCardCustomFields.kt @@ -0,0 +1,35 @@ +package com.kunzisoft.keepass.model + +import android.content.Context +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.database.element.security.ProtectedString + +object CreditCardCustomFields { + const val CC_CARDHOLDER_FIELD_NAME = "CREDIT_CARD_CARDHOLDER" + const val CC_NUMBER_FIELD_NAME = "CREDIT_CARD_NUMBER" + const val CC_EXP_FIELD_NAME = "CREDIT_CARD_EXPIRATION" + const val CC_CVV_FIELD_NAME = "CREDIT_CARD_CVV" + + val CC_CUSTOM_FIELDS = arrayOf(CC_CARDHOLDER_FIELD_NAME, CC_NUMBER_FIELD_NAME, + CC_EXP_FIELD_NAME, CC_CVV_FIELD_NAME) + + fun getLocalizedName(context: Context, fieldName: String): String { + return when (fieldName) { + CC_CARDHOLDER_FIELD_NAME -> context.getString(R.string.cc_cardholder) + CC_NUMBER_FIELD_NAME -> context.getString(R.string.cc_number) + CC_EXP_FIELD_NAME -> context.getString(R.string.cc_expiration) + CC_CVV_FIELD_NAME -> context.getString(R.string.cc_security_code) + else -> fieldName + } + } + + fun buildAllFields(cardholder: String, number: String, expiration: String, cvv: String): ArrayList { + + val ccnField = Field(CC_NUMBER_FIELD_NAME, ProtectedString(false, number)) + val expirationField = Field(CC_EXP_FIELD_NAME, ProtectedString(false, expiration)) + val cvvField = Field(CC_CVV_FIELD_NAME, ProtectedString(true, cvv)) + val ccNameField = Field(CC_CARDHOLDER_FIELD_NAME, ProtectedString(false, cardholder)) + + return arrayListOf(ccNameField, ccnField, expirationField, cvvField) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt b/app/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt index 32ff5b3e9..0cc564876 100644 --- a/app/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt +++ b/app/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt @@ -39,9 +39,9 @@ class EntryInfo : NodeInfo { var attachments: List = ArrayList() var otpModel: OtpModel? = null - constructor(): super() + constructor() : super() - constructor(parcel: Parcel): super(parcel) { + constructor(parcel: Parcel) : super(parcel) { id = parcel.readString() ?: id username = parcel.readString() ?: username password = parcel.readString() ?: password @@ -133,8 +133,7 @@ class EntryInfo : NodeInfo { val webDomainToStore = "$webScheme://$webDomain" if (database?.allowEntryCustomFields() != true || url.isEmpty()) { url = webDomainToStore - } - else if (url != webDomainToStore){ + } else if (url != webDomainToStore) { // Save web domain in custom field addUniqueField(Field(WEB_DOMAIN_FIELD_NAME, ProtectedString(false, webDomainToStore)), @@ -153,6 +152,38 @@ class EntryInfo : NodeInfo { } } + fun saveRegisterInfo(database: Database?, registerInfo: RegisterInfo) { + registerInfo.username?.let { + username = it + } + registerInfo.password?.let { + password = it + } + + if (database?.allowEntryCustomFields() == true) { + val creditCard: CreditCard? = registerInfo.cc + + creditCard?.let { cc -> + cc.cardholder?.let { + val v = ProtectedString(false, it) + addUniqueField(Field(CreditCardCustomFields.CC_CARDHOLDER_FIELD_NAME, v)) + } + cc.expiration?.let { + val v = ProtectedString(false, it) + addUniqueField(Field(CreditCardCustomFields.CC_EXP_FIELD_NAME, v)) + } + cc.number?.let { + val v = ProtectedString(false, it) + addUniqueField(Field(CreditCardCustomFields.CC_NUMBER_FIELD_NAME, v)) + } + cc.cvv?.let { + val v = ProtectedString(true, it) + addUniqueField(Field(CreditCardCustomFields.CC_CVV_FIELD_NAME, v)) + } + } + } + } + companion object { const val WEB_DOMAIN_FIELD_NAME = "URL" diff --git a/app/src/main/java/com/kunzisoft/keepass/model/RegisterInfo.kt b/app/src/main/java/com/kunzisoft/keepass/model/RegisterInfo.kt index 496097e2d..67ee8a310 100644 --- a/app/src/main/java/com/kunzisoft/keepass/model/RegisterInfo.kt +++ b/app/src/main/java/com/kunzisoft/keepass/model/RegisterInfo.kt @@ -5,18 +5,21 @@ import android.os.Parcelable data class RegisterInfo(val searchInfo: SearchInfo, val username: String?, - val password: String?): Parcelable { + val password: String?, + val cc: CreditCard?): Parcelable { constructor(parcel: Parcel) : this( parcel.readParcelable(SearchInfo::class.java.classLoader) ?: SearchInfo(), parcel.readString() ?: "", - parcel.readString() ?: "") { + parcel.readString() ?: "", + parcel.readParcelable(CreditCard::class.java.classLoader)) { } override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeParcelable(searchInfo, flags) parcel.writeString(username) parcel.writeString(password) + parcel.writeParcelable(cc, flags) } override fun describeContents(): Int { diff --git a/app/src/main/java/com/kunzisoft/keepass/view/EntryContentsView.kt b/app/src/main/java/com/kunzisoft/keepass/view/EntryContentsView.kt index 3ffa96b51..c7e19986f 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/EntryContentsView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/EntryContentsView.kt @@ -42,6 +42,7 @@ import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.utils.UuidUtil import com.kunzisoft.keepass.model.EntryAttachmentState import com.kunzisoft.keepass.model.StreamDirection +import com.kunzisoft.keepass.model.CreditCardCustomFields import com.kunzisoft.keepass.otp.OtpElement import com.kunzisoft.keepass.otp.OtpType import com.kunzisoft.keepass.settings.PreferencesUtil @@ -55,6 +56,8 @@ class EntryContentsView @JvmOverloads constructor(context: Context, private var fontInVisibility: Boolean = false + private val entryFieldsContainerView: View + private val userNameFieldView: EntryField private val passwordFieldView: EntryField private val otpFieldView: EntryField @@ -66,6 +69,9 @@ class EntryContentsView @JvmOverloads constructor(context: Context, private val extraFieldsContainerView: View private val extraFieldsListView: ViewGroup + private val creditCardContainerView: View + private val creditCardFieldsListView: ViewGroup + private val creationDateView: TextView private val modificationDateView: TextView private val expiresImageView: ImageView @@ -87,6 +93,9 @@ class EntryContentsView @JvmOverloads constructor(context: Context, val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater? inflater?.inflate(R.layout.view_entry_contents, this) + entryFieldsContainerView = findViewById(R.id.entry_fields_container) + entryFieldsContainerView.visibility = View.GONE + userNameFieldView = findViewById(R.id.entry_user_name_field) userNameFieldView.setLabel(R.string.entry_user_name) @@ -107,6 +116,9 @@ class EntryContentsView @JvmOverloads constructor(context: Context, extraFieldsContainerView = findViewById(R.id.extra_fields_container) extraFieldsListView = findViewById(R.id.extra_fields_list) + creditCardContainerView = findViewById(R.id.credit_card_container) + creditCardFieldsListView = findViewById(R.id.credit_card_fields_list) + attachmentsContainerView = findViewById(R.id.entry_attachments_container) attachmentsListView = findViewById(R.id.entry_attachments_list) attachmentsListView?.apply { @@ -157,6 +169,7 @@ class EntryContentsView @JvmOverloads constructor(context: Context, visibility = View.VISIBLE setValue(userName) applyFontVisibility(fontInVisibility) + showOrHideEntryFieldsContainer(false) } else { visibility = View.GONE } @@ -173,7 +186,8 @@ class EntryContentsView @JvmOverloads constructor(context: Context, setValue(password, true) applyFontVisibility(fontInVisibility) activateCopyButton(allowCopyPassword) - }else { + showOrHideEntryFieldsContainer(false) + } else { visibility = View.GONE } assignCopyButtonClickListener(onClickListener) @@ -220,6 +234,7 @@ class EntryContentsView @JvmOverloads constructor(context: Context, } } } + showOrHideEntryFieldsContainer(false) } else { otpFieldView.visibility = View.GONE otpProgressView?.visibility = View.GONE @@ -231,6 +246,7 @@ class EntryContentsView @JvmOverloads constructor(context: Context, if (url != null && url.isNotEmpty()) { visibility = View.VISIBLE setValue(url) + showOrHideEntryFieldsContainer(false) } else { visibility = View.GONE } @@ -243,6 +259,7 @@ class EntryContentsView @JvmOverloads constructor(context: Context, visibility = View.VISIBLE setValue(notes) applyFontVisibility(fontInVisibility) + showOrHideEntryFieldsContainer(false) } else { visibility = View.GONE } @@ -283,6 +300,10 @@ class EntryContentsView @JvmOverloads constructor(context: Context, } } + private fun showOrHideEntryFieldsContainer(hide: Boolean) { + entryFieldsContainerView.visibility = if (hide) View.GONE else View.VISIBLE + } + /* ------------- * Extra Fields * ------------- @@ -292,11 +313,19 @@ class EntryContentsView @JvmOverloads constructor(context: Context, extraFieldsContainerView.visibility = if (hide) View.GONE else View.VISIBLE } + private fun showOrHideCreditCardContainer(hide: Boolean) { + creditCardContainerView.visibility = if (hide) View.GONE else View.VISIBLE + } + fun addExtraField(title: String, value: ProtectedString, allowCopy: Boolean, onCopyButtonClickListener: OnClickListener?) { + if (title in CreditCardCustomFields.CC_CUSTOM_FIELDS) { + return addExtraCCField(title, value, allowCopy, onCopyButtonClickListener) + } + val entryCustomField: EntryField? = EntryField(context) entryCustomField?.apply { setLabel(title) @@ -306,17 +335,47 @@ class EntryContentsView @JvmOverloads constructor(context: Context, assignCopyButtonClickListener(onCopyButtonClickListener) applyFontVisibility(fontInVisibility) } + entryCustomField?.let { extraFieldsListView.addView(it) } + showOrHideExtraFieldsContainer(false) } + private fun addExtraCCField(fieldName: String, + value: ProtectedString, + allowCopy: Boolean, + onCopyButtonClickListener: OnClickListener?) { + + val label = CreditCardCustomFields.getLocalizedName(context, fieldName) + + val entryCustomField: EntryField? = EntryField(context) + entryCustomField?.apply { + setLabel(label) + setValue(value.toString(), value.isProtected) + activateCopyButton(allowCopy) + assignCopyButtonClickListener(onCopyButtonClickListener) + applyFontVisibility(fontInVisibility) + } + + entryCustomField?.let { + creditCardFieldsListView.addView(it) + } + + showOrHideCreditCardContainer(false) + } + fun clearExtraFields() { extraFieldsListView.removeAllViews() showOrHideExtraFieldsContainer(true) } + fun clearCreditCardFields() { + creditCardFieldsListView.removeAllViews() + showOrHideCreditCardContainer(true) + } + /* ------------- * Attachments * ------------- @@ -332,7 +391,7 @@ class EntryContentsView @JvmOverloads constructor(context: Context, fun assignAttachments(attachments: Set, streamDirection: StreamDirection, - onAttachmentClicked: (attachment: Attachment)->Unit) { + onAttachmentClicked: (attachment: Attachment) -> Unit) { showAttachments(attachments.isNotEmpty()) attachmentsAdapter.assignItems(attachments.map { EntryAttachmentState(it, streamDirection) }) attachmentsAdapter.onItemClickListener = { item -> @@ -349,7 +408,7 @@ class EntryContentsView @JvmOverloads constructor(context: Context, * ------------- */ - fun assignHistory(history: ArrayList, action: (historyItem: Entry, position: Int)->Unit) { + fun assignHistory(history: ArrayList, action: (historyItem: Entry, position: Int) -> Unit) { historyAdapter.clear() historyAdapter.entryHistoryList.addAll(history) historyAdapter.onItemClickListener = { item, position -> diff --git a/app/src/main/java/com/kunzisoft/keepass/view/EntryField.kt b/app/src/main/java/com/kunzisoft/keepass/view/EntryField.kt index 1c49ec078..238de6883 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/EntryField.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/EntryField.kt @@ -20,6 +20,7 @@ package com.kunzisoft.keepass.view import android.content.Context +import android.graphics.Color import android.text.util.Linkify import android.util.AttributeSet import android.view.LayoutInflater @@ -31,6 +32,7 @@ import androidx.annotation.StringRes import androidx.core.text.util.LinkifyCompat import com.kunzisoft.keepass.R import com.kunzisoft.keepass.model.EntryInfo.Companion.APPLICATION_ID_FIELD_NAME +import com.kunzisoft.keepass.model.CreditCardCustomFields import com.kunzisoft.keepass.utils.UriUtil class EntryField @JvmOverloads constructor(context: Context, diff --git a/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt b/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt index df734a796..af8725ed2 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt @@ -45,7 +45,7 @@ import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.tasks.ActionRunnable /** - * Replace font by monospace, must be called after seText() + * Replace font by monospace, must be called after setText() */ fun TextView.applyFontVisibility() { val typeFace = Typeface.createFromAsset(context.assets, "fonts/FiraMono-Regular.ttf") diff --git a/app/src/main/res/drawable/ic_baseline_credit_card_24.xml b/app/src/main/res/drawable/ic_baseline_credit_card_24.xml new file mode 100644 index 000000000..9c0fb1f1e --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_credit_card_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_entry_edit.xml b/app/src/main/res/layout/activity_entry_edit.xml index f5faa0949..703610489 100644 --- a/app/src/main/res/layout/activity_entry_edit.xml +++ b/app/src/main/res/layout/activity_entry_edit.xml @@ -83,6 +83,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:contentDescription="@string/validate" + android:layout_marginBottom="?attr/actionBarSize" android:src="@drawable/ic_check_white_24dp" app:fabSize="mini" app:layout_constraintTop_toTopOf="@+id/entry_edit_bottom_bar" diff --git a/app/src/main/res/layout/entry_cc_details_dialog.xml b/app/src/main/res/layout/entry_cc_details_dialog.xml new file mode 100644 index 000000000..84e4d46de --- /dev/null +++ b/app/src/main/res/layout/entry_cc_details_dialog.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_entry_contents.xml b/app/src/main/res/layout/view_entry_contents.xml index 48164dfa5..81c908cd4 100644 --- a/app/src/main/res/layout/view_entry_contents.xml +++ b/app/src/main/res/layout/view_entry_contents.xml @@ -25,6 +25,7 @@ android:orientation="vertical"> + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 62458a681..b47488e09 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -363,12 +363,17 @@ Sicherheit Verlauf Einmalpasswort einrichten + Kreditkarte hinzufügen OTP-Typ Geheimnis Zeitraum (Sekunden) Zähler Stellen Algorithmus + Karteninhaber + Kreditkartennummer + Gültig bis + Prüfnummer OTP Ungültiges OTP-Geheimnis. Mindestens eine Anmeldeinformation muss festgelegt sein. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c3e1c39c..70b08a95b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,12 +93,17 @@ Save Title Set up one-time password + Edit credit card details OTP type Secret Period (seconds) Counter Digits Algorithm + Cardholder + Credit Card Number + Expiration Date + CVV OTP URL Username @@ -536,6 +541,31 @@ KiB MiB GiB + + 5 seconds + 10 seconds + 20 seconds + 30 seconds + 1 minute + 5 minutes + 15 minutes + 30 minutes + Never + + + 5 minutes + 15 minutes + 30 minutes + 1 hour + 2 hours + 5 hours + 10 hours + 24 hours + 48 hours + 1 week + 1 month + Never + Small Medium