Skip to content
10 changes: 10 additions & 0 deletions app/proguard-ci-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fields>; }

# 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 <init>();
public void addDomain(...);
}
Original file line number Diff line number Diff line change
@@ -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
* <p>
* 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.
* <p>
* 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.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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<Credential> credentials = new ArrayList<>(Collections.singletonList(credential));
return AutofillHelper.findMatchingCredentials(credentials, requesterPackageName, domain).contains(credential);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -51,15 +53,22 @@ 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;
this.baseContext = baseContext;
this.applicationContext = applicationContext;
this.ton = ton;
this.TAG = TAG;
this.listener = listener;
}

@Override
Expand All @@ -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<String, Vault>) 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<String, Vault> vaults = (HashMap<String, Vault>) 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);
}

Expand All @@ -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) {
Expand All @@ -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();
}
});
}
Expand All @@ -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);
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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");
Expand All @@ -68,9 +76,29 @@ public enum CustomAutofillIntentActions {
}
}

private ArrayList<AssistStructure> 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) {
Expand All @@ -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<Bundle> 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");
Expand All @@ -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();
}
}
Expand All @@ -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<AutofillId> tempFields = AutofillHelper.fillResponseForDecryptedVault(
fillResponse,
vault,
packageName,
requesterPackageName,
fields,
structures
domain
);

if (tempFields.size() > 0) {
Expand All @@ -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();
}
}
Expand All @@ -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<AutofillId> tempFields = AutofillHelper.fillResponseForExplicitCredential(
fillResponse,
item,
Expand All @@ -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) {
Expand Down
Loading
Loading