diff --git a/app/proguard-ci-rules.pro b/app/proguard-ci-rules.pro index 156b275c..7c290e93 100644 --- a/app/proguard-ci-rules.pro +++ b/app/proguard-ci-rules.pro @@ -13,3 +13,13 @@ # view ids the tests look up but the app code doesn't reference (only used in layouts), # only R$id, keeping all R classes would keep every resource and disable the resource shrinking -keep class es.wolfi.app.passman.R$id { public static ; } + +# autofill methods the tests call directly, R8 would inline them into their only caller +-keep class es.wolfi.app.passman.autofill.AutofillHelper { + public static *** newCredentialForRequester(...); + public static *** findMatchingCredentials(...); +} +-keep class es.wolfi.app.passman.autofill.AutofillHelper$WebDomainResult { + public (); + public void addDomain(...); +} diff --git a/app/src/androidTest/java/es/wolfi/app/passman/offline/AutofillMatchingTest.java b/app/src/androidTest/java/es/wolfi/app/passman/offline/AutofillMatchingTest.java new file mode 100644 index 00000000..1714d3df --- /dev/null +++ b/app/src/androidTest/java/es/wolfi/app/passman/offline/AutofillMatchingTest.java @@ -0,0 +1,108 @@ +/** + * Passman Android App + * + * @copyright Copyright (c) 2026, Timo Triebensky (timo@binsky.org) + * @license GNU AGPL version 3 or any later version + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package es.wolfi.app.passman.offline; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.Collections; + +import es.wolfi.app.passman.adapters.CustomFieldEditAdapter; +import es.wolfi.app.passman.autofill.AutofillHelper; +import es.wolfi.app.passman.testutil.TestVault; +import es.wolfi.passman.API.Credential; +import es.wolfi.passman.API.Vault; + +/** + * A credential added from the autofill manual search must be matched the next time the same website or app requests autofill. + */ +@RunWith(AndroidJUnit4.class) +public class AutofillMatchingTest { + private static final String BROWSER = "com.android.chrome"; + private static final String OTHER_BROWSER = "org.mozilla.firefox"; + private static final String APP = "com.example.app"; + + private Vault vault; + + @Before + public void unlockVault() throws Exception { + TestVault testVault = TestVault.get(); + vault = Vault.fromJSON(testVault.apiVaultJson()); + assertTrue(vault.unlock(testVault.vaultPassword())); + } + + @Test + public void websiteCredentialIsMatchedByItsDomain() { + Credential credential = addedCredential(website("gitlab.com", "https"), BROWSER); + + assertEquals("https://gitlab.com", credential.getUrl()); + assertTrue(matches(credential, website("gitlab.com", "https"), BROWSER)); + assertTrue("the domain matches in every browser", matches(credential, website("gitlab.com", "https"), OTHER_BROWSER)); + assertFalse(matches(credential, website("gitlab.example.com", "https"), BROWSER)); + } + + @Test + public void websiteCredentialWithoutSchemeIsMatchedByItsDomain() { + // the web scheme is only available since Android 9 + Credential credential = addedCredential(website("gitlab.com", null), BROWSER); + + assertEquals("gitlab.com", credential.getUrl()); + assertTrue(matches(credential, website("gitlab.com", null), BROWSER)); + assertFalse(matches(credential, website("gitlab.example.com", null), BROWSER)); + } + + @Test + public void appCredentialIsMatchedByItsPackageName() { + Credential credential = addedCredential(new AutofillHelper.WebDomainResult(), APP); + + assertTrue(matches(credential, new AutofillHelper.WebDomainResult(), APP)); + assertFalse(matches(credential, new AutofillHelper.WebDomainResult(), "com.example.other")); + assertFalse("an app credential must not match websites", matches(credential, website("gitlab.com", "https"), BROWSER)); + } + + /** + * The credential as the add form saves it, the custom fields are written back by its adapter. + */ + private Credential addedCredential(AutofillHelper.WebDomainResult domain, String requesterPackageName) { + Credential credential = AutofillHelper.newCredentialForRequester(vault, domain, requesterPackageName); + credential.setCustomFields(new CustomFieldEditAdapter(credential).getCustomFieldsString()); + return credential; + } + + private static AutofillHelper.WebDomainResult website(String domain, String scheme) { + AutofillHelper.WebDomainResult result = new AutofillHelper.WebDomainResult(); + result.addDomain(domain, scheme); + return result; + } + + private static boolean matches(Credential credential, AutofillHelper.WebDomainResult domain, String requesterPackageName) { + ArrayList credentials = new ArrayList<>(Collections.singletonList(credential)); + return AutofillHelper.findMatchingCredentials(credentials, requesterPackageName, domain).contains(credential); + } +} diff --git a/app/src/main/java/es/wolfi/app/ResponseHandlers/AutofillCredentialSaveResponseHandler.java b/app/src/main/java/es/wolfi/app/ResponseHandlers/AutofillCredentialSaveResponseHandler.java index ab05f3e1..88068439 100644 --- a/app/src/main/java/es/wolfi/app/ResponseHandlers/AutofillCredentialSaveResponseHandler.java +++ b/app/src/main/java/es/wolfi/app/ResponseHandlers/AutofillCredentialSaveResponseHandler.java @@ -30,6 +30,8 @@ import android.util.Log; import android.widget.Toast; +import androidx.annotation.Nullable; + import com.loopj.android.http.AsyncHttpResponseHandler; import org.json.JSONException; @@ -51,8 +53,14 @@ public class AutofillCredentialSaveResponseHandler extends AsyncHttpResponseHand private final Context applicationContext; private final SingleTon ton; private final String TAG; + @Nullable + private final OnCredentialSavedListener listener; public AutofillCredentialSaveResponseHandler(Vault vault, Context baseContext, Context applicationContext, SingleTon ton, String TAG) { + this(vault, baseContext, applicationContext, ton, TAG, null); + } + + public AutofillCredentialSaveResponseHandler(Vault vault, Context baseContext, Context applicationContext, SingleTon ton, String TAG, @Nullable OnCredentialSavedListener listener) { super(); this.vault = vault; @@ -60,6 +68,7 @@ public AutofillCredentialSaveResponseHandler(Vault vault, Context baseContext, C this.applicationContext = applicationContext; this.ton = ton; this.TAG = TAG; + this.listener = listener; } @Override @@ -75,9 +84,14 @@ public void run() { if (credentialObject.has("credential_id") && credentialObject.getInt("vault_id") == vault.vault_id) { Credential currentCredential = Credential.fromJSON(credentialObject, vault); vault.addCredential(currentCredential); - ((HashMap) ton.getExtra(SettingValues.VAULTS.toString())).put(vault.guid, vault); + + // vaults and active vault are not loaded if the app process was started by the autofill service + HashMap vaults = (HashMap) ton.getExtra(SettingValues.VAULTS.toString()); + if (vaults != null) { + vaults.put(vault.guid, vault); + } Vault activeVault = (Vault) SingleTon.getTon().getExtra(SettingValues.ACTIVE_VAULT.toString()); - if (vault.guid.equals(activeVault.guid)) { + if (activeVault != null && vault.guid.equals(activeVault.guid)) { ton.addExtra(SettingValues.ACTIVE_VAULT.toString(), vault); } @@ -86,6 +100,9 @@ public void run() { Toast.makeText(applicationContext, applicationContext.getString(R.string.successfully_saved), Toast.LENGTH_SHORT).show(); Log.d(TAG, applicationContext.getString(R.string.successfully_saved)); + if (listener != null) { + listener.onCredentialSaved(currentCredential); + } return; } } catch (JSONException e) { @@ -95,6 +112,7 @@ public void run() { Toast.makeText(applicationContext, applicationContext.getString(R.string.error_occurred), Toast.LENGTH_SHORT).show(); Log.d(TAG, "onSaveRequest(), failed to save: " + R.string.error_occurred); } + notifySaveFailed(); } }); } @@ -111,6 +129,8 @@ public void onFailure(int statusCode, cz.msebera.android.httpclient.Header[] hea new Handler(Looper.getMainLooper()).post(new Runnable() { @Override public void run() { + notifySaveFailed(); + if (!finalResponse.equals("") && JSONUtils.isJSONObject(finalResponse)) { try { JSONObject o = new JSONObject(finalResponse); @@ -138,8 +158,20 @@ public void run() { }); } + private void notifySaveFailed() { + if (listener != null) { + listener.onCredentialSaveFailed(); + } + } + @Override public void onRetry(int retryNo) { // called when request is retried } + + public interface OnCredentialSavedListener { + void onCredentialSaved(Credential credential); + + void onCredentialSaveFailed(); + } } diff --git a/app/src/main/java/es/wolfi/app/passman/activities/AutofillInteractionActivity.java b/app/src/main/java/es/wolfi/app/passman/activities/AutofillInteractionActivity.java index 1c1176ef..87d6fc52 100644 --- a/app/src/main/java/es/wolfi/app/passman/activities/AutofillInteractionActivity.java +++ b/app/src/main/java/es/wolfi/app/passman/activities/AutofillInteractionActivity.java @@ -23,7 +23,7 @@ import static android.view.autofill.AutofillManager.EXTRA_AUTHENTICATION_RESULT; -import android.app.assist.AssistStructure; +import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Bundle; @@ -44,6 +44,7 @@ import es.wolfi.app.passman.autofill.AutofillFieldCollection; import es.wolfi.app.passman.autofill.AutofillHelper; import es.wolfi.app.passman.databinding.ActivityAutofillInteractionBinding; +import es.wolfi.app.passman.fragments.CredentialAddFragment; import es.wolfi.app.passman.fragments.CredentialItemFragment; import es.wolfi.app.passman.fragments.VaultLockScreenFragment; import es.wolfi.passman.API.Credential; @@ -52,11 +53,18 @@ @RequiresApi(api = Build.VERSION_CODES.O) public class AutofillInteractionActivity extends BaseActivity implements VaultLockScreenFragment.VaultUnlockInteractionListener, - CredentialItemFragment.OnListFragmentInteractionListener { + CredentialItemFragment.OnListFragmentInteractionListener, + CredentialAddFragment.OnCredentialAddedListener { public final static String LOG_TAG = "AutofillInteractionAct."; public static final int REQUEST_CODE_AUTOFILL_PLACEHOLDER = 1; + private static final String EXTRA_PACKAGE_NAME = "packageName"; + private static final String EXTRA_REQUESTER_PACKAGE_NAME = "requesterPackageName"; + private static final String EXTRA_FIELDS = "fields"; + private static final String EXTRA_WEB_DOMAIN = "webDomain"; + private static final String EXTRA_WEB_SCHEME = "webScheme"; + public enum CustomAutofillIntentActions { VAULT_UNLOCK("VAULT_UNLOCK"), MANUAL_SEARCH("MANUAL_SEARCH"); @@ -68,9 +76,29 @@ public enum CustomAutofillIntentActions { } } - private ArrayList structures; + private AutofillFieldCollection fields; + private AutofillHelper.WebDomainResult domain; private String packageName; private String requesterPackageName; + private Vault vault; + + /** + * The activity gets the already parsed fields and web domain instead of the AssistStructures. + * Reading AssistStructures that were passed on by the autofill service crashes on MIUI + * (SecurityException in AssistStructure$ParcelTransferWriter). + */ + public static Intent newIntent(Context context, CustomAutofillIntentActions action, String packageName, + String requesterPackageName, AutofillFieldCollection fields, + AutofillHelper.WebDomainResult domain) { + Intent intent = new Intent(context, AutofillInteractionActivity.class); + intent.setAction(action.name); + intent.putExtra(EXTRA_PACKAGE_NAME, packageName); + intent.putExtra(EXTRA_REQUESTER_PACKAGE_NAME, requesterPackageName); + intent.putParcelableArrayListExtra(EXTRA_FIELDS, fields.toBundles()); + intent.putExtra(EXTRA_WEB_DOMAIN, domain.firstDomain); + intent.putExtra(EXTRA_WEB_SCHEME, domain.firstScheme); + return intent; + } @Override protected void onCreate(Bundle savedInstanceState) { @@ -85,12 +113,15 @@ protected void onCreate(Bundle savedInstanceState) { Intent intent = getIntent(); if (intent != null) { - structures = intent.getParcelableArrayListExtra("structures"); - packageName = intent.getStringExtra("packageName"); - requesterPackageName = intent.getStringExtra("requesterPackageName"); + ArrayList fieldBundles = intent.getParcelableArrayListExtra(EXTRA_FIELDS); + fields = AutofillFieldCollection.fromBundles(fieldBundles); + domain = new AutofillHelper.WebDomainResult(); + domain.addDomain(intent.getStringExtra(EXTRA_WEB_DOMAIN), intent.getStringExtra(EXTRA_WEB_SCHEME)); + packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME); + requesterPackageName = intent.getStringExtra(EXTRA_REQUESTER_PACKAGE_NAME); SingleTon ton = SingleTon.getTon(); - Vault vault = AutofillHelper.getAutofillVault(ton, getBaseContext()); + vault = AutofillHelper.getAutofillVault(ton, getBaseContext()); if (intent.getAction().equals(CustomAutofillIntentActions.VAULT_UNLOCK.name)) { Log.d(LOG_TAG, "open VaultLockScreenFragment"); @@ -106,7 +137,7 @@ protected void onCreate(Bundle savedInstanceState) { getSupportFragmentManager() .beginTransaction() .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left, R.anim.slide_out_left, R.anim.slide_out_left) - .replace(R.id.frameContainer, new CredentialItemFragment(vault, true), "vault") + .replace(R.id.frameContainer, new CredentialItemFragment(vault), "vault") .commitNow(); } } @@ -115,20 +146,18 @@ protected void onCreate(Bundle savedInstanceState) { @Override public void onVaultUnlock(Vault vault) { Log.d(LOG_TAG, "onVaultUnlock " + vault.name); + this.vault = vault; FillResponse.Builder fillResponse = new FillResponse.Builder(); Intent replyIntent = new Intent(); - // Find autofillable fields - AutofillFieldCollection fields = AutofillHelper.getAutofillableFields(structures.get(structures.size() - 1), false); - Set tempFields = AutofillHelper.fillResponseForDecryptedVault( fillResponse, vault, packageName, requesterPackageName, fields, - structures + domain ); if (tempFields.size() > 0) { @@ -146,7 +175,7 @@ public void onVaultUnlock(Vault vault) { getSupportFragmentManager() .beginTransaction() .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left, R.anim.slide_out_left, R.anim.slide_out_left) - .replace(R.id.frameContainer, new CredentialItemFragment(vault, true), "vault") + .replace(R.id.frameContainer, new CredentialItemFragment(vault), "vault") .commitNow(); } } @@ -158,9 +187,6 @@ public void onListFragmentInteraction(Credential item) { FillResponse.Builder fillResponse = new FillResponse.Builder(); Intent replyIntent = new Intent(); - // Find autofillable fields - AutofillFieldCollection fields = AutofillHelper.getAutofillableFields(structures.get(structures.size() - 1), false); - Set tempFields = AutofillHelper.fillResponseForExplicitCredential( fillResponse, item, @@ -182,6 +208,24 @@ public void onListFragmentInteraction(Credential item) { finish(); } + @Override + public void onAddCredentialRequested() { + Credential credential = AutofillHelper.newCredentialForRequester(vault, domain, requesterPackageName); + + getSupportFragmentManager() + .beginTransaction() + .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left, R.anim.slide_in_left, R.anim.slide_out_right) + .replace(R.id.frameContainer, CredentialAddFragment.newInstance(credential), "credentialAdd") + .addToBackStack(null) + .commit(); + } + + @Override + public void onCredentialAdded(Credential credential) { + // select the new credential for autofill like a manually selected one + onListFragmentInteraction(credential); + } + @Override //unused// public void setLastCredentialListPosition(int pos) { diff --git a/app/src/main/java/es/wolfi/app/passman/activities/BaseActivity.java b/app/src/main/java/es/wolfi/app/passman/activities/BaseActivity.java index 3f1e03aa..61e9b7b7 100644 --- a/app/src/main/java/es/wolfi/app/passman/activities/BaseActivity.java +++ b/app/src/main/java/es/wolfi/app/passman/activities/BaseActivity.java @@ -1,23 +1,39 @@ package es.wolfi.app.passman.activities; +import android.content.Intent; import android.content.SharedPreferences; +import android.net.Uri; import android.os.Bundle; +import android.os.ParcelFileDescriptor; import android.preference.PreferenceManager; +import android.util.Base64; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import org.json.JSONException; + +import java.io.FileInputStream; +import java.io.IOException; + import es.wolfi.app.passman.PassmanApp; import es.wolfi.app.passman.R; import es.wolfi.app.passman.SettingValues; import es.wolfi.app.passman.VaultLockManager; +import es.wolfi.app.passman.fragments.CredentialAddFragment; +import es.wolfi.app.passman.fragments.CredentialEditFragment; +import es.wolfi.utils.FileUtils; public abstract class BaseActivity extends AppCompatActivity { + public static final int REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_EDIT = 7; + public static final int REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_ADD = 8; + private View countdownOverlay; @Override @@ -50,6 +66,112 @@ public void applyScreenshotProtection() { } } + public void selectFileToAdd(int activityRequestFileCode) { + //new Intent("android.intent.action.GET_CONTENT").addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"); + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"); + + // Optionally, specify a URI for the directory that should be opened in + // the system file picker when your app creates the document. + //intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri); + + startActivityForResult(intent, activityRequestFileCode); + } + + public void scanQRCodeForOTP(int requestCode) { + startActivityForResult(new Intent(this, ScanQRCodeActivity.class), requestCode); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_EDIT) { // scan qr code as otp config in credential edit + if (resultCode != RESULT_OK) { + return; + } + + CredentialEditFragment credentialEditFragment = (CredentialEditFragment) getSupportFragmentManager().findFragmentByTag("credentialEdit"); + if (credentialEditFragment != null) { + credentialEditFragment.processScannedQRCodeData(data.getData().toString()); + } + } + + if (requestCode == REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_ADD) { // scan qr code as otp config in credential add + if (resultCode != RESULT_OK) { + return; + } + + CredentialAddFragment credentialAddFragment = (CredentialAddFragment) getSupportFragmentManager().findFragmentByTag("credentialAdd"); + if (credentialAddFragment != null) { + credentialAddFragment.processScannedQRCodeData(data.getData().toString()); + } + } + + // Following cases should only be handled on positive result + if (resultCode != RESULT_OK) + return; + + if (requestCode >= FileUtils.activityRequestFileCode.credentialEditFile.ordinal() && requestCode <= FileUtils.activityRequestFileCode.credentialAddCustomFieldFile.ordinal()) { //add file + if (data != null) { + Uri uri = data.getData(); + + if (uri != null) { + try { + ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(uri, "r"); + if (pfd != null) { + FileInputStream fileInputStream = new FileInputStream(pfd.getFileDescriptor()); + int fileSize = fileInputStream.available(); + byte[] fileContent = new byte[fileSize]; + + int num = fileInputStream.read(fileContent); + fileInputStream.close(); + String realEncodedFile = Base64.encodeToString(fileContent, Base64.DEFAULT | Base64.NO_WRAP); + pfd.close(); + + String mimeType = getContentResolver().getType(uri); + String fileName = "unknown"; + String filePathFromUri = FileUtils.getPath(this, uri); + if (filePathFromUri != null) { + java.io.File file = new java.io.File(filePathFromUri); + fileName = file.getName(); + } + + try { + String encodedFile = String.format("data:%s;base64,%s", mimeType, realEncodedFile); + if (requestCode == FileUtils.activityRequestFileCode.credentialEditFile.ordinal() || requestCode == FileUtils.activityRequestFileCode.credentialEditCustomFieldFile.ordinal()) { + CredentialEditFragment credentialEditFragment = (CredentialEditFragment) getSupportFragmentManager().findFragmentByTag("credentialEdit"); + + // generalize requestCode for usage with generalized ResponseHandler instances + if (requestCode == FileUtils.activityRequestFileCode.credentialEditCustomFieldFile.ordinal()) { + requestCode = FileUtils.activityRequestFileCode.credentialAddCustomFieldFile.ordinal(); + } else { + requestCode = FileUtils.activityRequestFileCode.credentialAddFile.ordinal(); + } + + if (credentialEditFragment != null) { + credentialEditFragment.addSelectedFile(encodedFile, fileName, mimeType, fileSize, requestCode); + } + } else if (requestCode == FileUtils.activityRequestFileCode.credentialAddFile.ordinal() || requestCode == FileUtils.activityRequestFileCode.credentialAddCustomFieldFile.ordinal()) { + CredentialAddFragment credentialAddFragment = (CredentialAddFragment) getSupportFragmentManager().findFragmentByTag("credentialAdd"); + if (credentialAddFragment != null) { + credentialAddFragment.addSelectedFile(encodedFile, fileName, mimeType, fileSize, requestCode); + } + } + return; + } catch (JSONException e) { + e.printStackTrace(); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + } + Toast.makeText(getApplicationContext(), getString(R.string.error_occurred), Toast.LENGTH_SHORT).show(); + } + } + public void showCountdownOverlay(int secondsLeft) { runOnUiThread(() -> { if (countdownOverlay == null) { diff --git a/app/src/main/java/es/wolfi/app/passman/activities/PasswordListActivity.java b/app/src/main/java/es/wolfi/app/passman/activities/PasswordListActivity.java index 6bdcc371..95e8f8c6 100644 --- a/app/src/main/java/es/wolfi/app/passman/activities/PasswordListActivity.java +++ b/app/src/main/java/es/wolfi/app/passman/activities/PasswordListActivity.java @@ -65,7 +65,6 @@ import org.json.JSONException; -import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.Collections; @@ -81,7 +80,6 @@ import es.wolfi.app.passman.VaultLockManager; import es.wolfi.app.passman.fragments.CredentialAddFragment; import es.wolfi.app.passman.fragments.CredentialDisplayFragment; -import es.wolfi.app.passman.fragments.CredentialEditFragment; import es.wolfi.app.passman.fragments.CredentialItemFragment; import es.wolfi.app.passman.fragments.SettingsFragment; import es.wolfi.app.passman.fragments.VaultFragment; @@ -91,7 +89,6 @@ import es.wolfi.passman.API.File; import es.wolfi.passman.API.Vault; import es.wolfi.utils.DeviceCredentialAuthentication; -import es.wolfi.utils.FileUtils; import es.wolfi.utils.KeyStoreUtils; import es.wolfi.utils.LocalNetworkPermission; import es.wolfi.utils.ProgressUtils; @@ -108,8 +105,6 @@ public class PasswordListActivity extends BaseActivity implements private static final int REQUEST_CODE_KEYGUARD = 0; private static final int REQUEST_CODE_AUTHENTICATE = 1; private static final int REQUEST_CODE_CREATE_DOCUMENT = 2; - public static final int REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_EDIT = 7; - public static final int REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_ADD = 8; static boolean running = false; @@ -741,6 +736,16 @@ public void onVaultUnlock(Vault vault) { showActiveVault(); } + @Override + public void onAddCredentialRequested() { + getSupportFragmentManager() + .beginTransaction() + .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left, R.anim.slide_in_left, R.anim.slide_out_right) + .replace(R.id.content_password_list, CredentialAddFragment.newInstance(), "credentialAdd") + .addToBackStack(null) + .commit(); + } + @Override public void onCredentialFragmentInteraction(Credential credential) { } @@ -795,22 +800,6 @@ public void onCompleted(Exception e, String decryptedFileDataField) { item.download(getApplicationContext(), progress, offerDownloadCallback); } - public void selectFileToAdd(int activityRequestFileCode) { - //new Intent("android.intent.action.GET_CONTENT").addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"); - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"); - - // Optionally, specify a URI for the directory that should be opened in - // the system file picker when your app creates the document. - //intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri); - - startActivityForResult(intent, activityRequestFileCode); - } - - public void scanQRCodeForOTP(int requestCode) { - startActivityForResult(new Intent(this, ScanQRCodeActivity.class), requestCode); - } - /** * The server of the manual login, null when logged in with Nextcloud SSO (the Nextcloud files app connects then). */ @@ -852,28 +841,6 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } } - if (requestCode == REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_EDIT) { // scan qr code as otp config in credential edit - if (resultCode != RESULT_OK) { - return; - } - - CredentialEditFragment credentialEditFragment = (CredentialEditFragment) getSupportFragmentManager().findFragmentByTag("credentialEdit"); - if (credentialEditFragment != null) { - credentialEditFragment.processScannedQRCodeData(data.getData().toString()); - } - } - - if (requestCode == REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_ADD) { // scan qr code as otp config in credential add - if (resultCode != RESULT_OK) { - return; - } - - CredentialAddFragment credentialAddFragment = (CredentialAddFragment) getSupportFragmentManager().findFragmentByTag("credentialAdd"); - if (credentialAddFragment != null) { - credentialAddFragment.processScannedQRCodeData(data.getData().toString()); - } - } - // Following cases should only be handled on positive result if (resultCode != RESULT_OK) return; @@ -902,65 +869,6 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } Toast.makeText(getApplicationContext(), getString(R.string.error_writing_file), Toast.LENGTH_SHORT).show(); } - - if (requestCode >= FileUtils.activityRequestFileCode.credentialEditFile.ordinal() && requestCode <= FileUtils.activityRequestFileCode.credentialAddCustomFieldFile.ordinal()) { //add file - if (data != null) { - Uri uri = data.getData(); - - if (uri != null) { - try { - ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(uri, "r"); - if (pfd != null) { - FileInputStream fileInputStream = new FileInputStream(pfd.getFileDescriptor()); - int fileSize = fileInputStream.available(); - byte[] fileContent = new byte[fileSize]; - - int num = fileInputStream.read(fileContent); - fileInputStream.close(); - String realEncodedFile = Base64.encodeToString(fileContent, Base64.DEFAULT | Base64.NO_WRAP); - pfd.close(); - - String mimeType = getContentResolver().getType(uri); - String fileName = "unknown"; - String filePathFromUri = FileUtils.getPath(this, uri); - if (filePathFromUri != null) { - java.io.File file = new java.io.File(filePathFromUri); - fileName = file.getName(); - } - - try { - String encodedFile = String.format("data:%s;base64,%s", mimeType, realEncodedFile); - if (requestCode == FileUtils.activityRequestFileCode.credentialEditFile.ordinal() || requestCode == FileUtils.activityRequestFileCode.credentialEditCustomFieldFile.ordinal()) { - CredentialEditFragment credentialEditFragment = (CredentialEditFragment) getSupportFragmentManager().findFragmentByTag("credentialEdit"); - - // generalize requestCode for usage with generalized ResponseHandler instances - if (requestCode == FileUtils.activityRequestFileCode.credentialEditCustomFieldFile.ordinal()) { - requestCode = FileUtils.activityRequestFileCode.credentialAddCustomFieldFile.ordinal(); - } else { - requestCode = FileUtils.activityRequestFileCode.credentialAddFile.ordinal(); - } - - if (credentialEditFragment != null) { - credentialEditFragment.addSelectedFile(encodedFile, fileName, mimeType, fileSize, requestCode); - } - } else if (requestCode == FileUtils.activityRequestFileCode.credentialAddFile.ordinal() || requestCode == FileUtils.activityRequestFileCode.credentialAddCustomFieldFile.ordinal()) { - CredentialAddFragment credentialAddFragment = (CredentialAddFragment) getSupportFragmentManager().findFragmentByTag("credentialAdd"); - if (credentialAddFragment != null) { - credentialAddFragment.addSelectedFile(encodedFile, fileName, mimeType, fileSize, requestCode); - } - } - return; - } catch (JSONException e) { - e.printStackTrace(); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - } - } - Toast.makeText(getApplicationContext(), getString(R.string.error_occurred), Toast.LENGTH_SHORT).show(); - } } private void checkFragmentPosition(boolean positive) { diff --git a/app/src/main/java/es/wolfi/app/passman/autofill/AutofillField.java b/app/src/main/java/es/wolfi/app/passman/autofill/AutofillField.java index 8e2d5bc1..f373824d 100644 --- a/app/src/main/java/es/wolfi/app/passman/autofill/AutofillField.java +++ b/app/src/main/java/es/wolfi/app/passman/autofill/AutofillField.java @@ -24,6 +24,7 @@ import android.app.assist.AssistStructure; import android.os.Build; +import android.os.Bundle; import android.util.Log; import android.util.Pair; import android.view.View; @@ -35,6 +36,7 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; @@ -50,12 +52,20 @@ public class AutofillField implements Comparable { public static final int AUTOFILL_DISCOVERY_TYPE_RESOURCEID = 8; public static final int AUTOFILL_DISCOVERY_TYPE_TEXT = 16; + private static final String BUNDLE_ID = "id"; + private static final String BUNDLE_VALUE = "value"; + private static final String BUNDLE_HINTS = "hints"; + private static final String BUNDLE_FOCUSED = "focused"; + private static final String BUNDLE_DISCOVERY_TYPES = "discoveryTypes"; + private HashSet hints; private AutofillId autofillid; private AutofillValue autofillValue; private boolean focused; private int discoveryTypes; + private AutofillField() { + } @RequiresApi(api = Build.VERSION_CODES.O) public AutofillField(AutofillId id, AssistStructure.ViewNode viewNode) throws Exception { @@ -89,6 +99,32 @@ public AutofillField(AutofillValue value, AssistStructure.ViewNode viewNode) thr } } + /** + * Fields passed within a PendingIntent must only contain framework classes, + * the system unparcels its extras without access to the classes of this app. + * + * @return the field as Bundle, readable with fromBundle() + */ + public Bundle toBundle() { + Bundle bundle = new Bundle(); + bundle.putParcelable(BUNDLE_ID, autofillid); + bundle.putParcelable(BUNDLE_VALUE, autofillValue); + bundle.putStringArrayList(BUNDLE_HINTS, new ArrayList<>(hints)); + bundle.putBoolean(BUNDLE_FOCUSED, focused); + bundle.putInt(BUNDLE_DISCOVERY_TYPES, discoveryTypes); + return bundle; + } + + public static AutofillField fromBundle(@NonNull Bundle bundle) { + AutofillField field = new AutofillField(); + field.autofillid = bundle.getParcelable(BUNDLE_ID); + field.autofillValue = bundle.getParcelable(BUNDLE_VALUE); + field.hints = new HashSet<>(bundle.getStringArrayList(BUNDLE_HINTS)); + field.focused = bundle.getBoolean(BUNDLE_FOCUSED); + field.discoveryTypes = bundle.getInt(BUNDLE_DISCOVERY_TYPES); + return field; + } + public boolean isFocused() { return focused; } diff --git a/app/src/main/java/es/wolfi/app/passman/autofill/AutofillFieldCollection.java b/app/src/main/java/es/wolfi/app/passman/autofill/AutofillFieldCollection.java index e4160921..6b01b9a4 100644 --- a/app/src/main/java/es/wolfi/app/passman/autofill/AutofillFieldCollection.java +++ b/app/src/main/java/es/wolfi/app/passman/autofill/AutofillFieldCollection.java @@ -22,11 +22,34 @@ package es.wolfi.app.passman.autofill; +import android.os.Bundle; + +import androidx.annotation.NonNull; + import java.util.ArrayList; import java.util.Collections; import java.util.List; public class AutofillFieldCollection extends ArrayList { + /** + * @return the fields as Bundles to pass them within a PendingIntent (see AutofillField.toBundle()) + */ + public ArrayList toBundles() { + ArrayList bundles = new ArrayList<>(); + for (AutofillField field : this) { + bundles.add(field.toBundle()); + } + return bundles; + } + + public static AutofillFieldCollection fromBundles(@NonNull List bundles) { + AutofillFieldCollection fields = new AutofillFieldCollection(); + for (Bundle bundle : bundles) { + fields.add(AutofillField.fromBundle(bundle)); + } + return fields; + } + public AutofillField getRequiredId(String hint) { // Order: Android Hint, HTMLInfo, View Hint, Resource id // Focused First diff --git a/app/src/main/java/es/wolfi/app/passman/autofill/AutofillHelper.java b/app/src/main/java/es/wolfi/app/passman/autofill/AutofillHelper.java index fb8b7546..5a0746af 100644 --- a/app/src/main/java/es/wolfi/app/passman/autofill/AutofillHelper.java +++ b/app/src/main/java/es/wolfi/app/passman/autofill/AutofillHelper.java @@ -40,6 +40,7 @@ import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; import org.json.JSONArray; import org.json.JSONException; @@ -63,6 +64,7 @@ public class AutofillHelper { private static final String LOG_TAG = "AutofillHelper"; private static final int MAX_DATASETS = 4; + public static final String PACKAGE_NAME_CUSTOM_FIELD_LABEL = "androidCredPackageName"; /** * Returns the autofill vault, if there was one selected in the app settings. @@ -103,7 +105,7 @@ public static Vault getAutofillVault(SingleTon ton, Context context) { * @param packageName Passman package name * @param requesterPackageName package name of the app that was requesting the autofill service * @param fields fillable fields - * @param structures + * @param domain web domain of the requester * @return set of tempFields */ public static Set fillResponseForDecryptedVault( @@ -112,14 +114,12 @@ public static Set fillResponseForDecryptedVault( String packageName, String requesterPackageName, AutofillFieldCollection fields, - ArrayList structures + @NonNull WebDomainResult domain ) { AutofillField bestUsername = AutofillHelper.getUsernameField(fields); AutofillField bestEmail = AutofillHelper.getEmailField(fields); AutofillField bestPassword = AutofillHelper.getPasswordField(fields); - WebDomainResult domain = getLikelyDomain(structures); - // Grab Credentials from vault ArrayList allCred = vault.getCredentials(); @@ -290,7 +290,8 @@ private static String returnBestString(@NonNull String... usernameOptions) { } public static class WebDomainResult { - String firstDomain = null; + public String firstDomain = null; + public String firstScheme = null; HashSet allDomains; public WebDomainResult() { @@ -298,7 +299,7 @@ public WebDomainResult() { Log.d(LOG_TAG, "Web Domain Result constructed"); } - public void addDomain(String domain) { + public void addDomain(String domain, String scheme) { if (TextUtils.isEmpty(domain)) { return; } @@ -308,7 +309,20 @@ public void addDomain(String domain) { if (firstDomain == null) { firstDomain = domain; + firstScheme = scheme; + } + } + + /** + * The autofill framework only provides the domain and (since Android 9) the scheme of a website, not its path + * + * @return the first domain, prefixed with its scheme if known + */ + public String getFirstUrl() { + if (TextUtils.isEmpty(firstScheme)) { + return firstDomain; } + return firstScheme + "://" + firstDomain; } } @@ -329,8 +343,30 @@ public static String getDomainName(String url) { return domain.startsWith("www.") ? domain.substring(4) : domain; } + /** + * Credentials of apps without a web domain are matched by a custom field with the requester package name + * + * @param requesterPackageName package name of the app that was requesting the autofill service + * @return custom fields json string with the package name field, empty on error + */ + public static String getPackageNameCustomFields(String requesterPackageName) { + try { + JSONArray customFields = new JSONArray(); + JSONObject customField = new JSONObject(); + customField.put("label", PACKAGE_NAME_CUSTOM_FIELD_LABEL); + customField.put("value", requesterPackageName); + customField.put("secret", false); + customField.put("field_type", "text"); + customFields.put(customField); + return customFields.toString(); + } catch (JSONException e) { + Log.e(LOG_TAG, "error creating package name customField"); + } + return ""; + } + @NonNull - static WebDomainResult getLikelyDomain(ArrayList assistStructures) { + public static WebDomainResult getLikelyDomain(ArrayList assistStructures) { WebDomainResult res = new WebDomainResult(); for (AssistStructure assistStructure : assistStructures) { @@ -347,14 +383,45 @@ static WebDomainResult getLikelyDomain(ArrayList assistStructur static void getNodeDomain(AssistStructure.ViewNode viewNode, WebDomainResult res) { String webDomain = viewNode.getWebDomain(); if (webDomain != null) { - res.addDomain(webDomain); + String webScheme = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + webScheme = viewNode.getWebScheme(); + } + res.addDomain(webDomain, webScheme); } for (int i = 0; i < viewNode.getChildCount(); i++) { getNodeDomain(viewNode.getChildAt(i), res); } } - private static List findMatchingCredentials( + /** + * New credential for the autofill requester, which will be matched by findMatchingCredentials once it's saved + * + * @param vault target vault of the new credential + * @param domain web domain of the requester, if it's a website + * @param requesterPackageName package name of the app that was requesting the autofill service + * @return credential with the url of the website or the package name custom field of the app + */ + @NonNull + public static Credential newCredentialForRequester( + @NonNull Vault vault, + @NonNull WebDomainResult domain, + String requesterPackageName + ) { + Credential credential = new Credential(); + credential.setVault(vault); + + if (domain.firstDomain != null) { + credential.setUrl(domain.getFirstUrl()); + } else { + // apps without a web domain are matched by their package name + credential.setCustomFields(getPackageNameCustomFields(requesterPackageName)); + } + return credential; + } + + @VisibleForTesting + public static List findMatchingCredentials( @NonNull ArrayList credentialArrayList, @NonNull String packageName, @NonNull WebDomainResult domain) { @@ -398,7 +465,7 @@ private static List findMatchingCredentials( String customFieldLabel = thisCredCustomField.getString("label"); - if (customFieldLabel.equalsIgnoreCase("androidCredPackageName")) { + if (customFieldLabel.equalsIgnoreCase(PACKAGE_NAME_CUSTOM_FIELD_LABEL)) { String credPackageName = thisCredCustomField.getString("value"); if (packageName.equalsIgnoreCase(credPackageName)) { matchingPackageCred.add(thisCred); diff --git a/app/src/main/java/es/wolfi/app/passman/autofill/CredentialAutofillService.java b/app/src/main/java/es/wolfi/app/passman/autofill/CredentialAutofillService.java index ac9a0c83..85583d6d 100644 --- a/app/src/main/java/es/wolfi/app/passman/autofill/CredentialAutofillService.java +++ b/app/src/main/java/es/wolfi/app/passman/autofill/CredentialAutofillService.java @@ -50,7 +50,6 @@ import com.loopj.android.http.AsyncHttpResponseHandler; import org.json.JSONArray; -import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; @@ -115,6 +114,8 @@ public void onFillRequest(FillRequest request, CancellationSignal cancellationSi return; } + AutofillHelper.WebDomainResult domain = AutofillHelper.getLikelyDomain(structures); + // Create the base response FillResponse.Builder response = new FillResponse.Builder(); @@ -136,11 +137,8 @@ public void onFillRequest(FillRequest request, CancellationSignal cancellationSi RemoteViews vaultUnlockPresentation = new RemoteViews(getPackageName(), R.layout.autofill_list_item_with_icon); vaultUnlockPresentation.setTextViewText(R.id.autofilltext, "Unlock autofill vault"); - Intent authIntent = new Intent(this, AutofillInteractionActivity.class); - authIntent.setAction(AutofillInteractionActivity.CustomAutofillIntentActions.VAULT_UNLOCK.name()); - authIntent.putExtra("packageName", packageName); - authIntent.putExtra("requesterPackageName", requesterPackageName); - authIntent.putParcelableArrayListExtra("structures", structures); + Intent authIntent = AutofillInteractionActivity.newIntent(this, AutofillInteractionActivity.CustomAutofillIntentActions.VAULT_UNLOCK, + packageName, requesterPackageName, fields, domain); boolean intentItemState = setupCustomAutofillIntentItem(this, response, fields, vaultUnlockPresentation, authIntent); callback.onSuccess(intentItemState ? response.build() : null); @@ -166,7 +164,7 @@ public void onFillRequest(FillRequest request, CancellationSignal cancellationSi packageName, requesterPackageName, fields, - structures + domain ); if (tempFields.size() > 0) { @@ -186,11 +184,8 @@ public void onFillRequest(FillRequest request, CancellationSignal cancellationSi RemoteViews vaultUnlockPresentation = new RemoteViews(getPackageName(), R.layout.autofill_list_item_with_icon); vaultUnlockPresentation.setTextViewText(R.id.autofilltext, "Manual search"); - Intent authIntent = new Intent(this, AutofillInteractionActivity.class); - authIntent.setAction(AutofillInteractionActivity.CustomAutofillIntentActions.MANUAL_SEARCH.name()); - authIntent.putExtra("packageName", packageName); - authIntent.putExtra("requesterPackageName", requesterPackageName); - authIntent.putParcelableArrayListExtra("structures", structures); + Intent authIntent = AutofillInteractionActivity.newIntent(this, AutofillInteractionActivity.CustomAutofillIntentActions.MANUAL_SEARCH, + packageName, requesterPackageName, fields, domain); boolean intentItemState = setupCustomAutofillIntentItem(this, response, fields, vaultUnlockPresentation, authIntent); callback.onSuccess(intentItemState ? response.build() : null); @@ -326,19 +321,7 @@ public void onSaveRequest(SaveRequest request, SaveCallback callback) { username = ""; } - String customFieldString = ""; - try { - JSONArray customFields = new JSONArray(); - JSONObject customField = new JSONObject(); - customField.put("label", "androidCredPackageName"); - customField.put("value", requesterPackageName); - customField.put("secret", false); - customField.put("field_type", "text"); - customFields.put(customField); - customFieldString = customFields.toString(); - } catch (JSONException e) { - Log.e(LOG_TAG, "onSaveRequest(), error creating customField"); - } + String customFieldString = AutofillHelper.getPackageNameCustomFields(requesterPackageName); Log.d(LOG_TAG, "onSaveRequest(), building Credential"); diff --git a/app/src/main/java/es/wolfi/app/passman/fragments/CredentialAddFragment.java b/app/src/main/java/es/wolfi/app/passman/fragments/CredentialAddFragment.java index ebefa331..3c637848 100644 --- a/app/src/main/java/es/wolfi/app/passman/fragments/CredentialAddFragment.java +++ b/app/src/main/java/es/wolfi/app/passman/fragments/CredentialAddFragment.java @@ -23,6 +23,7 @@ import android.app.ProgressDialog; import android.content.Context; +import android.graphics.Rect; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -46,7 +47,6 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.koushikdutta.async.future.FutureCallback; import com.loopj.android.http.AsyncHttpResponseHandler; import org.json.JSONException; @@ -55,12 +55,14 @@ import java.util.Arrays; import java.util.concurrent.atomic.AtomicBoolean; +import es.wolfi.app.ResponseHandlers.AutofillCredentialSaveResponseHandler; import es.wolfi.app.ResponseHandlers.CredentialAddFileResponseHandler; import es.wolfi.app.ResponseHandlers.CredentialSaveResponseHandler; import es.wolfi.app.passman.EditPasswordTextItem; import es.wolfi.app.passman.R; import es.wolfi.app.passman.SettingValues; import es.wolfi.app.passman.SingleTon; +import es.wolfi.app.passman.activities.BaseActivity; import es.wolfi.app.passman.activities.PasswordListActivity; import es.wolfi.app.passman.adapters.CustomFieldEditAdapter; import es.wolfi.app.passman.adapters.FileEditAdapter; @@ -104,7 +106,7 @@ public class CredentialAddFragment extends Fragment implements View.OnClickListe Spinner otp_algorithm_spinner; Spinner customFieldType; - private OnCredentialFragmentInteraction mListener; + private OnCredentialAddedListener mListener; private Credential credential; private FileEditAdapter fed; private CustomFieldEditAdapter cfed; @@ -130,6 +132,17 @@ public static CredentialAddFragment newInstance() { return new CredentialAddFragment(); } + /** + * Use this factory method to create a new instance of this fragment with prefilled values. + * + * @param prefilledCredential new credential with the target vault and the values to prefill + */ + public static CredentialAddFragment newInstance(Credential prefilledCredential) { + CredentialAddFragment fragment = new CredentialAddFragment(); + fragment.credential = prefilledCredential; + return fragment; + } + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -168,6 +181,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, description = binding.addCredentialDescription; customFieldType = binding.customFieldType; + url.setText(credential.getUrl()); + otp_edit_extended = (LinearLayout) view.findViewById(R.id.otp_edit_extended); otp_secret = view.findViewById(R.id.edit_credential_otp_secret); otp_digits = view.findViewById(R.id.edit_credential_otp_digits); @@ -184,8 +199,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, @Override public void onAttach(Context context) { super.onAttach(context); - if (context instanceof OnCredentialFragmentInteraction) { - mListener = (OnCredentialFragmentInteraction) context; + if (context instanceof OnCredentialAddedListener) { + mListener = (OnCredentialAddedListener) context; } } @@ -218,6 +233,11 @@ public void onDestroyView() { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (this.credential != null) { + // prefilled by newInstance(Credential) + return; + } + Vault v = (Vault) SingleTon.getTon().getExtra(SettingValues.ACTIVE_VAULT.toString()); if (v != null) { this.credential = new Credential(); @@ -326,7 +346,7 @@ public View.OnClickListener getScanOtpQRCodeButtonListener() { return new View.OnClickListener() { @Override public void onClick(View view) { - ((PasswordListActivity) requireActivity()).scanQRCodeForOTP(PasswordListActivity.REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_ADD); + ((BaseActivity) requireActivity()).scanQRCodeForOTP(BaseActivity.REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_ADD); } }; } @@ -350,7 +370,7 @@ public View.OnClickListener getAddFileButtonListener() { return new View.OnClickListener() { @Override public void onClick(View view) { - ((PasswordListActivity) requireActivity()).selectFileToAdd(FileUtils.activityRequestFileCode.credentialAddFile.ordinal()); + ((BaseActivity) requireActivity()).selectFileToAdd(FileUtils.activityRequestFileCode.credentialAddFile.ordinal()); } }; } @@ -360,7 +380,7 @@ public View.OnClickListener getAddCustomFieldButtonListener() { @Override public void onClick(View view) { if (customFieldType.getSelectedItem().toString().equals("File")) { - ((PasswordListActivity) requireActivity()).selectFileToAdd(FileUtils.activityRequestFileCode.credentialAddCustomFieldFile.ordinal()); + ((BaseActivity) requireActivity()).selectFileToAdd(FileUtils.activityRequestFileCode.credentialAddCustomFieldFile.ordinal()); } else { CustomField cf = new CustomField(); cf.setLabel("newLabel" + (cfed.getItemCount() + 1)); @@ -381,8 +401,11 @@ public void onClick(View view) { return; } - if (label.getText().toString().equals("")) { + if (label.getText().toString().isEmpty()) { label_header.setTextColor(getResources().getColor(R.color.danger)); + // the label may be scrolled out of view, show the highlighted header and focus the empty field + label.requestFocus(); + label_header.requestRectangleOnScreen(new Rect(0, 0, label_header.getWidth(), label_header.getHeight())); return; } @@ -418,25 +441,37 @@ public void onClick(View view) { Context context = getContext(); final ProgressDialog progress = ProgressUtils.showLoadingSequence(context); - final AsyncHttpResponseHandler responseHandler = new CredentialSaveResponseHandler(alreadySaving, false, progress, view, (PasswordListActivity) getActivity(), getFragmentManager()); + final AsyncHttpResponseHandler responseHandler; + if (mListener != null) { + // the host handles the saved credential itself (e.g. the autofill interaction) + final OnCredentialAddedListener listener = mListener; + responseHandler = new AutofillCredentialSaveResponseHandler(this.credential.getVault(), context, context.getApplicationContext(), SingleTon.getTon(), "CredentialAdd", + new AutofillCredentialSaveResponseHandler.OnCredentialSavedListener() { + @Override + public void onCredentialSaved(Credential credential) { + alreadySaving.set(false); + ProgressUtils.dismiss(progress); + listener.onCredentialAdded(credential); + } + + @Override + public void onCredentialSaveFailed() { + alreadySaving.set(false); + ProgressUtils.dismiss(progress); + } + }); + } else { + responseHandler = new CredentialSaveResponseHandler(alreadySaving, false, progress, view, (PasswordListActivity) getActivity(), getFragmentManager()); + } this.credential.save(context, responseHandler); } /** - * This interface must be implemented by activities that contain this - * fragment to allow an interaction in this fragment to be communicated - * to the activity and potentially other fragments contained in that - * activity. - *

- * See the Android Training lesson Communicating with Other Fragments for more information. + * Activities containing this fragment can implement this interface to receive the saved credential, + * instead of the default vault list update of the PasswordListActivity. */ - public interface OnCredentialFragmentInteraction { - // TODO: Update argument type and name - void onCredentialFragmentInteraction(Credential credential); - - void saveCredential(Credential credential, Context c, FutureCallback cb); + public interface OnCredentialAddedListener { + void onCredentialAdded(Credential credential); } } diff --git a/app/src/main/java/es/wolfi/app/passman/fragments/CredentialEditFragment.java b/app/src/main/java/es/wolfi/app/passman/fragments/CredentialEditFragment.java index e17e173d..ba5d9f5d 100644 --- a/app/src/main/java/es/wolfi/app/passman/fragments/CredentialEditFragment.java +++ b/app/src/main/java/es/wolfi/app/passman/fragments/CredentialEditFragment.java @@ -63,6 +63,7 @@ import es.wolfi.app.passman.R; import es.wolfi.app.passman.SettingValues; import es.wolfi.app.passman.SingleTon; +import es.wolfi.app.passman.activities.BaseActivity; import es.wolfi.app.passman.activities.PasswordListActivity; import es.wolfi.app.passman.adapters.CustomFieldEditAdapter; import es.wolfi.app.passman.adapters.FileEditAdapter; @@ -352,7 +353,7 @@ public View.OnClickListener getScanOtpQRCodeButtonListener() { return new View.OnClickListener() { @Override public void onClick(View view) { - ((PasswordListActivity) requireActivity()).scanQRCodeForOTP(PasswordListActivity.REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_EDIT); + ((BaseActivity) requireActivity()).scanQRCodeForOTP(BaseActivity.REQUEST_CODE_SCAN_QR_CODE_FOR_OTP_EDIT); } }; } @@ -376,7 +377,7 @@ public View.OnClickListener getAddFileButtonListener() { return new View.OnClickListener() { @Override public void onClick(View view) { - ((PasswordListActivity) requireActivity()).selectFileToAdd(FileUtils.activityRequestFileCode.credentialEditFile.ordinal()); + ((BaseActivity) requireActivity()).selectFileToAdd(FileUtils.activityRequestFileCode.credentialEditFile.ordinal()); } }; } @@ -386,7 +387,7 @@ public View.OnClickListener getAddCustomFieldButtonListener() { @Override public void onClick(View view) { if (customFieldType.getSelectedItem().toString().equals("File")) { - ((PasswordListActivity) requireActivity()).selectFileToAdd(FileUtils.activityRequestFileCode.credentialEditCustomFieldFile.ordinal()); + ((BaseActivity) requireActivity()).selectFileToAdd(FileUtils.activityRequestFileCode.credentialEditCustomFieldFile.ordinal()); } else { CustomField cf = new CustomField(); //cf.setLabel("newLabel" + (cfed.getItemCount() + 1)); diff --git a/app/src/main/java/es/wolfi/app/passman/fragments/CredentialItemFragment.java b/app/src/main/java/es/wolfi/app/passman/fragments/CredentialItemFragment.java index 746e2f89..059ceed8 100644 --- a/app/src/main/java/es/wolfi/app/passman/fragments/CredentialItemFragment.java +++ b/app/src/main/java/es/wolfi/app/passman/fragments/CredentialItemFragment.java @@ -70,7 +70,6 @@ public class CredentialItemFragment extends Fragment { private AsyncTask filterTask = null; private RecyclerView recyclerView; private Vault customVault = null; - private boolean enableLimitedAutofillView = false; private SharedPreferences sharedPreferences; private boolean caseInsensitiveSort = false; @@ -81,9 +80,8 @@ public class CredentialItemFragment extends Fragment { public CredentialItemFragment() { } - public CredentialItemFragment(Vault customVault, boolean enableLimitedAutofillView) { + public CredentialItemFragment(Vault customVault) { this.customVault = customVault; - this.enableLimitedAutofillView = enableLimitedAutofillView; } // TODO: Customize parameter initialization @@ -187,21 +185,13 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, View credentialView = view.findViewById(R.id.list); if (credentialView instanceof RecyclerView) { - if (!this.enableLimitedAutofillView) { - FloatingActionButton addCredentialButton = view.findViewById(R.id.addCredentialButton); - addCredentialButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - getParentFragmentManager() - .beginTransaction() - .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left, R.anim.slide_in_left, R.anim.slide_out_right) - .replace(R.id.content_password_list, CredentialAddFragment.newInstance(), "credentialAdd") - .addToBackStack(null) - .commit(); - } - }); - addCredentialButton.setVisibility(View.VISIBLE); - } + FloatingActionButton addCredentialButton = view.findViewById(R.id.addCredentialButton); + addCredentialButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + mListener.onAddCredentialRequested(); + } + }); Context context = credentialView.getContext(); recyclerView = (RecyclerView) credentialView; @@ -267,6 +257,8 @@ public interface OnListFragmentInteractionListener { // TODO: Update argument type and name void onListFragmentInteraction(Credential item); + void onAddCredentialRequested(); + void setLastCredentialListPosition(int pos); int getLastCredentialListPosition(); diff --git a/app/src/main/java/es/wolfi/passman/API/Core.java b/app/src/main/java/es/wolfi/passman/API/Core.java index e1366051..3ca1ee10 100644 --- a/app/src/main/java/es/wolfi/passman/API/Core.java +++ b/app/src/main/java/es/wolfi/passman/API/Core.java @@ -140,6 +140,7 @@ public static int getResponseTimeout(Context c) { } public static void requestInternalAPIGET(Context c, String endpoint, final FutureCallback callback) { + setUpAPIIfMissing(c); final AsyncHttpResponseHandler responseHandler = new CoreAPIGETResponseHandler(callback); if (ssoAccount != null) { final Map> header = new HashMap<>(); @@ -167,6 +168,7 @@ public static void requestInternalAPIGET(Context c, String endpoint, final Futur } public static void requestAPIGET(Context c, String endpoint, final FutureCallback callback) { + setUpAPIIfMissing(c); final AsyncHttpResponseHandler responseHandler = new CoreAPIGETResponseHandler(callback); if (ssoAccount != null) { final Map> header = new HashMap<>(); @@ -195,6 +197,7 @@ public static void requestAPIGET(Context c, String endpoint, final FutureCallbac public static void requestAPI(Context c, String endpoint, JSONObject jsonPostData, String requestType, final AsyncHttpResponseHandler responseHandler) throws MalformedURLException, UnsupportedEncodingException { + setUpAPIIfMissing(c); if (ssoAccount != null) { final Map> header = new HashMap<>(); @@ -304,22 +307,19 @@ private static void showConnectionErrorHint(Context context) { } /** - * Check if the user has logged in, also sets up the API + * Sets up the API with the stored server settings * - * @param c The context where this should be run - * @param toast Whether we want or not a toast! Yum! - * @param cb The callback + * @param c The context where this should be run + * @return true if the API was set up, false if the app has not yet been configured */ - public static void checkLogin(final Context c, final boolean toast, final FutureCallback cb) { + public static boolean setUpAPIFromSettings(Context c) { SingleTon ton = SingleTon.getTon(); if (ton.getString(SettingValues.HOST.toString()) == null) { String url = KeyStoreUtils.getString(SettingValues.HOST.toString(), null); - // If the url is null app has not yet been configured! if (url == null) { - cb.onCompleted(null, false); - return; + return false; } // Load the server settings @@ -338,6 +338,33 @@ public static void checkLogin(final Context c, final boolean toast, final Future Log.d(LOG_TAG, "Pass: " + pass.replaceAll("(?s).", "*")); setUpAPI(c, host, user, pass); + return true; + } + + /** + * The API is only set up by the app start, not if e.g. the autofill service started the app process + */ + private static void setUpAPIIfMissing(Context c) { + if (host == null) { + setUpAPIFromSettings(c); + } + } + + /** + * Check if the user has logged in, also sets up the API + * + * @param c The context where this should be run + * @param toast Whether we want or not a toast! Yum! + * @param cb The callback + */ + public static void checkLogin(final Context c, final boolean toast, final FutureCallback cb) { + // If the API could not be set up the app has not yet been configured + if (!setUpAPIFromSettings(c)) { + cb.onCompleted(null, false); + return; + } + + String host = SingleTon.getTon().getString(SettingValues.HOST.toString()); getAPIVersion(c, new FutureCallback() { @Override public void onCompleted(Exception e, String result) {