Initial commit: QSafe Android 1.1.0 and Windows 1.0.4

This commit is contained in:
Курнат Андрей
2026-11-01 15:28:12 +03:00
commit 9845944bb8
91 changed files with 6496 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
plugins { id 'com.android.application' }
def signing = new Properties()
def signingFile = rootProject.file('.private/signing.properties')
if (signingFile.exists()) signingFile.withInputStream { signing.load(it) }
android {
namespace 'ru.kusoft.qsafe'
compileSdk 35
defaultConfig {
applicationId 'ru.kusoft.qsafe'
minSdk 28
targetSdk 35
versionCode = (project.findProperty('qsafeVersionCode') ?: '8').toInteger()
versionName = (project.findProperty('qsafeVersionName') ?: '1.1.0')
testInstrumentationRunner 'ru.kusoft.qsafe.QSafeTestRunner'
}
signingConfigs {
release {
if (signingFile.exists()) {
storeFile rootProject.file(signing.getProperty('storeFile'))
storePassword signing.getProperty('storePassword')
keyAlias signing.getProperty('keyAlias')
keyPassword signing.getProperty('keyPassword')
}
}
}
buildTypes { release { signingConfig signingConfigs.release; minifyEnabled false } }
compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
buildFeatures { buildConfig true }
sourceSets { main.resources.srcDir rootProject.file('shared') }
testOptions { unitTests.returnDefaultValues = true }
lint { abortOnError true }
}
dependencies {
androidTestImplementation 'androidx.test:runner:1.6.2'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303'
}
@@ -0,0 +1,38 @@
package ru.kusoft.qsafe;
import android.app.*;
import android.content.*;
import android.view.WindowManager;
import android.widget.*;
import junit.framework.TestCase;
import org.json.*;
import java.lang.reflect.Field;
import java.nio.file.Files;
public class BiometricDeviceTests extends TestCase {
private final Instrumentation instrument=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation();
private MainActivity activity;
private void main(Runnable work){instrument.runOnMainSync(work);instrument.waitForIdleSync();}
private Object field(String name)throws Exception{Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);return f.get(activity);}
private interface Ready{boolean get()throws Exception;}
private void await(Ready ready)throws Exception{long end=System.currentTimeMillis()+90000;while(System.currentTimeMillis()<end){instrument.waitForIdleSync();if(ready.get())return;Thread.sleep(100);}fail("Biometric stage timed out");}
public void testFingerprintCryptographicUnlockAndMasterPasswordFallback()throws Exception {
assertTrue("This test must only use the dedicated emulator",android.os.Build.MODEL.contains("sdk_gphone"));
Context c=instrument.getTargetContext();Repository r=Repository.get(c);r.setAutoSync(false);r.setHomeWifi(false,"");r.fingerprintOffered(true);
char[] pass="synthetic biometric password!".toCharArray();
try(VaultCrypto.Key key=VaultCrypto.create(pass)) {JSONObject data=Vault.empty(),entry=CardTemplates.create("login").put("title","Biometric synthetic card");entry.getJSONArray("fields").getJSONObject(1).put("value","SYNTHETIC_BIOMETRIC_SECRET");Vault.update(data,entry);r.save(data,key);}
Intent launch=new Intent(c,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);activity=(MainActivity)instrument.startActivitySync(launch);
Biometrics bio=(Biometrics)field("biometrics");if(bio.enabled())main(()->{try{bio.disable();}catch(Exception e){throw new RuntimeException(e);}});assertTrue("Enroll emulator fingerprint first",bio.available());
main(()->{((EditText)activity.findViewById(R.id.master_password)).setText(new String(pass));activity.findViewById(R.id.open_vault).performClick();});await(()->activity.findViewById(R.id.add_entry)!=null);
main(()->activity.findViewById(R.id.settings).performClick());assertNull(field("dialog"));main(()->activity.findViewById(R.id.fingerprint).performClick());android.util.Log.i("QSafeQA","BIO_ENABLE_READY");
await(bio::enabled);assertFalse(new String(Files.readAllBytes(new java.io.File(c.getFilesDir(),"biometric.enc").toPath()),java.nio.charset.StandardCharsets.UTF_8).contains("SYNTHETIC"));
main(activity::finish);activity=(MainActivity)instrument.startActivitySync(launch);await(()->(Boolean)field("fingerprintRunning"));android.util.Log.i("QSafeQA","BIO_UNLOCK_READY");
await(()->activity.findViewById(R.id.add_entry)!=null);assertTrue((activity.getWindow().getAttributes().flags&WindowManager.LayoutParams.FLAG_SECURE)!=0);
main(activity::finish);activity=(MainActivity)instrument.startActivitySync(launch);await(()->(Boolean)field("fingerprintRunning"));android.util.Log.i("QSafeQA","BIO_FALLBACK_READY");
await(()->!(Boolean)field("fingerprintRunning"));assertNotNull(activity.findViewById(R.id.master_password));assertNull(activity.findViewById(R.id.connect_pi));
main(()->{((EditText)activity.findViewById(R.id.master_password)).setText("incorrect synthetic password");activity.findViewById(R.id.open_vault).performClick();});await(()->!(Boolean)field("busy"));assertNull(activity.findViewById(R.id.add_entry));
main(()->{((EditText)activity.findViewById(R.id.master_password)).setText(new String(pass));activity.findViewById(R.id.open_vault).performClick();});await(()->activity.findViewById(R.id.add_entry)!=null);
try(VaultCrypto.Key key=VaultCrypto.unlock(r.encrypted(),pass)){assertEquals("Biometric synthetic card",Vault.visible(r.read(key),"",false).get(0).getString("title"));}
java.util.Arrays.fill(pass,'\0');main(activity::finish);android.util.Log.i("QSafeQA","BIO_ALL_PASSED");
}
}
@@ -0,0 +1,106 @@
package ru.kusoft.qsafe;
import android.app.*;
import android.content.*;
import android.graphics.Bitmap;
import junit.framework.TestCase;
import android.view.*;
import android.widget.*;
import org.json.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
@SuppressWarnings("deprecation")
@android.annotation.SuppressLint("ResourceType")
public class DeviceTests extends TestCase {
private android.app.Instrumentation getInstrumentation() {return androidx.test.platform.app.InstrumentationRegistry.getInstrumentation();}
private Context isolated(String name) {
Context target=getInstrumentation().getTargetContext();File dir=new File(target.getCacheDir(),"qa-"+name+"-"+UUID.randomUUID());assertTrue(dir.mkdirs());
return new ContextWrapper(target) {@Override public File getFilesDir(){return dir;}};
}
public void testAEncryptedPersistenceAndImportOnAndroid() throws Exception {
Context c=isolated("persistence");Repository r=new Repository(c);
char[] pass="synthetic password 123!".toCharArray();
try(VaultCrypto.Key key=VaultCrypto.create(pass)) {
JSONObject data=Vault.empty();byte[] xml=("<database><label id='1' name='Работа'/><card id='1' title='Тестовая почта'><field name='Логин' type='login'>demo@example.test</field><field name='Пароль' type='password'>TEST_SECRET_NEVER_REAL</field><label_id>1</label_id><notes>Проверка импорта</notes></card></database>").getBytes(StandardCharsets.UTF_8);
SafeInCloudImport.Preview preview=SafeInCloudImport.preview(xml,data);assertEquals(1,preview.added);r.save(preview.vault,key);
assertFalse(new String(r.encrypted(),StandardCharsets.UTF_8).contains("TEST_SECRET_NEVER_REAL"));
Repository reopened=new Repository(c);assertEquals("Тестовая почта",Vault.visible(reopened.read(key),"",false).get(0).getString("title"));
byte[] exported=r.encrypted();reopened.restoreFile(exported,pass);
assertEquals(1,Vault.visible(reopened.read(key),"",false).size());
}
Arrays.fill(pass,'\0');
}
public void testBRealHttpsSyncRestoreMergeAndConflict() throws Exception {
String path=QSafeTestRunner.arguments.getString("connection");assertNotNull("Pass a QA connection file path",path);
JSONObject connection;try(InputStream in=new FileInputStream(path)) {connection=new JSONObject(new String(Repository.bounded(in,16384),StandardCharsets.UTF_8));}
Context ca=isolated("deviceA"),cb=isolated("deviceB");Repository a=new Repository(ca),b=new Repository(cb);
a.configure(connection.getString("url"),connection.getString("token"));b.configure(connection.getString("url"),connection.getString("token"));
char[] pass="synthetic sync password 123!".toCharArray();
try(VaultCrypto.Key ka=VaultCrypto.create(pass)) {
JSONObject first=Vault.empty(),entry=Vault.entry("Mail synthetic");entry.getJSONArray("fields").put(Vault.field("Password","password","SYNTHETIC_SYNC_SECRET"));Vault.update(first,entry);a.save(first,ka);
assertEquals(0,a.sync(ka).conflicts);
try(VaultCrypto.Key kb=b.restorePi(pass)) {
assertEquals("Mail synthetic",Vault.visible(b.read(kb),"",false).get(0).getString("title"));
JSONObject localA=a.read(ka),localB=b.read(kb);
JSONObject ea=Vault.copy(localA.getJSONArray("entries").getJSONObject(0)).put("notes","A changed");
JSONObject eb=Vault.copy(localB.getJSONArray("entries").getJSONObject(0)).put("notes","B changed");
Vault.update(localA,ea);Vault.update(localB,eb);a.save(localA,ka);b.save(localB,kb);
a.sync(ka);Repository.SyncResult merged=b.sync(kb);assertEquals(1,merged.conflicts);assertEquals(2,Vault.visible(merged.data,"",false).size());
Repository.SyncResult pulled=a.sync(ka);assertEquals(2,Vault.visible(pulled.data,"",false).size());
JSONObject deleted=a.read(ka);JSONObject remove=Vault.copy(deleted.getJSONArray("entries").getJSONObject(0)).put("deleted",true);Vault.update(deleted,remove);a.save(deleted,ka);a.sync(ka);
assertEquals(1,Vault.visible(b.sync(kb).data,"",false).size());
Repository reopened=new Repository(ca);assertTrue(reopened.connected());assertEquals(a.url(),reopened.url());
// Send while locked, then stage on the other locked client without changing its trusted ciphertext.
JSONObject changed=b.read(kb);JSONObject remaining=Vault.copy(Vault.visible(changed,"",false).get(0)).put("notes","Background encrypted transfer");
Vault.update(changed,remaining);b.save(changed,kb);kb.close();
assertTrue(b.backgroundSync(null,()->false).contains("Pi"));
byte[] before=a.encrypted();ka.close();
assertTrue(a.backgroundSync(null,()->false).contains("загружена"));assertTrue(Arrays.equals(before,a.encrypted()));
java.io.File staged=new java.io.File(ca.getFilesDir(),"vault/pending.qsafe");byte[] corrupt=java.nio.file.Files.readAllBytes(staged.toPath());corrupt[corrupt.length-1]^=1;
Repository.write(new android.util.AtomicFile(staged),corrupt);
try(VaultCrypto.Key renewed=VaultCrypto.unlock(a.encrypted(),pass)) {
try{a.applyPending(renewed);fail("Tampered remote must not replace the local vault");}catch(java.security.GeneralSecurityException expected){}
assertTrue(Arrays.equals(before,a.encrypted()));
a.backgroundSync(null,()->false);
assertEquals("Background encrypted transfer",Vault.visible(a.applyPending(renewed).data,"",false).get(0).getString("notes"));
}
}
}finally{Arrays.fill(pass,'\0');}
}
public void testDHomeWifiArrivalRunsWhileVaultIsLocked() throws Exception {
Context context=getInstrumentation().getTargetContext();
String path=QSafeTestRunner.arguments.getString("connection");assertNotNull(path);
JSONObject connection;try(InputStream in=new FileInputStream(path)){connection=new JSONObject(new String(Repository.bounded(in,16384),StandardCharsets.UTF_8));}
assertTrue(HomeWifiSync.permissionProblem(context,true),HomeWifiSync.permissionProblem(context,true).isEmpty());
shell("svc wifi enable");await(()->HomeWifiSync.homeNetwork(context,"AndroidWifi",null,true)!=null,60000);
Repository r=Repository.get(context);
SyncRuntime.IO.submit(()->{
r.configure(connection.getString("url"),connection.getString("token"));
try(VaultCrypto.Key ignored=r.restorePi("synthetic sync password 123!".toCharArray())){}
r.setHomeWifi(true,"definitely-not-home");HomeWifiSync.install(context,r);return null;
}).get();
await(()->r.backgroundStatus().contains("Ожидается"),120000);
byte[] before=r.encrypted();
try {
shell("svc wifi disable");await(()->HomeWifiSync.currentSsid(context).isEmpty(),30000);
SyncRuntime.IO.submit(()->{r.setHomeWifi(true,"AndroidWifi");r.backgroundStatus("QA ждёт домашний Wi-Fi");HomeWifiSync.install(context,r);return null;}).get();
Thread.sleep(1000);assertTrue(Arrays.equals(before,r.encrypted()));
assertFalse(r.backgroundStatus().contains("Телефон и Pi синхронизированы"));
shell("svc wifi enable");
await(()->r.backgroundStatus().contains("Телефон и Pi синхронизированы"),180000);
assertTrue(r.exists());
}finally{shell("svc wifi enable");}
}
private void shell(String command) throws Exception {
try(android.os.ParcelFileDescriptor fd=getInstrumentation().getUiAutomation().executeShellCommand(command);
InputStream in=new android.os.ParcelFileDescriptor.AutoCloseInputStream(fd)){Repository.bounded(in,65536);}
}
public void testCUserInterfaceCreateSearchEditAndLock() throws Exception {
new UiChecks(getInstrumentation()).run();
}
private VaultCrypto.Key testKey(Repository r) throws Exception {return VaultCrypto.unlock(r.encrypted(),"test master password 123!".toCharArray());}
private interface Condition {boolean ready() throws Exception;}
private void await(Condition c,long timeout) throws Exception {long end=System.currentTimeMillis()+timeout;while(System.currentTimeMillis()<end){getInstrumentation().waitForIdleSync();if(c.ready())return;Thread.sleep(150);}fail("Timed out waiting for UI state");}
}
@@ -0,0 +1,121 @@
package ru.kusoft.qsafe;
import android.app.*;
import android.app.job.JobScheduler;
import android.content.*;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.ParcelFileDescriptor;
import android.view.*;
import android.widget.*;
import junit.framework.TestCase;
import java.io.*;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import org.json.*;
/** Acceptance on the dedicated emulator, using invented data and an unreachable test server. */
@SuppressWarnings("deprecation")
public class MenuThemeDeviceTests extends TestCase {
private final Instrumentation instrument=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation();
private MainActivity activity;
private Context context;
private void main(Runnable action){instrument.runOnMainSync(action);instrument.waitForIdleSync();}
private Object field(String name)throws Exception {Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);return f.get(activity);}
private String screen()throws Exception{return (String)field("screen");}
private AlertDialog dialog()throws Exception{return (AlertDialog)field("dialog");}
private interface Ready{boolean get()throws Exception;}
private void await(Ready ready)throws Exception {
long end=System.currentTimeMillis()+25000;
while(System.currentTimeMillis()<end){boolean[] value={false};Exception[] failure={null};main(()->{try{value[0]=ready.get();}catch(Exception e){failure[0]=e;}});if(failure[0]!=null)throw failure[0];if(value[0])return;Thread.sleep(100);}
fail("Menu/theme transition timed out: "+screen());
}
private void tap(int id){main(()->{View v=activity.findViewById(id);assertNotNull("Missing view "+id,v);v.performClick();});}
private View text(View root,String value){if(root instanceof TextView&&((TextView)root).getText().toString().equals(value))return root;if(root instanceof ViewGroup){ViewGroup g=(ViewGroup)root;for(int i=0;i<g.getChildCount();i++){View found=text(g.getChildAt(i),value);if(found!=null)return found;}}return null;}
private void tapText(String value){main(()->{View v=text(activity.getWindow().getDecorView(),value);assertNotNull(value,v);v.performClick();});}
private void set(int id,String value){main(()->((EditText)activity.findViewById(id)).setText(value));}
private String shell(String command)throws Exception {try(ParcelFileDescriptor fd=instrument.getUiAutomation().executeShellCommand(command);InputStream in=new ParcelFileDescriptor.AutoCloseInputStream(fd)){return new String(Repository.bounded(in,65536),StandardCharsets.UTF_8).trim();}}
private void secure(){assertTrue((activity.getWindow().getAttributes().flags&WindowManager.LayoutParams.FLAG_SECURE)!=0);}
private int background()throws Exception{return ((ColorDrawable)((View)field("frame")).getBackground()).getColor();}
private void shot(String name)throws Exception {
secure();AlertDialog d=dialog();if(d!=null)assertTrue((d.getWindow().getAttributes().flags&WindowManager.LayoutParams.FLAG_SECURE)!=0);
main(()->{activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);if(d!=null)d.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);});
try{Thread.sleep(250);Bitmap bitmap=instrument.getUiAutomation().takeScreenshot();assertNotNull(bitmap);try(FileOutputStream out=new FileOutputStream(new File(context.getExternalFilesDir(null),name+".png"))){bitmap.compress(Bitmap.CompressFormat.PNG,100,out);}bitmap.recycle();}
finally{main(()->{SecureWindows.protect(activity);if(d!=null)SecureWindows.protect(d.getWindow());});}
}
private void launch(String master)throws Exception {
activity=(MainActivity)instrument.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));secure();
set(R.id.master_password,master);tap(R.id.open_vault);await(()->activity.findViewById(R.id.add_entry)!=null);
}
private void editor()throws Exception {
tap(R.id.add_entry);AlertDialog templates=dialog();main(()->((GridLayout)templates.findViewById(R.id.template_grid)).getChildAt(0).performClick());assertEquals("edit",screen());
set(R.id.entry_title,"Unsaved theme draft");set(3101,"SYNTHETIC_DRAFT_SECRET");
}
public void testMenuPagesActualTogglesThemesAndDraftSurvival()throws Exception {
assertTrue(android.os.Build.MODEL.contains("sdk_gphone"));context=instrument.getTargetContext();Repository r=Repository.get(context);assertFalse("Fresh synthetic installation required",r.exists());
String originalAutofill=shell("settings get secure autofill_service");
assertTrue(originalAutofill.isEmpty()||originalAutofill.matches("[A-Za-z0-9_./]+"));
String originalNight=shell("cmd uimode night");
AppAppearance.save(context,AppAppearance.SYSTEM);shell("cmd uimode night no");
r.fingerprintOffered(true);r.setAutoSync(false);r.setHomeWifi(false,"");
String master="Synthetic menu theme master 2026!";
try(VaultCrypto.Key key=VaultCrypto.create(master.toCharArray())){
JSONObject data=Vault.empty(),entry=CardTemplates.create("login").put("title","Menu QA synthetic");entry.getJSONArray("fields").getJSONObject(1).put("value","SYNTHETIC_VAULT_SECRET");Vault.update(data,entry);r.save(data,key);
}
String initialVaultHash=VaultCrypto.hash(r.encrypted());
try {
launch(master);tap(R.id.settings);assertEquals("menu",screen());assertNull(dialog());secure();
for(String section:new String[]{"Внешний вид","Синхронизация","Защита","Данные","Приложение"})assertNotNull(section,text(activity.getWindow().getDecorView(),section));
tap(R.id.sync);assertEquals("connection",screen());assertNull(dialog());tap(R.id.navigate_back);
assertFalse(((Switch)activity.findViewById(R.id.auto_sync)).isChecked());tap(R.id.auto_sync);await(()->r.autoSync()&&!(Boolean)field("busy"));assertTrue(((Switch)activity.findViewById(R.id.auto_sync)).isChecked());
tap(R.id.auto_sync);await(()->!r.autoSync()&&!(Boolean)field("busy"));
tap(R.id.background_sync);assertEquals("connection",screen());assertNull(dialog());assertFalse(r.homeWifiEnabled());
assertNotNull(activity.findViewById(R.id.connection_url));tap(R.id.navigate_back);assertEquals("menu",screen());assertFalse(((Switch)activity.findViewById(R.id.background_sync)).isChecked());
tap(R.id.connection_settings);set(R.id.connection_url,"https://example.test/");set(R.id.connection_token,new String(new char[43]).replace('\0','q'));set(R.id.home_ssid,"QSafe QA network");set(R.id.connection_pin,new String(new char[64]).replace('\0','f'));tapText("Сохранить");
await(()->!(Boolean)field("busy"));assertFalse(r.connected());assertEquals("connection",screen());assertFalse(((TextView)activity.findViewById(R.id.status)).getText().toString().isEmpty());
set(R.id.connection_url,"https://192.168.254.250:18443/qsafe/");tapText("Сохранить");await(()->screen().equals("menu")&&r.connected());
tap(R.id.background_sync);assertEquals("wifi",screen());assertNull(dialog());set(R.id.home_ssid,"");tapText("Сохранить");assertEquals("wifi",screen());assertFalse(r.homeWifiEnabled());set(R.id.home_ssid,"QSafe QA updated network");tapText("Сохранить");await(()->screen().equals("menu")&&r.homeWifiEnabled());
assertTrue(((Switch)activity.findViewById(R.id.background_sync)).isChecked());tap(R.id.background_sync);await(()->!r.homeWifiEnabled()&&!(Boolean)field("busy"));
JobScheduler jobs=context.getSystemService(JobScheduler.class);assertNull(jobs.getPendingJob(HomeWifiSync.ARRIVAL_JOB));assertNull(jobs.getPendingJob(HomeWifiSync.RECOVERY_JOB));
HomeWifiSync.enqueue(context);assertNull("Disabled background sync must not be queued again",jobs.getPendingJob(HomeWifiSync.ARRIVAL_JOB));
tap(R.id.connection_settings);assertEquals("",((EditText)activity.findViewById(R.id.connection_token)).getText().toString());set(R.id.home_ssid,"QSafe QA final network");tapText("Сохранить");await(()->screen().equals("menu"));assertTrue(r.connected());assertEquals("QSafe QA final network",r.homeSsid());
// Reflect the actual Android service state, then disable our service from the switch.
shell("settings put secure autofill_service ru.kusoft.qsafe/.QSafeAutofillService");
android.view.autofill.AutofillManager manager=context.getSystemService(android.view.autofill.AutofillManager.class);await(manager::hasEnabledAutofillServices);
tap(R.id.navigate_back);tap(R.id.settings);assertTrue(((Switch)activity.findViewById(R.id.autofill_enabled)).isChecked());tap(R.id.autofill_enabled);await(()->!manager.hasEnabledAutofillServices()&&!(Boolean)field("busy"));assertFalse(((Switch)activity.findViewById(R.id.autofill_enabled)).isChecked());
assertFalse(((Switch)activity.findViewById(R.id.fingerprint)).isChecked());
if(!((Biometrics)field("biometrics")).available()){tap(R.id.fingerprint);assertEquals("fingerprint_setup",screen());assertNull(dialog());tap(R.id.navigate_back);assertFalse(((Switch)activity.findViewById(R.id.fingerprint)).isChecked());}
Thread.sleep(4000);shot("menu-light-top");
tap(R.id.theme_settings);assertEquals("theme",screen());assertNull(dialog());tap(R.id.theme_dark);assertEquals(AppAppearance.DARK,AppAppearance.mode(context));assertEquals(Color.parseColor("#101817"),background());shot("theme-dark");
tap(R.id.navigate_back);shot("menu-dark-top");ScrollView scroll=(ScrollView)field("pageScroll");
int[] headerBefore=new int[2],headerAfter=new int[2];main(()->activity.findViewById(R.id.page_title).getLocationOnScreen(headerBefore));
main(()->scroll.scrollTo(0,scroll.getChildAt(0).getHeight()));main(()->activity.findViewById(R.id.page_title).getLocationOnScreen(headerAfter));assertEquals(headerBefore[1],headerAfter[1]);shot("menu-dark-bottom");
tap(R.id.connection_settings);assertEquals(Color.parseColor("#101817"),background());shot("connection-dark");tap(R.id.navigate_back);
tap(R.id.backup_settings);assertEquals("backups",screen());assertNull(dialog());assertNotNull(activity.findViewById(R.id.export_backup));tap(R.id.navigate_back);
tap(R.id.import_settings);assertEquals("import",screen());assertNull(dialog());tap(R.id.navigate_back);tap(R.id.navigate_back);
editor();tapText("Сгенерировать пароль");shot("generator-dark");AlertDialog generator=dialog();main(()->generator.getButton(AlertDialog.BUTTON_NEGATIVE).performClick());
tap(R.id.navigate_back);AlertDialog discard=dialog();assertNotNull(discard);main(()->discard.getButton(AlertDialog.BUTTON_POSITIVE).performClick());
main(activity::finish);launch(master);assertEquals(AppAppearance.DARK,AppAppearance.mode(context));assertEquals(Color.parseColor("#101817"),background());
tap(R.id.settings);tap(R.id.theme_settings);tap(R.id.theme_light);assertEquals(Color.parseColor("#F5F8F7"),background());shot("theme-light");
shell("cmd uimode night yes");instrument.waitForIdleSync();assertEquals(Color.parseColor("#F5F8F7"),background());shot("theme-light-system-dark");
Bitmap lightCapture=android.graphics.BitmapFactory.decodeFile(new File(context.getExternalFilesDir(null),"theme-light-system-dark.png").getAbsolutePath());
assertEquals("Explicit light theme must stay light in the rendered screen",Color.parseColor("#F5F8F7"),lightCapture.getPixel(10,lightCapture.getHeight()/2));lightCapture.recycle();
tap(R.id.theme_system);await(()->background()==Color.parseColor("#101817"));assertFalse(activity.isDestroyed());assertNotNull(field("session"));
shell("cmd uimode night no");await(()->background()==Color.parseColor("#F5F8F7"));tap(R.id.navigate_back);tap(R.id.navigate_back);editor();
shell("cmd uimode night yes");await(()->background()==Color.parseColor("#101817"));assertEquals("edit",screen());assertEquals("Unsaved theme draft",((EditText)activity.findViewById(R.id.entry_title)).getText().toString());assertEquals("SYNTHETIC_DRAFT_SECRET",((EditText)activity.findViewById(3101)).getText().toString());secure();shot("editor-system-dark");
tap(R.id.navigate_back);AlertDialog finalDiscard=dialog();assertNotNull("Unsaved-change warning must survive a theme change",finalDiscard);main(()->finalDiscard.getButton(AlertDialog.BUTTON_POSITIVE).performClick());
assertEquals(initialVaultHash,VaultCrypto.hash(r.encrypted()));
Repository reopened=new Repository(context);assertFalse(reopened.autoSync());assertFalse(reopened.homeWifiEnabled());assertEquals("QSafe QA final network",reopened.homeSsid());
File copy=new File(context.getCacheDir(),"menu-theme-restore.qsafe");try(FileOutputStream out=new FileOutputStream(copy)){out.write(r.encrypted());}
main(()->activity.onActivityResult(44,Activity.RESULT_OK,new Intent().setData(android.net.Uri.fromFile(copy))));
assertEquals("restore",screen());assertNull(dialog());set(R.id.restore_password,"incorrect synthetic master");tapText("Восстановить");await(()->!(Boolean)field("busy"));assertEquals("restore",screen());assertEquals(initialVaultHash,VaultCrypto.hash(r.encrypted()));
set(R.id.restore_password,master);tapText("Восстановить");await(()->screen().equals("locked"));assertNull(field("session"));assertNotNull(activity.findViewById(R.id.master_password));assertEquals(initialVaultHash,VaultCrypto.hash(r.encrypted()));
}finally {
if(activity!=null)main(activity::finish);
r.setHomeWifi(false,r.homeSsid());HomeWifiSync.install(context,r);
if(originalAutofill.equals("null")||originalAutofill.isEmpty())shell("settings delete secure autofill_service");else shell("settings put secure autofill_service "+originalAutofill);
shell("cmd uimode night "+(originalNight.contains("yes")?"yes":originalNight.contains("auto")?"auto":"no"));
}
}
}
@@ -0,0 +1,34 @@
package ru.kusoft.qsafe;
import android.app.*;
import android.content.*;
import android.view.View;
import android.widget.EditText;
import junit.framework.TestCase;
import org.json.JSONObject;
import java.io.*;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
public class OnboardingRestoreTests extends TestCase {
private final Instrumentation instrument=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation();
private void main(Runnable work){instrument.runOnMainSync(work);instrument.waitForIdleSync();}
private void await(java.util.concurrent.Callable<Boolean> condition)throws Exception{long end=System.currentTimeMillis()+25000;while(System.currentTimeMillis()<end){instrument.waitForIdleSync();if(condition.call())return;Thread.sleep(100);}fail("Restore onboarding timed out");}
public void testFirstConnectionRequiresCorrectMasterAndDisappearsAfterRestore()throws Exception {
assertTrue(android.os.Build.MODEL.contains("sdk_gphone"));Context context=instrument.getTargetContext();
File directory=new File(context.getCacheDir(),"onboarding-"+java.util.UUID.randomUUID());assertTrue(directory.mkdirs());
Repository isolated=new Repository(new ContextWrapper(context){@Override public File getFilesDir(){return directory;}});
JSONObject connection;try(InputStream in=new FileInputStream(QSafeTestRunner.arguments.getString("connection"))){connection=new JSONObject(new String(Repository.bounded(in,16384),StandardCharsets.UTF_8));}
isolated.configure(connection.getString("url"),connection.getString("token"));isolated.fingerprintOffered(true);isolated.setAutoSync(false);
Field singleton=Repository.class.getDeclaredField("instance");singleton.setAccessible(true);Object previous=singleton.get(null);singleton.set(null,isolated);
MainActivity a=null;
try {
Intent launch=new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);a=(MainActivity)instrument.startActivitySync(launch);final MainActivity first=a;
assertNotNull(first.findViewById(R.id.connect_pi));main(()->{((EditText)first.findViewById(R.id.master_password)).setText("wrong synthetic master");first.findViewById(R.id.open_vault).performClick();});
Field busy=MainActivity.class.getDeclaredField("busy");busy.setAccessible(true);await(()->!(Boolean)busy.get(first));assertFalse(isolated.exists());assertNotNull(first.findViewById(R.id.connect_pi));
main(()->{((EditText)first.findViewById(R.id.master_password)).setText("synthetic sync password 123!");first.findViewById(R.id.connect_pi).performClick();});await(()->first.findViewById(R.id.add_entry)!=null);assertTrue(isolated.exists());
try(VaultCrypto.Key key=VaultCrypto.unlock(isolated.encrypted(),"synthetic sync password 123!".toCharArray())){assertTrue(Vault.visible(isolated.read(key),"",false).size()>0);}
main(first::finish);a=(MainActivity)instrument.startActivitySync(launch);assertNull(a.findViewById(R.id.connect_pi));assertNotNull(a.findViewById(R.id.open_vault));
}finally{if(a!=null){MainActivity last=a;main(last::finish);}singleton.set(null,previous);}
}
}
@@ -0,0 +1,101 @@
package ru.kusoft.qsafe;
import android.app.*;
import android.content.*;
import android.graphics.Bitmap;
import android.view.*;
import android.widget.*;
import junit.framework.TestCase;
import java.io.*;
import java.lang.reflect.Field;
import org.json.*;
/** Dedicated fresh emulator; the user's database and connection are never opened. */
@SuppressWarnings("deprecation")
public class PasswordGeneratorDeviceTests extends TestCase {
private final Instrumentation instrument=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation();
private MainActivity activity;
private void main(Runnable work){instrument.runOnMainSync(work);instrument.waitForIdleSync();}
private AlertDialog dialog()throws Exception {Field f=MainActivity.class.getDeclaredField("dialog");f.setAccessible(true);return (AlertDialog)f.get(activity);}
private View text(View root,String value) {
if(root instanceof TextView&&((TextView)root).getText().toString().equals(value))return root;
if(root instanceof ViewGroup){ViewGroup group=(ViewGroup)root;for(int i=0;i<group.getChildCount();i++){View found=text(group.getChildAt(i),value);if(found!=null)return found;}}
return null;
}
private void tap(String value){View found=text(activity.getWindow().getDecorView(),value);assertNotNull(value,found);main(found::performClick);}
private void secure(AlertDialog d){assertTrue((d.getWindow().getAttributes().flags&WindowManager.LayoutParams.FLAG_SECURE)!=0);}
private String preview(AlertDialog d){return ((TextView)d.findViewById(R.id.generator_preview)).getText().toString();}
private void shot(String name)throws Exception {
AlertDialog d=dialog();secure(d);
main(()->{activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);d.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);});
try{Thread.sleep(200);Bitmap bitmap=instrument.getUiAutomation().takeScreenshot();assertNotNull(bitmap);
try(FileOutputStream out=new FileOutputStream(new File(instrument.getTargetContext().getExternalFilesDir(null),name+".png"))){bitmap.compress(Bitmap.CompressFormat.PNG,100,out);}bitmap.recycle();
}finally{main(()->{SecureWindows.protect(activity);SecureWindows.protect(d.getWindow());});}
}
public void testVisiblePreviewModesCancelApplyAndPersistence()throws Exception {
assertTrue("Only a dedicated emulator may run this test",android.os.Build.MODEL.contains("sdk_gphone"));
Context context=instrument.getTargetContext();Repository repository=Repository.get(context);
assertFalse("Fresh synthetic installation required",repository.exists());repository.fingerprintOffered(true);
String master="Generator synthetic master 2026!";
try(VaultCrypto.Key key=VaultCrypto.create(master.toCharArray())){repository.save(Vault.empty(),key);}
activity=(MainActivity)instrument.startActivitySync(new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
try {
main(()->{((EditText)activity.findViewById(R.id.master_password)).setText(master);activity.findViewById(R.id.open_vault).performClick();});
long deadline=System.currentTimeMillis()+20000;
while(activity.findViewById(R.id.add_entry)==null&&System.currentTimeMillis()<deadline){Thread.sleep(100);instrument.waitForIdleSync();}
assertNotNull(activity.findViewById(R.id.add_entry));main(()->activity.findViewById(R.id.add_entry).performClick());
AlertDialog templates=dialog();main(()->((GridLayout)templates.findViewById(R.id.template_grid)).getChildAt(0).performClick());
EditText target=activity.findViewById(3101);assertNotNull(target);
main(()->{((EditText)activity.findViewById(R.id.entry_title)).setText("Generator QA");target.setText("ORIGINAL_SYNTHETIC_SECRET");});
tap("Сгенерировать пароль");AlertDialog canceled=dialog();secure(canceled);
TextView oldPreview=canceled.findViewById(R.id.generator_preview);
assertEquals(24,preview(canceled).length());assertNull(oldPreview.getTransformationMethod());
assertEquals("ORIGINAL_SYNTHETIC_SECRET",target.getText().toString());
main(()->canceled.getButton(AlertDialog.BUTTON_NEGATIVE).performClick());
assertEquals("ORIGINAL_SYNTHETIC_SECRET",target.getText().toString());assertEquals("",oldPreview.getText().toString());
tap("Сгенерировать пароль");AlertDialog generator=dialog();secure(generator);
SeekBar length=generator.findViewById(R.id.generator_length);
main(()->length.setProgress(32));assertEquals(32,preview(generator).length());
main(()->((CheckBox)generator.findViewById(R.id.generator_symbols)).setChecked(false));assertTrue(preview(generator).matches("[A-Za-z2-9]{32}"));
main(()->((CheckBox)generator.findViewById(R.id.generator_symbols)).setChecked(true));shot("generator-complex");
main(()->generator.findViewById(R.id.generator_digits).performClick());main(()->length.setProgress(4));
assertTrue(preview(generator).matches("[0-9]{4}"));shot("generator-digits");
main(()->generator.findViewById(R.id.generator_memorable).performClick());main(()->length.setProgress(12));
assertEquals(12,countWords(preview(generator)));shot("generator-memorable");
assertEquals(2,length.getMin());main(()->length.setProgress(2));
CheckBox symbols=generator.findViewById(R.id.generator_symbols),digits=generator.findViewById(R.id.generator_add_digits);
assertEquals(View.VISIBLE,symbols.getVisibility());assertEquals(View.VISIBLE,digits.getVisibility());
assertWholeWords(preview(generator),2);assertEquals(2,preview(generator).replaceAll("[^0-9]","").length());shot("generator-two-words");
main(()->digits.setChecked(false));assertFalse(preview(generator).matches(".*[0-9].*"));
main(()->symbols.setChecked(false));assertTrue(preview(generator).matches("[A-Z][a-z]+[A-Z][a-z]+"));
main(()->{digits.setChecked(true);symbols.setChecked(true);});
main(()->generator.findViewById(R.id.generator_complex).performClick());assertEquals(16,length.getMin());assertEquals(32,length.getProgress());assertEquals(32,preview(generator).length());
main(()->generator.findViewById(R.id.generator_digits).performClick());assertEquals(4,length.getProgress());
main(()->generator.findViewById(R.id.generator_memorable).performClick());assertEquals(2,length.getProgress());
main(()->generator.findViewById(R.id.generator_regenerate).performClick());
String selected=preview(generator);assertWholeWords(selected,2);assertEquals(2,selected.replaceAll("[^0-9]","").length());
assertEquals("ORIGINAL_SYNTHETIC_SECRET",target.getText().toString());
main(()->generator.getButton(AlertDialog.BUTTON_POSITIVE).performClick());
assertEquals(selected,target.getText().toString());assertNotNull(target.getTransformationMethod());assertEquals("",preview(generator));
tap("Сохранить");deadline=System.currentTimeMillis()+20000;
while(text(activity.getWindow().getDecorView(),"Изменить")==null&&System.currentTimeMillis()<deadline){Thread.sleep(100);instrument.waitForIdleSync();}
try(VaultCrypto.Key key=VaultCrypto.unlock(repository.encrypted(),master.toCharArray())){
JSONObject saved=repository.read(key).getJSONArray("entries").getJSONObject(0);
assertEquals(selected,saved.getJSONArray("fields").getJSONObject(1).getString("value"));
}
// A secure preview also disappears when the vault is locked while it is open.
tap("Изменить");tap("Сгенерировать пароль");AlertDialog locked=dialog();TextView lockedPreview=locked.findViewById(R.id.generator_preview);
main(()->activity.moveTaskToBack(true));deadline=System.currentTimeMillis()+10000;
while(locked.isShowing()&&System.currentTimeMillis()<deadline){Thread.sleep(100);instrument.waitForIdleSync();}
assertFalse(locked.isShowing());assertEquals("",lockedPreview.getText().toString());
}finally{main(activity::finish);}
}
private int countWords(String value) {
java.util.regex.Matcher words=java.util.regex.Pattern.compile("[A-Z]?[a-z]+").matcher(value);
int count=0;while(words.find())count++;return count;
}
private void assertWholeWords(String value,int count) {
String gap="[0-9!@#$%&*+\\-=?]*";
assertTrue("Words must stay whole",value.matches(gap+"[A-Z][a-z]+(?:"+gap+"[A-Z][a-z]+){"+(count-1)+"}"+gap));
}
}
@@ -0,0 +1,7 @@
package ru.kusoft.qsafe;
import android.os.Bundle;
import androidx.test.runner.AndroidJUnitRunner;
public final class QSafeTestRunner extends AndroidJUnitRunner {
static Bundle arguments;
@Override public void onCreate(Bundle b) {arguments=b;super.onCreate(b);}
}
@@ -0,0 +1,38 @@
package ru.kusoft.qsafe;
import android.app.*;
import android.content.*;
import android.graphics.Bitmap;
import android.view.*;
import android.widget.*;
import junit.framework.TestCase;
import org.json.*;
import java.lang.reflect.Field;
public class ScreenSecurityTests extends TestCase {
private final Instrumentation instrument=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation();
private MainActivity activity;
private void main(Runnable work){instrument.runOnMainSync(work);instrument.waitForIdleSync();}
private AlertDialog dialog()throws Exception{Field f=MainActivity.class.getDeclaredField("dialog");f.setAccessible(true);return (AlertDialog)f.get(activity);}
private void secure(Window w){assertTrue((w.getAttributes().flags&WindowManager.LayoutParams.FLAG_SECURE)!=0);}
private View text(View v,String target){if(v instanceof TextView&&((TextView)v).getText().toString().equals(target))return v;if(v instanceof ViewGroup){ViewGroup g=(ViewGroup)v;for(int i=0;i<g.getChildCount();i++){View found=text(g.getChildAt(i),target);if(found!=null)return found;}}return null;}
private void open()throws Exception {
main(()->{((EditText)activity.findViewById(R.id.master_password)).setText("synthetic screenshot password!");activity.findViewById(R.id.open_vault).performClick();});
long end=System.currentTimeMillis()+20000;while(System.currentTimeMillis()<end){instrument.waitForIdleSync();if(text(activity.getWindow().getDecorView(),"Security synthetic")!=null||text(activity.getWindow().getDecorView(),"Security synthetic\nsecurity@example.test")!=null)return;Thread.sleep(100);}fail("Unlock timed out");
}
public void testProtectedScreenshotDeleteDialogAndAutofillConfirmation()throws Exception {
assertTrue(android.os.Build.MODEL.contains("sdk_gphone"));Context c=instrument.getTargetContext();Repository r=Repository.get(c);r.setAutoSync(false);r.setHomeWifi(false,"");r.fingerprintOffered(true);
try(VaultCrypto.Key key=VaultCrypto.create("synthetic screenshot password!".toCharArray())){
JSONObject data=Vault.empty(),e=CardTemplates.create("login").put("title","Security synthetic");e.getJSONArray("fields").getJSONObject(0).put("value","security@example.test");e.getJSONArray("fields").getJSONObject(1).put("value","SYNTHETIC_SCREEN_SECRET");e.getJSONArray("fields").getJSONObject(2).put("value","https://example.test");Vault.update(data,e);r.save(data,key);
}
activity=(MainActivity)instrument.startActivitySync(new Intent(c,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));secure(activity.getWindow());
Thread.sleep(200);Bitmap capture=instrument.getUiAutomation().takeScreenshot();if(capture!=null){int color=capture.getPixel(capture.getWidth()/2,capture.getHeight()/2);assertEquals("Secure app content must be black in capture",0,color&0x00ffffff);capture.recycle();}
open();View card=text(activity.getWindow().getDecorView(),"Security synthetic");main(()->((View)card.getParent().getParent()).performClick());secure(activity.getWindow());
View remove=text(activity.getWindow().getDecorView(),"Удалить карточку");assertNotNull(remove);main(remove::performClick);secure(dialog().getWindow());main(activity::finish);
Intent autofill=new Intent(c,AutofillActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).putExtra("domain","example.test").putExtra("targetPackage","example.browser");activity=(MainActivity)instrument.startActivitySync(autofill);secure(activity.getWindow());open();
View choice=text(activity.getWindow().getDecorView(),"Security synthetic\nsecurity@example.test");assertNotNull(choice);main(choice::performClick);secure(dialog().getWindow());
main(()->activity.moveTaskToBack(true));long end=System.currentTimeMillis()+10000;
while(dialog()!=null&&System.currentTimeMillis()<end){Thread.sleep(100);instrument.waitForIdleSync();}
assertNull("Leaving autofill must dismiss its confirmation",dialog());main(activity::finish);
}
}
@@ -0,0 +1,68 @@
package ru.kusoft.qsafe;
import android.app.*;
import android.content.*;
import android.graphics.Bitmap;
import android.view.*;
import android.widget.*;
import org.json.*;
import java.io.*;
import java.lang.reflect.*;
import static junit.framework.Assert.*;
/** Acceptance on the dedicated emulator only, with invented data and passwords. */
final class UiChecks {
private final Instrumentation instrument;
private final Context context;
private MainActivity activity;
UiChecks(Instrumentation i){instrument=i;context=i.getTargetContext();}
private void main(Runnable r){instrument.runOnMainSync(r);instrument.waitForIdleSync();}
private Object field(String name)throws Exception{Field f=MainActivity.class.getDeclaredField(name);f.setAccessible(true);return f.get(activity);}
private AlertDialog dialog()throws Exception{return (AlertDialog)field("dialog");}
private void secure(Window w){assertTrue("Window must block capture",(w.getAttributes().flags&WindowManager.LayoutParams.FLAG_SECURE)!=0);}
private View text(View root,String value){if(root instanceof TextView&&((TextView)root).getText().toString().equals(value))return root;if(root instanceof ViewGroup){ViewGroup group=(ViewGroup)root;for(int n=0;n<group.getChildCount();n++){View found=text(group.getChildAt(n),value);if(found!=null)return found;}}return null;}
private View description(View root,String value){if(value.contentEquals(root.getContentDescription()==null?"":root.getContentDescription()))return root;if(root instanceof ViewGroup){ViewGroup group=(ViewGroup)root;for(int n=0;n<group.getChildCount();n++){View found=description(group.getChildAt(n),value);if(found!=null)return found;}}return null;}
private void tapText(String value){View view=text(activity.getWindow().getDecorView(),value);assertNotNull(value,view);main(view::performClick);}
private void tapDialog(String value)throws Exception{View view=text(dialog().getWindow().getDecorView(),value);assertNotNull(value,view);main(view::performClick);}
private interface Ready{boolean get()throws Exception;}
private void await(Ready ready)throws Exception{long end=System.currentTimeMillis()+20000;while(System.currentTimeMillis()<end){instrument.waitForIdleSync();if(ready.get())return;Thread.sleep(100);}fail("UI transition timed out");}
private void shot(String name)throws Exception {
secure(activity.getWindow());AlertDialog d=dialog();if(d!=null&&d.isShowing())secure(d.getWindow());
// This exists only in the test APK, and is used only on invented emulator data.
main(()->{activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);if(d!=null&&d.isShowing())d.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);});
try{Thread.sleep(150);Bitmap bitmap=instrument.getUiAutomation().takeScreenshot();assertNotNull(bitmap);try(FileOutputStream out=new FileOutputStream(new File(context.getExternalFilesDir(null),name+".png"))){bitmap.compress(Bitmap.CompressFormat.PNG,100,out);}bitmap.recycle();}
finally{main(()->{SecureWindows.protect(activity);if(d!=null&&d.isShowing())SecureWindows.protect(d.getWindow());});}
}
void run()throws Exception {
assertTrue("Synthetic acceptance requires an emulator",android.os.Build.MODEL.contains("sdk_gphone"));
Repository r=Repository.get(context);assertFalse("Fresh test installation required",r.exists());r.fingerprintOffered(true);
Intent launch=new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
activity=(MainActivity)instrument.startActivitySync(launch);secure(activity.getWindow());assertNotNull(activity.findViewById(R.id.connect_pi));shot("welcome-1.0.0");
main(()->activity.findViewById(R.id.connect_pi).performClick());secure(dialog().getWindow());tapDialog("Создать новое хранилище");
AlertDialog create=dialog();secure(create.getWindow());main(()->{((EditText)create.findViewById(R.id.master_password)).setText("test master password 123!");((EditText)create.findViewById(R.id.confirm_password)).setText("test master password 123!");create.getButton(AlertDialog.BUTTON_POSITIVE).performClick();});
await(()->activity.findViewById(R.id.add_entry)!=null);assertNull(activity.findViewById(R.id.sync));
main(()->activity.findViewById(R.id.add_entry).performClick());AlertDialog types=dialog();secure(types.getWindow());assertEquals(8,((GridLayout)types.findViewById(R.id.template_grid)).getChildCount());shot("templates-1.0.0");
main(()->((GridLayout)types.findViewById(R.id.template_grid)).getChildAt(0).performClick());assertNull(dialog());assertEquals("edit",field("screen"));secure(activity.getWindow());
main(()->{((EditText)activity.findViewById(R.id.entry_title)).setText("Почта · пример");((EditText)activity.findViewById(3100)).setText("demo@example.test");((EditText)activity.findViewById(3101)).setText("SYNTHETIC_UI_SECRET");((EditText)activity.findViewById(3102)).setText("https://example.test");((EditText)activity.findViewById(R.id.entry_tags)).setText("Работа, Личное");});
shot("editor-1.0.0");tapText("Сохранить");await(()->field("screen").equals("detail"));assertNull(dialog());secure(activity.getWindow());
assertNull(text(activity.getWindow().getDecorView(),"Копировать"));assertNull(text(activity.getWindow().getDecorView(),"SYNTHETIC_UI_SECRET"));
View password=description(activity.getWindow().getDecorView(),"Скопировать Пароль");assertNotNull(password);main(password::performClick);
main(()->{ClipboardManager clipboard=context.getSystemService(ClipboardManager.class);assertEquals("SYNTHETIC_UI_SECRET",clipboard.getPrimaryClip().getItemAt(0).getText().toString());assertTrue(clipboard.getPrimaryClipDescription().getExtras().getBoolean("android.content.extra.IS_SENSITIVE"));clipboard.clearPrimaryClip();});
shot("detail-1.0.0");View eye=description(activity.getWindow().getDecorView(),"Показать Пароль");main(eye::performClick);assertNotNull(text(activity.getWindow().getDecorView(),"SYNTHETIC_UI_SECRET"));main(eye::performClick);
tapText("Изменить");main(()->((EditText)activity.findViewById(R.id.entry_title)).setText("Unsaved synthetic edit"));main(activity::onBackPressed);secure(dialog().getWindow());assertNotNull(text(dialog().getWindow().getDecorView(),"Выйти без сохранения?"));main(()->{try{dialog().getButton(AlertDialog.BUTTON_POSITIVE).performClick();}catch(Exception e){throw new RuntimeException(e);}});assertEquals("detail",field("screen"));
main(activity::onBackPressed);assertEquals("home",field("screen"));
try(VaultCrypto.Key key=VaultCrypto.unlock(r.encrypted(),"test master password 123!".toCharArray())) {
JSONObject data=r.read(key);for(int n=0;n<30;n++){JSONObject card=CardTemplates.create(CardTemplates.KINDS[n%8]).put("title",new String[]{"Почта","Банк","Домашняя сеть","Облачный сервер","Личные заметки"}[n%5]+" · "+(n+1));Vault.update(data,card);}r.save(data,key);
}
main(activity::finish);activity=(MainActivity)instrument.startActivitySync(launch);assertNull(activity.findViewById(R.id.connect_pi));assertNull(activity.findViewById(R.id.confirm_password));
assertEquals(1,countButtons(activity.getWindow().getDecorView()));shot("locked-1.0.0");
main(()->{((EditText)activity.findViewById(R.id.master_password)).setText("test master password 123!");activity.findViewById(R.id.open_vault).performClick();});await(()->activity.findViewById(R.id.add_entry)!=null);
View plus=activity.findViewById(R.id.add_entry);int[] before=new int[2],after=new int[2];main(()->plus.getLocationOnScreen(before));shot("cards-1.0.0");
main(()->((ScrollView)activity.findViewById(R.id.card_scroller)).fullScroll(View.FOCUS_DOWN));main(()->plus.getLocationOnScreen(after));assertEquals(before[0],after[0]);assertEquals(before[1],after[1]);assertTrue(plus.isShown());shot("cards-scrolled-1.0.0");
main(()->((EditText)activity.findViewById(R.id.search)).setText("нет совпадений"));assertEquals("Карточек: 0",((TextView)activity.findViewById(R.id.card_counter)).getText().toString());
main(()->((EditText)activity.findViewById(R.id.search)).setText(""));assertEquals("Карточек: 31",((TextView)activity.findViewById(R.id.card_counter)).getText().toString());
main(()->activity.findViewById(R.id.settings).performClick());assertNull(dialog());assertEquals("menu",field("screen"));secure(activity.getWindow());assertNotNull(activity.findViewById(R.id.fingerprint));assertNotNull(activity.findViewById(R.id.check_updates));
main(activity::finish);activity=(MainActivity)instrument.startActivitySync(launch);assertNull(activity.findViewById(R.id.connect_pi));secure(activity.getWindow());main(activity::finish);
}
private int countButtons(View view){int count=view instanceof Button?1:0;if(view instanceof ViewGroup){ViewGroup g=(ViewGroup)view;for(int n=0;n<g.getChildCount();n++)count+=countButtons(g.getChildAt(n));}return count;}
}
@@ -0,0 +1,40 @@
package ru.kusoft.qsafe;
import android.content.*;
import android.content.pm.*;
import android.net.Uri;
import junit.framework.TestCase;
import org.json.*;
import java.io.*;
import java.nio.file.Files;
public class UpdateDeviceTests extends TestCase {
private Context context(){return androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext();}
private File fixture(String key){String path=QSafeTestRunner.arguments.getString(key);assertNotNull("Missing signed update fixture",path);return new File(path);}
private UpdateManifest release(File file)throws Exception {
return UpdateManifest.parse(new JSONObject().put("slug","qsafe").put("release",new JSONObject().put("version","1.0.1").put("androidVersionCode",4)
.put("platform","android").put("channel","stable").put("packageKind","apk").put("packageSizeBytes",file.length()).put("sha256",VaultCrypto.hash(Files.readAllBytes(file.toPath())))
.put("downloadPath","/api/apps/qsafe/releases/3d149d9b-16fb-4935-8537-b447fce96aeb/download")));
}
public void testTrustedFutureApkAndReadOnlyProvider()throws Exception {
Context c=context();File fixture=fixture("updateApk");UpdateManifest release=release(fixture);AppUpdates.verify(c,fixture,release);
File cache=AppUpdates.apk(c);assertTrue(cache.getParentFile().isDirectory()||cache.getParentFile().mkdirs());Files.copy(fixture.toPath(),cache.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING);
Uri uri=AppUpdates.installer(c).getData();assertEquals("application/vnd.android.package-archive",c.getContentResolver().getType(uri));
try(InputStream in=c.getContentResolver().openInputStream(uri)){assertEquals(release.sha256,VaultCrypto.hash(Repository.bounded(in,UpdateManifest.MAX_APK_BYTES)));}
for(String path:new String[]{"/../files/vault/vault.qsafe","/vault.qsafe","/update.apk?other=1"}) {
try{c.getContentResolver().openInputStream(Uri.parse("content://"+c.getPackageName()+".updates"+path));fail("Provider escaped the APK");}catch(FileNotFoundException expected) { }
}
try{c.getContentResolver().openOutputStream(uri);fail("APK must be read only");}catch(FileNotFoundException expected) { }
ProviderInfo provider=c.getPackageManager().resolveContentProvider(c.getPackageName()+".updates",0);assertFalse(provider.exported);assertTrue(provider.grantUriPermissions);
}
public void testDifferentSigningKeyAndDamagedApkAreRejected()throws Exception {
Context c=context();File otherSigner=fixture("wrongSignerApk");try{AppUpdates.verify(c,otherSigner,release(otherSigner));fail("Different signing key accepted");}catch(IOException expected) { }
File original=fixture("updateApk"),damaged=new File(c.getCacheDir(),"damaged-update.apk");byte[] bytes=Files.readAllBytes(original.toPath());bytes[bytes.length/2]^=1;Files.write(damaged.toPath(),bytes);
try{AppUpdates.verify(c,damaged,release(original));fail("Damaged update accepted");}catch(IOException expected) { }
File current=new File(c.getApplicationInfo().sourceDir);UpdateManifest currentManifest=release(current);
try{AppUpdates.verify(c,current,currentManifest);fail("Current build masqueraded as future build");}catch(IOException expected) { }
}
public void testRealArgusManifestIsReadableOverHttps()throws Exception {
UpdateManifest release=AppUpdates.check();assertTrue(release.versionCode>=2);assertTrue(release.size>0);assertTrue(release.downloadPath.startsWith("/api/apps/qsafe/"));
}
}
@@ -0,0 +1,29 @@
package ru.kusoft.qsafe;
import android.app.*;
import android.content.*;
import junit.framework.TestCase;
import org.json.*;
import java.io.File;
import java.lang.reflect.*;
import java.nio.file.Files;
/** The harness then confirms Android's installer and checks the installed package version. */
public class UpdateInstallerTests extends TestCase {
public void testPrepareVerifiedUpdateAndProtectedPermissionDialog()throws Exception {
assertTrue(android.os.Build.MODEL.contains("sdk_gphone"));Instrumentation i=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation();Context c=i.getTargetContext();
File source=new File(QSafeTestRunner.arguments.getString("updateApk"));
UpdateManifest release=UpdateManifest.parse(new JSONObject().put("slug","qsafe").put("release",new JSONObject().put("version","1.0.1").put("androidVersionCode",4).put("platform","android").put("channel","stable").put("packageKind","apk").put("packageSizeBytes",source.length()).put("sha256",VaultCrypto.hash(Files.readAllBytes(source.toPath()))).put("downloadPath","/api/apps/qsafe/releases/3d149d9b-16fb-4935-8537-b447fce96aeb/download")));
AppUpdates.verify(c,source,release);File target=AppUpdates.apk(c);target.getParentFile().mkdirs();Files.copy(source.toPath(),target.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING);
MainActivity activity=(MainActivity)i.startActivitySync(new Intent(c,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
Field f=MainActivity.class.getDeclaredField("downloadedUpdate");f.setAccessible(true);f.set(activity,release);Method method=MainActivity.class.getDeclaredMethod("requestUpdateInstall");method.setAccessible(true);
i.runOnMainSync(()->{try{
Field automatic=MainActivity.class.getDeclaredField("automaticFingerprintRequested");automatic.setAccessible(true);automatic.set(activity,true);
Field biometric=MainActivity.class.getDeclaredField("biometrics");biometric.setAccessible(true);((Biometrics)biometric.get(activity)).cancel();
method.invoke(activity);
}catch(Exception e){throw new RuntimeException(e);}});
assertFalse("Harness must exercise the unknown-sources permission flow",c.getPackageManager().canRequestPackageInstalls());
Field dialog=MainActivity.class.getDeclaredField("dialog");dialog.setAccessible(true);android.app.Dialog permission=(android.app.Dialog)dialog.get(activity);assertNotNull(permission);assertTrue(permission.isShowing());
assertTrue((permission.getWindow().getAttributes().flags&android.view.WindowManager.LayoutParams.FLAG_SECURE)!=0);android.util.Log.i("QSafeQA","INSTALLER_PERMISSION_READY");
}
}
@@ -0,0 +1,12 @@
package ru.kusoft.qsafe;
import junit.framework.TestCase;
public final class UpgradeTests extends TestCase {
public void testLegacyVaultRemainsReadableAfterUpdate() throws Exception {
android.content.Context c=androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext();
Repository r=Repository.get(c);assertTrue(r.exists());byte[] before=r.encrypted();
try(VaultCrypto.Key k=VaultCrypto.unlock(before,"test master password 123!".toCharArray())) {
assertEquals("Почта · пример",Vault.visible(r.read(k),"",false).get(0).getString("title"));
}
assertTrue(java.util.Arrays.equals(before,r.encrypted()));
}
}
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<queries><intent><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="https" /></intent></queries>
<application android:name=".QSafeApplication" android:theme="@style/AppTheme" android:label="QSafe" android:icon="@drawable/ic_launcher"
android:allowBackup="false" android:fullBackupContent="false" android:dataExtractionRules="@xml/data_extraction_rules" android:usesCleartextTraffic="false"
android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true" android:windowSoftInputMode="adjustResize" android:configChanges="uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".AutofillActivity" android:exported="false" android:excludeFromRecents="true" android:configChanges="uiMode" />
<provider android:name=".UpdateProvider" android:authorities="${applicationId}.updates" android:exported="false" android:grantUriPermissions="true" />
<service android:name=".QSafeAutofillService" android:exported="true" android:permission="android.permission.BIND_AUTOFILL_SERVICE" android:label="QSafe">
<intent-filter><action android:name="android.service.autofill.AutofillService" /></intent-filter>
<meta-data android:name="android.autofill" android:resource="@xml/autofill_service" />
</service>
<service android:name=".HomeWifiJob" android:exported="true" android:permission="android.permission.BIND_JOB_SERVICE" />
<receiver android:name=".HomeWifiReceiver" android:exported="false">
<intent-filter><action android:name="android.intent.action.BOOT_COMPLETED" /><action android:name="android.intent.action.MY_PACKAGE_REPLACED" /></intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,38 @@
package ru.kusoft.qsafe;
import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Color;
import android.view.View;
import android.view.Window;
/** Device-local appearance preference; contains no vault data. */
public final class AppAppearance {
public static final String SYSTEM="system",LIGHT="light",DARK="dark";
private AppAppearance() { }
public static String mode(Context context) {
String value=context.getSharedPreferences("appearance",Context.MODE_PRIVATE).getString("theme",SYSTEM);
return LIGHT.equals(value)||DARK.equals(value)?value:SYSTEM;
}
public static void save(Context context,String value) {
if(!SYSTEM.equals(value)&&!LIGHT.equals(value)&&!DARK.equals(value))throw new IllegalArgumentException("Unknown theme");
context.getSharedPreferences("appearance",Context.MODE_PRIVATE).edit().putString("theme",value).apply();
}
public static boolean dark(Context context) {
String mode=mode(context);
return DARK.equals(mode)||(SYSTEM.equals(mode)&&(context.getResources().getConfiguration().uiMode&Configuration.UI_MODE_NIGHT_MASK)==Configuration.UI_MODE_NIGHT_YES);
}
public static void applyStyle(Activity activity) {activity.setTheme(dark(activity)?R.style.AppTheme_Dark:R.style.AppTheme);}
public static void disableForceDark(Window window) {
if(android.os.Build.VERSION.SDK_INT>=29&&window!=null)window.getDecorView().setForceDarkAllowed(false);
}
@SuppressWarnings("deprecation") public static void applyWindow(Activity activity) {
boolean dark=dark(activity);int background=Color.parseColor(dark?"#101817":"#F5F8F7");
activity.getWindow().setStatusBarColor(background);activity.getWindow().setNavigationBarColor(background);
View decor=activity.getWindow().getDecorView();int flags=decor.getSystemUiVisibility();
disableForceDark(activity.getWindow());
int lightBars=View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR|View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
decor.setSystemUiVisibility(dark?flags&~lightBars:flags|lightBars);
}
}
@@ -0,0 +1,72 @@
package ru.kusoft.qsafe;
import android.content.*;
import android.content.pm.*;
import android.net.Uri;
import org.json.*;
import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import javax.net.ssl.HttpsURLConnection;
public final class AppUpdates {
private AppUpdates() { }
public static File apk(Context context){return new File(context.getCacheDir(),"updates/update.apk");}
private static HttpsURLConnection connection(String path)throws IOException {
HttpsURLConnection c=(HttpsURLConnection)new URL(UpdateManifest.ORIGIN+path).openConnection();
c.setInstanceFollowRedirects(false);c.setConnectTimeout(15000);c.setReadTimeout(30000);c.setRequestProperty("Accept","application/json, application/vnd.android.package-archive");
return c;
}
public static UpdateManifest check()throws Exception {
HttpsURLConnection c=connection("/api/apps/qsafe/manifest?platform=android&channel=stable");
try {
if(c.getResponseCode()!=200)throw new IOException("Каталог обновлений недоступен. Повторите позже.");
byte[] bytes=Repository.bounded(c.getInputStream(),128*1024);
return UpdateManifest.parse(new JSONObject(new String(bytes,StandardCharsets.UTF_8)));
}finally{c.disconnect();}
}
public static File download(Context context,UpdateManifest release)throws Exception {
if(!release.newerThan(context.getPackageManager().getPackageInfo(context.getPackageName(),0).getLongVersionCode()))throw new IOException("Эта версия уже установлена");
File target=apk(context),directory=target.getParentFile();
if(!directory.isDirectory()&&!directory.mkdirs())throw new IOException("Не удалось подготовить загрузку");
File part=new File(directory,"update.part.apk");HttpsURLConnection c=connection(release.downloadPath);
try {
if(c.getResponseCode()!=200)throw new IOException("Не удалось скачать обновление");
long announced=c.getContentLengthLong();if(announced!=-1&&announced!=release.size)throw new IOException("Размер обновления не совпадает с каталогом");
try(InputStream in=c.getInputStream();FileOutputStream out=new FileOutputStream(part)) {
byte[] buffer=new byte[32768];long total=0;int read;
while((read=in.read(buffer))!=-1) {total+=read;if(total>release.size)throw new IOException("Превышен размер обновления");out.write(buffer,0,read);}
if(total!=release.size)throw new IOException("Обновление загружено не полностью");out.getFD().sync();
}
verify(context,part,release);
java.nio.file.Files.move(part.toPath(),target.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING);
return target;
}finally{c.disconnect();java.nio.file.Files.deleteIfExists(part.toPath());}
}
public static void verify(Context context,File file,UpdateManifest release)throws Exception {
if(file.length()!=release.size)throw new IOException("Изменился размер APK");
MessageDigest sha=MessageDigest.getInstance("SHA-256");
try(InputStream in=new FileInputStream(file)){byte[] buffer=new byte[32768];int n;while((n=in.read(buffer))!=-1)sha.update(buffer,0,n);}
StringBuilder hash=new StringBuilder();for(byte b:sha.digest())hash.append(String.format(Locale.ROOT,"%02x",b&255));
if(!hash.toString().equals(release.sha256))throw new IOException("Контрольная сумма APK не совпадает. Установка отменена.");
PackageManager pm=context.getPackageManager();PackageInfo installed=pm.getPackageInfo(context.getPackageName(),PackageManager.GET_SIGNING_CERTIFICATES);
PackageInfo candidate=pm.getPackageArchiveInfo(file.getAbsolutePath(),PackageManager.GET_SIGNING_CERTIFICATES);
verifyIdentity(installed,candidate,release);
}
static void verifyIdentity(PackageInfo installed,PackageInfo candidate,UpdateManifest release)throws IOException {
if(candidate==null||!installed.packageName.equals(candidate.packageName)||candidate.getLongVersionCode()!=release.versionCode||
!release.newerThan(installed.getLongVersionCode())||!release.version.equals(candidate.versionName))throw new IOException("APK не является новой версией QSafe");
if(installed.signingInfo==null||candidate.signingInfo==null||!signers(installed).equals(signers(candidate)))throw new IOException("Подпись APK отличается от установленного QSafe. Установка отменена.");
}
private static Set<String> signers(PackageInfo info)throws IOException {
Signature[] signatures=info.signingInfo.getApkContentsSigners();if(signatures==null||signatures.length==0)throw new IOException("APK не подписан");
Set<String> result=new HashSet<>();for(Signature s:signatures)result.add(Base64.getEncoder().encodeToString(s.toByteArray()));return result;
}
public static Intent installer(Context context) {
Uri uri=Uri.parse("content://"+context.getPackageName()+".updates/update.apk");
Intent intent=new Intent(Intent.ACTION_VIEW).setDataAndType(uri,"application/vnd.android.package-archive").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setClipData(ClipData.newRawUri("QSafe update",uri));return intent;
}
}
@@ -0,0 +1,65 @@
package ru.kusoft.qsafe;
import android.app.AlertDialog;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.service.autofill.Dataset;
import android.service.autofill.FillResponse;
import android.view.*;
import android.view.autofill.*;
import android.widget.*;
import org.json.*;
import java.net.URI;
import java.util.*;
public final class AutofillActivity extends MainActivity {
@Override protected boolean isAutofill() {return true;}
@Override public void onCreate(Bundle saved) {setResult(RESULT_CANCELED);super.onCreate(saved);}
@Override protected void showAutofill() {
JSONObject data=unlockedData();if(data==null)return;
LinearLayout layout=new LinearLayout(this);layout.setOrientation(LinearLayout.VERTICAL);layout.setPadding(32,72,32,32);layout.setBackgroundColor(BG);
layout.setOnApplyWindowInsetsListener((v,i)->{v.setPadding(32,i.getSystemWindowInsetTop()+32,32,i.getSystemWindowInsetBottom()+16);return i;});
TextView title=new TextView(this);title.setText("QSafe · выберите карточку");title.setTextSize(24);title.setTextColor(INK);layout.addView(title);
String domain=getIntent().getStringExtra("domain"),target=getIntent().getStringExtra("targetPackage");
if(domain==null)domain="";if(target==null)target="Неизвестное приложение";
TextView origin=new TextView(this);String destination=domain.isEmpty()?"Приложение: "+target:"Сайт: "+domain;
destination+="\nПриложение: "+target;
origin.setText(destination);origin.setTextSize(16);origin.setTextColor(MUTED);origin.setPadding(0,16,0,24);layout.addView(origin);
ScrollView scroll=new ScrollView(this);LinearLayout entries=new LinearLayout(this);entries.setOrientation(LinearLayout.VERTICAL);scroll.addView(entries);layout.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));
try {
int count=0;
for(JSONObject entry:Vault.visible(data,"",false)) {
String login="",password="";boolean matches=domain.isEmpty();JSONArray fields=entry.getJSONArray("fields");
for(int i=0;i<fields.length();i++) {
JSONObject f=fields.getJSONObject(i);String type=f.optString("type");
if((type.equals("login")||type.equals("email"))&&login.isEmpty())login=f.optString("value");
if(type.equals("password")&&password.isEmpty())password=f.optString("value");
if(type.equals("website")||type.equals("url")) {
try {
String value=f.optString("value");URI u=new URI(value.contains("://")?value:"https://"+value);
if(u.getHost()!=null&&!domain.isEmpty()&&java.net.IDN.toASCII(u.getHost()).equalsIgnoreCase(java.net.IDN.toASCII(domain)))matches=true;
}catch(Exception ignored){}
}
}
if(password.isEmpty()||!matches)continue;count++;
Button button=new Button(this);button.setAllCaps(false);button.setTextColor(INK);button.setText(entry.optString("title")+"\n"+login);entries.addView(button);
String selectedLogin=login,selectedPassword=password,confirmedDestination=destination;
button.setOnClickListener(v->showProtectedDialog(new AlertDialog.Builder(this).setTitle("Заполнить пароль?")
.setMessage(confirmedDestination+"\nКарточка: "+entry.optString("title"))
.setNegativeButton("Отмена",null).setPositiveButton("Заполнить",(d,w)->fill(entry.optString("title"),selectedLogin,selectedPassword)).create()));
}
if(count==0) {TextView empty=new TextView(this);empty.setTextColor(INK);empty.setText("Нет подходящих карточек. Для сайта адрес в карточке должен совпадать с доменом.");entries.addView(empty);}
}catch(JSONException e){finish();return;}
Button cancel=new Button(this);cancel.setText("Отмена");cancel.setOnClickListener(v->finish());layout.addView(cancel);setContentView(layout);layout.requestApplyInsets();
}
@SuppressWarnings("deprecation") private void fill(String name,String login,String password) {
ArrayList<AutofillId> logins=getIntent().getParcelableArrayListExtra("logins"),passwords=getIntent().getParcelableArrayListExtra("passwords");
if(passwords==null||passwords.isEmpty()) {finish();return;}
RemoteViews view=new RemoteViews(getPackageName(),android.R.layout.simple_list_item_1);view.setTextViewText(android.R.id.text1,name);
Dataset.Builder dataset=new Dataset.Builder(view);
if(logins!=null)for(AutofillId id:logins)dataset.setValue(id,AutofillValue.forText(login));
for(AutofillId id:passwords)dataset.setValue(id,AutofillValue.forText(password));
setResult(RESULT_OK,new Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT,new FillResponse.Builder().addDataset(dataset.build()).build()));finish();
}
}
@@ -0,0 +1,89 @@
package ru.kusoft.qsafe;
import android.app.Activity;
import android.hardware.biometrics.BiometricPrompt;
import android.os.CancellationSignal;
import android.security.keystore.*;
import android.util.AtomicFile;
import java.io.File;
import java.nio.ByteBuffer;
import java.security.*;
import java.util.Arrays;
import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
public final class Biometrics {
private static final String ALIAS="qsafe.biometric.v1";
private final Activity activity;
private final AtomicFile wrapped;
private CancellationSignal pending;
public interface Result {void done(VaultCrypto.Key key);void error(String message);}
public Biometrics(Activity a) {activity=a;wrapped=new AtomicFile(new File(a.getFilesDir(),"biometric.enc"));}
public boolean enabled() {return wrapped.getBaseFile().exists();}
@SuppressWarnings("deprecation") public boolean available() {
if(!activity.getPackageManager().hasSystemFeature(android.content.pm.PackageManager.FEATURE_FINGERPRINT))return false;
if(android.os.Build.VERSION.SDK_INT>=30)return activity.getSystemService(android.hardware.biometrics.BiometricManager.class).canAuthenticate(android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG)==android.hardware.biometrics.BiometricManager.BIOMETRIC_SUCCESS;
if(android.os.Build.VERSION.SDK_INT>=29)return activity.getSystemService(android.hardware.biometrics.BiometricManager.class).canAuthenticate()==android.hardware.biometrics.BiometricManager.BIOMETRIC_SUCCESS;
android.hardware.fingerprint.FingerprintManager manager=activity.getSystemService(android.hardware.fingerprint.FingerprintManager.class);
return manager!=null&&manager.hasEnrolledFingerprints();
}
public void cancel() {if(pending!=null) {pending.cancel();pending=null;}}
public void disable() throws Exception {cancel();wrapped.delete();KeyStore s=KeyStore.getInstance("AndroidKeyStore");s.load(null);s.deleteEntry(ALIAS);}
private SecretKey key(boolean create) throws Exception {
KeyStore s=KeyStore.getInstance("AndroidKeyStore");s.load(null);
if(create) {
s.deleteEntry(ALIAS);
KeyGenerator g=KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,"AndroidKeyStore");
KeyGenParameterSpec.Builder b=new KeyGenParameterSpec.Builder(ALIAS,KeyProperties.PURPOSE_ENCRYPT|KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256).setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true);
if(android.os.Build.VERSION.SDK_INT>=30) b.setUserAuthenticationParameters(0,KeyProperties.AUTH_BIOMETRIC_STRONG);
else b.setUserAuthenticationValidityDurationSeconds(-1);
g.init(b.build());return g.generateKey();
}
return (SecretKey)s.getKey(ALIAS,null);
}
public void enable(VaultCrypto.Key vaultKey,Result callback) {
try {
Cipher c=Cipher.getInstance("AES/GCM/NoPadding");c.init(Cipher.ENCRYPT_MODE,key(true));
byte[] payload=ByteBuffer.allocate(52).putInt(vaultKey.iterations).put(vaultKey.salt).put(vaultKey.bytes).array();
prompt(c,new Result() {
public void done(VaultCrypto.Key ignored) {
try {byte[] result=c.doFinal(payload);Repository.write(wrapped,ByteBuffer.allocate(12+result.length).put(c.getIV()).put(result).array());callback.done(null);}
catch(Exception e) {wrapped.delete();callback.error("Не удалось включить биометрию. Проверьте отпечатки в настройках телефона.");}
finally {Arrays.fill(payload,(byte)0);}
}
public void error(String m) {Arrays.fill(payload,(byte)0);callback.error(m);}
});
} catch(Exception e) {wrapped.delete();callback.error("Сначала настройте надёжную биометрию в Android.");}
}
public void unlock(Result callback) {
try {
byte[] data=wrapped.readFully();if(data.length<28) throw new GeneralSecurityException();
Cipher c=Cipher.getInstance("AES/GCM/NoPadding");c.init(Cipher.DECRYPT_MODE,key(false),new GCMParameterSpec(128,Arrays.copyOf(data,12)));
prompt(c,new Result() {
public void done(VaultCrypto.Key ignored) {
byte[] plain=null;
try {
plain=c.doFinal(data,12,data.length-12);if(plain.length!=52) throw new GeneralSecurityException();
ByteBuffer b=ByteBuffer.wrap(plain);int count=b.getInt();byte[] salt=new byte[16],key=new byte[32];b.get(salt);b.get(key);
callback.done(new VaultCrypto.Key(key,salt,count));
} catch(Exception e) {callback.error("Войдите мастер-паролем и настройте биометрию заново.");}
finally {if(plain!=null) Arrays.fill(plain,(byte)0);}
}
public void error(String m) {callback.error(m);}
});
} catch(Exception e) {callback.error("Биометрический ключ недоступен. Введите мастер-пароль.");}
}
private void prompt(Cipher cipher,Result callback) {
cancel();CancellationSignal signal=new CancellationSignal();pending=signal;
java.util.concurrent.atomic.AtomicBoolean completed=new java.util.concurrent.atomic.AtomicBoolean();
BiometricPrompt.Builder b=new BiometricPrompt.Builder(activity).setTitle("Вход в QSafe").setSubtitle("Подтвердите отпечаток пальца")
.setNegativeButton("Мастер-пароль",activity.getMainExecutor(),(d,w)->{if(completed.compareAndSet(false,true))callback.error("Введите мастер-пароль");});
if(android.os.Build.VERSION.SDK_INT>=30) b.setAllowedAuthenticators(android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG);
b.build().authenticate(new BiometricPrompt.CryptoObject(cipher),signal,activity.getMainExecutor(),new BiometricPrompt.AuthenticationCallback() {
public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {if(pending==signal)pending=null;if(completed.compareAndSet(false,true))callback.done(null);}
public void onAuthenticationError(int code,CharSequence message) {if(pending==signal)pending=null;if(completed.compareAndSet(false,true))callback.error("Отпечаток: "+message);}
});
}
}
@@ -0,0 +1,56 @@
package ru.kusoft.qsafe;
import org.json.*;
import java.net.URI;
import java.util.*;
public final class CardTemplates {
public static final String[] KINDS={"login","bank","email","wifi","server","document","note","custom"};
public static final String[] TITLES={"Сайт или приложение","Банковская карта","Электронная почта","Сеть Wi-Fi","Сервер","Документ","Секретная заметка","Своя карточка"};
private CardTemplates() { }
public static String title(String kind) {for(int i=0;i<KINDS.length;i++)if(KINDS[i].equals(kind))return TITLES[i];return TITLES[0];}
public static JSONObject create(String kind) throws JSONException {
if(!Arrays.asList(KINDS).contains(kind))throw new IllegalArgumentException("Неизвестный тип карточки");
JSONObject entry=Vault.entry("").put("kind",kind);JSONArray fields=entry.getJSONArray("fields");
switch(kind) {
case "bank":add(fields,"Номер карты","card");add(fields,"Владелец","text");add(fields,"Срок действия","date");add(fields,"CVV / CVC","pin");add(fields,"PIN","pin");break;
case "email":add(fields,"Адрес почты","email");add(fields,"Пароль","password");add(fields,"Сайт","website");break;
case "wifi":add(fields,"Имя сети (SSID)","text");add(fields,"Пароль сети","password");break;
case "server":add(fields,"Сервер / адрес","text");add(fields,"Логин","login");add(fields,"Пароль","password");add(fields,"Порт","text");break;
case "document":add(fields,"Тип документа","text");add(fields,"Номер документа","secret");add(fields,"Владелец","text");add(fields,"Срок действия","date");break;
case "note":break;
case "custom":add(fields,"Секретное поле","secret");break;
default:add(fields,"Логин","login");add(fields,"Пароль","password");add(fields,"Сайт","website");
}
return entry;
}
private static void add(JSONArray fields,String name,String type)throws JSONException {fields.put(Vault.field(name,type,""));}
public static String kind(JSONObject entry) {
String explicit=entry.optString("kind");if(Arrays.asList(KINDS).contains(explicit))return explicit;
JSONArray fields=entry.optJSONArray("fields");boolean email=false;
if(fields!=null)for(int i=0;i<fields.length();i++) {
JSONObject field=fields.optJSONObject(i);if(field==null)continue;
String type=field.optString("type").toLowerCase(Locale.ROOT),name=field.optString("name").toLowerCase(Locale.ROOT);
if(type.contains("card"))return "bank";
if(name.contains("ssid")||name.contains("wi-fi"))return "wifi";
if(type.equals("email"))email=true;
}
return email?"email":"login";
}
public static String subtitle(JSONObject entry) {
JSONArray fields=entry.optJSONArray("fields");String login="";
if(fields!=null)for(int i=0;i<fields.length();i++) {
JSONObject field=fields.optJSONObject(i);if(field==null)continue;
String type=field.optString("type"),value=field.optString("value").trim();
if(value.isEmpty())continue;
if(type.equals("website")||type.equals("url"))try {
URI uri=new URI(value.contains("://")?value:"https://"+value);String host=uri.getHost();
if(host!=null)return host.startsWith("www.")?host.substring(4):host;
}catch(Exception ignored) { }
if(login.isEmpty()&&(type.equals("email")||type.equals("login")))login=value;
}
if(!login.isEmpty())return login;
JSONArray tags=entry.optJSONArray("tags");if(tags!=null&&tags.length()>0)return tags.optString(0);
return title(kind(entry));
}
}
@@ -0,0 +1,19 @@
package ru.kusoft.qsafe;
/** A locked vault may upload only against a known common version; changed remote data is staged. */
public final class CipherSync {
private CipherSync() {}
public enum Plan { CREATE, MISSING_REMOTE, UNCHANGED, UPLOAD, STAGE }
public static Plan plan(String local,String common,String remote) {
if(remote==null) return common.isEmpty()?Plan.CREATE:Plan.MISSING_REMOTE;
if(remote.equals(local))return Plan.UNCHANGED;
if(!common.isEmpty()&&common.equals(remote))return Plan.UPLOAD;
return Plan.STAGE;
}
public static String ssid(String raw) {
if(raw==null||raw.equals("<unknown ssid>"))return "";
if(raw.length()>=2&&raw.startsWith("\"")&&raw.endsWith("\""))return raw.substring(1,raw.length()-1);
return raw;
}
public static boolean matches(String expected,String observed) {return !expected.isEmpty()&&expected.equals(ssid(observed));}
}
@@ -0,0 +1,46 @@
package ru.kusoft.qsafe;
import android.graphics.*;
import android.graphics.drawable.Drawable;
/** Small local vector pictograms. Card domains are never sent to an icon service. */
public final class GlyphDrawable extends Drawable {
private final String kind;
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final int size;
public GlyphDrawable(String kind, int color, int pixels) {
this.kind=kind;size=pixels;paint.setColor(color);paint.setStrokeWidth(1.7f);
paint.setStrokeCap(Paint.Cap.ROUND);paint.setStrokeJoin(Paint.Join.ROUND);paint.setStyle(Paint.Style.STROKE);
setBounds(0,0,pixels,pixels);
}
private void line(Canvas c,float x,float y,float x2,float y2) {c.drawLine(x,y,x2,y2,paint);}
private void path(Canvas c,float... points) {
Path p=new Path();p.moveTo(points[0],points[1]);for(int i=2;i<points.length;i+=2)p.lineTo(points[i],points[i+1]);c.drawPath(p,paint);
}
@Override public void draw(Canvas canvas) {
canvas.save();canvas.translate(getBounds().left,getBounds().top);canvas.scale(getBounds().width()/24f,getBounds().height()/24f);
switch(kind) {
case "menu":line(canvas,4,6,20,6);line(canvas,4,12,20,12);line(canvas,4,18,20,18);break;
case "plus":line(canvas,5,12,19,12);line(canvas,12,5,12,19);break;
case "search":canvas.drawCircle(10.5f,10.5f,6.5f,paint);line(canvas,15.5f,15.5f,21,21);break;
case "chevron":path(canvas,9,6,15,12,9,18);break;
case "back":path(canvas,14,5,7,12,14,19);line(canvas,7,12,21,12);break;
case "eye":canvas.drawOval(3,6,21,18,paint);canvas.drawCircle(12,12,3,paint);break;
case "bank":canvas.drawRoundRect(3,5,21,19,3,3,paint);line(canvas,3,10,21,10);line(canvas,6,15,10,15);break;
case "email":canvas.drawRoundRect(3,5,21,19,3,3,paint);path(canvas,4,7,12,13,20,7);break;
case "wifi":canvas.drawArc(2,4,22,24,225,90,false,paint);canvas.drawArc(6,9,18,23,225,90,false,paint);canvas.drawCircle(12,19,.8f,paint);break;
case "server":canvas.drawRoundRect(3,3,21,11,2,2,paint);canvas.drawRoundRect(3,13,21,21,2,2,paint);canvas.drawCircle(7,7,.5f,paint);canvas.drawCircle(7,17,.5f,paint);line(canvas,12,7,17,7);line(canvas,12,17,17,17);break;
case "document":canvas.drawRoundRect(3,5,21,19,2,2,paint);canvas.drawCircle(8,10,2,paint);canvas.drawArc(5,12,11,17,180,180,false,paint);line(canvas,14,9,18,9);line(canvas,14,13,18,13);break;
case "note":canvas.drawRoundRect(5,3,19,21,2,2,paint);line(canvas,8,8,16,8);line(canvas,8,12,16,12);line(canvas,8,16,13,16);break;
case "custom":canvas.drawRoundRect(4,4,20,20,4,4,paint);line(canvas,8,12,16,12);line(canvas,12,8,12,16);break;
case "shield":path(canvas,12,2,21,6,20,14,17,19,12,22,7,19,4,14,3,6,12,2);path(canvas,8,12,11,15,16,9);break;
default:canvas.drawCircle(8,9,4,paint);path(canvas,11,12,20,21,23,18,20,15,18,17);break;
}
canvas.restore();
}
@Override public int getIntrinsicWidth(){return size;}
@Override public int getIntrinsicHeight(){return size;}
@Override public void setAlpha(int alpha){paint.setAlpha(alpha);invalidateSelf();}
@Override public void setColorFilter(ColorFilter filter){paint.setColorFilter(filter);invalidateSelf();}
@Override public int getOpacity(){return PixelFormat.TRANSLUCENT;}
}
@@ -0,0 +1,35 @@
package ru.kusoft.qsafe;
import android.app.job.*;
import android.net.Network;
import android.os.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.ConcurrentHashMap;
public final class HomeWifiJob extends JobService {
private final ConcurrentHashMap<Integer,AtomicBoolean> running=new ConcurrentHashMap<>();
@Override public boolean onStartJob(JobParameters parameters) {
AtomicBoolean cancelled=new AtomicBoolean();running.put(parameters.getJobId(),cancelled);
SyncRuntime.IO.execute(()->{
boolean retry=false;
try {
Repository r=Repository.get(this);
if(!r.homeWifiEnabled()||!r.connected())return;
String problem=HomeWifiSync.permissionProblem(this,true);
if(!problem.isEmpty()){r.backgroundStatus(problem);return;}
Network network=HomeWifiSync.homeNetwork(this,r.homeSsid(),parameters.getNetwork(),true);
if(network==null){r.backgroundStatus("Ожидается домашний Wi-Fi «"+r.homeSsid()+"»");return;}
String result=r.backgroundSync(network,cancelled::get);
r.backgroundStatus(new java.text.SimpleDateFormat("dd.MM HH:mm",java.util.Locale.getDefault()).format(new java.util.Date())+" · "+result);
}catch(Exception e) {
retry=true;
try{Repository.get(this).backgroundStatus("Не удалось связаться с Pi. Android повторит попытку; локальная база сохранена.");}catch(Exception ignored){}
}finally {
boolean needsRetry=retry;
new Handler(Looper.getMainLooper()).post(()->{running.remove(parameters.getJobId(),cancelled);if(!cancelled.get())jobFinished(parameters,needsRetry);});
}
});
return true;
}
@Override public boolean onStopJob(JobParameters parameters) {AtomicBoolean stopped=running.remove(parameters.getJobId());if(stopped!=null)stopped.set(true);return true;}
}
@@ -0,0 +1,6 @@
package ru.kusoft.qsafe;
import android.content.*;
/** Explicit PendingIntent target plus system boot/package-replaced broadcasts; no credentials in intents. */
public final class HomeWifiReceiver extends BroadcastReceiver {
@Override public void onReceive(Context context,Intent intent) {HomeWifiSync.enqueue(context);}
}
@@ -0,0 +1,96 @@
package ru.kusoft.qsafe;
import android.Manifest;
import android.app.*;
import android.app.job.*;
import android.content.*;
import android.content.pm.PackageManager;
import android.location.LocationManager;
import android.net.*;
import android.net.wifi.*;
import android.os.Build;
/** Persistent network PendingIntent survives process death; periodic work recovers OEM missed callbacks. */
public final class HomeWifiSync {
public static final int ARRIVAL_JOB=7201,RECOVERY_JOB=7202;
private static ConnectivityManager.NetworkCallback liveCallback;
private HomeWifiSync() {}
private static NetworkRequest wifi() {
return new NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build();
}
private static PendingIntent operation(Context c) {
Intent i=new Intent(c,HomeWifiReceiver.class).setAction("ru.kusoft.qsafe.HOME_WIFI_AVAILABLE");
return PendingIntent.getBroadcast(c,7200,i,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE);
}
public static synchronized void install(Context context,Repository repository) {
Context c=context.getApplicationContext();ConnectivityManager cm=c.getSystemService(ConnectivityManager.class);
JobScheduler jobs=c.getSystemService(JobScheduler.class);
if(!repository.homeWifiEnabled()||!repository.connected()) {
try{cm.unregisterNetworkCallback(operation(c));}catch(RuntimeException ignored){}
if(liveCallback!=null){try{cm.unregisterNetworkCallback(liveCallback);}catch(RuntimeException ignored){}liveCallback=null;}
jobs.cancel(ARRIVAL_JOB);jobs.cancel(RECOVERY_JOB);return;
}
// Re-registering the same PendingIntent replaces its previous request instead of leaking registrations.
cm.registerNetworkCallback(wifi(),operation(c));
// Keep reconnects responsive while the process lives; the PendingIntent and periodic
// job remain responsible for recovery after process death and OEM scheduling delays.
if(liveCallback==null) {
ConnectivityManager.NetworkCallback callback=new ConnectivityManager.NetworkCallback(){
@Override public void onAvailable(Network network){enqueue(c,repository);}
@Override public void onLost(Network network){
synchronized(HomeWifiSync.class){if(repository.homeWifiEnabled()&&repository.connected())cm.registerNetworkCallback(wifi(),operation(c));}
}
};
cm.registerNetworkCallback(wifi(),callback);liveCallback=callback;
}
JobInfo periodic=new JobInfo.Builder(RECOVERY_JOB,new ComponentName(c,HomeWifiJob.class))
.setRequiredNetwork(wifi()).setPeriodic(15*60_000L).setPersisted(true).build();
if(jobs.getPendingJob(RECOVERY_JOB)==null)jobs.schedule(periodic);
enqueue(c,repository);
}
public static synchronized void enqueue(Context c) {
try{enqueue(c,Repository.get(c));}catch(Exception ignored){ }
}
private static synchronized void enqueue(Context c,Repository repository) {
if(!repository.homeWifiEnabled()||!repository.connected())return;
JobScheduler jobs=c.getSystemService(JobScheduler.class);
JobInfo job=new JobInfo.Builder(ARRIVAL_JOB,new ComponentName(c,HomeWifiJob.class))
.setRequiredNetwork(wifi()).setBackoffCriteria(30_000,JobInfo.BACKOFF_POLICY_EXPONENTIAL).setPersisted(true).build();
if(jobs.getPendingJob(ARRIVAL_JOB)==null)jobs.schedule(job);
}
public static boolean locationAllowed(Context c) {return c.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)==PackageManager.PERMISSION_GRANTED;}
public static boolean backgroundAllowed(Context c) {return Build.VERSION.SDK_INT<29||c.checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION)==PackageManager.PERMISSION_GRANTED;}
public static String permissionProblem(Context c,boolean background) {
if(!locationAllowed(c))return "Для имени Wi-Fi разрешите точный доступ к местоположению";
LocationManager lm=c.getSystemService(LocationManager.class);
if(lm==null||!lm.isLocationEnabled())return "Включите геолокацию Android, чтобы система разрешила читать имя Wi-Fi";
if(background&&!backgroundAllowed(c))return "Для закрытого приложения разрешите местоположение «Всегда»";
return "";
}
@SuppressWarnings("deprecation") public static String currentSsid(Context c) {
if(!permissionProblem(c,false).isEmpty())return "";
try {
WifiManager wm=(WifiManager)c.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
WifiInfo info=wm.getConnectionInfo();return info==null?"":CipherSync.ssid(info.getSSID());
}catch(SecurityException e){return "";}
}
@SuppressWarnings("deprecation") public static Network homeNetwork(Context c,String expected,Network preferred,boolean background) {
if(!permissionProblem(c,background).isEmpty()||!CipherSync.matches(expected,currentSsid(c)))return null;
ConnectivityManager cm=c.getSystemService(ConnectivityManager.class);
WifiManager wm=(WifiManager)c.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
WifiInfo current=wm==null?null:wm.getConnectionInfo();int wifiAddress=current==null?0:current.getIpAddress();
if(wifiAddress==0)return null;
Network[] networks=preferred==null?cm.getAllNetworks():new Network[]{preferred};
for(Network n:networks) {
NetworkCapabilities caps=cm.getNetworkCapabilities(n);
if(caps==null||!caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)||caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))continue;
LinkProperties links=cm.getLinkProperties(n);if(links==null)continue;
// Match the actual current Wi-Fi IPv4, even when Android redacts WifiInfo in capabilities.
for(LinkAddress link:links.getLinkAddresses()) {
byte[] ip=link.getAddress().getAddress();
if(ip.length==4&&(ip[0]&255)==(wifiAddress&255)&&(ip[1]&255)==(wifiAddress>>>8&255)&&(ip[2]&255)==(wifiAddress>>>16&255)&&(ip[3]&255)==(wifiAddress>>>24&255))return n;
}
}
return null;
}
}
@@ -0,0 +1,35 @@
package ru.kusoft.qsafe;
import java.io.IOException;
import java.net.*;
/** Pure validation: never resolves DNS and never opens a socket. */
public final class LanPolicy {
private LanPolicy() {}
public static byte[] ipv4(String text) throws IOException {
if(text==null||!text.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}"))throw new IOException("Нужен локальный IPv4 Pi, без доменного имени");
String[] parts=text.split("\\.");byte[] out=new byte[4];
for(int i=0;i<4;i++){int value=Integer.parseInt(parts[i]);if(value>255||(parts[i].length()>1&&parts[i].charAt(0)=='0'))throw new IOException("Неверный IPv4");out[i]=(byte)value;}
int a=out[0]&255,b=out[1]&255;
if(!(a==10||a==172&&b>=16&&b<=31||a==192&&b==168))throw new IOException("Внешние адреса базы запрещены. Нужен локальный IPv4 Pi");
return out;
}
public static String url(String address) throws IOException {
try {
URI uri=new URI(address==null?"":address.trim());
if(!"https".equalsIgnoreCase(uri.getScheme())||uri.getHost()==null||uri.getRawUserInfo()!=null||uri.getRawQuery()!=null||uri.getRawFragment()!=null)throw new IOException("Нужен локальный HTTPS-адрес Pi без логина и параметров");
ipv4(uri.getHost());String result=uri.toASCIIString();return result.endsWith("/")?result:result+"/";
}catch(URISyntaxException e){throw new IOException("Неверный адрес Pi",e);}
}
public static void identity(String ssid,String pin)throws IOException {
if(ssid==null||ssid.isEmpty()||ssid.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>32)throw new IOException("Укажите точное имя домашнего Wi-Fi");
if(pin==null||!pin.matches("[0-9a-fA-F]{64}"))throw new IOException("Нужен SHA-256 локального сертификата Pi из файла подключения");
}
public static boolean sameSubnet(byte[] target,byte[] local,int prefix) {
if(target.length!=4||local.length!=4||prefix<16||prefix>30||java.util.Arrays.equals(target,local))return false;
long value=0;for(byte b:target)value=value<<8|(b&255);long hostMask=(1L<<(32-prefix))-1;
if((value&hostMask)==0||(value&hostMask)==hostMask)return false;
for(int i=0;i<4;i++){int bits=Math.max(0,Math.min(8,prefix-i*8));int mask=255<<(8-bits)&255;if((target[i]&mask)!=(local[i]&mask))return false;}
return true;
}
}
@@ -0,0 +1,814 @@
package ru.kusoft.qsafe;
import android.app.*;
import android.content.*;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.RippleDrawable;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.*;
import android.text.*;
import android.text.method.PasswordTransformationMethod;
import android.view.*;
import android.view.inputmethod.InputMethodManager;
import android.widget.*;
import org.json.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
import java.util.function.BiConsumer;
public class MainActivity extends Activity {
protected int INK,MUTED,TEAL,BG,SURFACE,SOFT,LINE,ON_ACCENT,DANGER;
private boolean darkTheme;
private Runnable appearanceRefresh;
private boolean enableWifiOnSave=false;
private String wifiDraft;
private static final int IMPORT=41,CONNECTION=42,EXPORT=43,RESTORE=44;
private final Handler handler=new Handler(Looper.getMainLooper());
private final ExecutorService io=SyncRuntime.IO;
private Repository repository;
private Biometrics biometrics;
private Session session;
private LinearLayout root,content,list;
private FrameLayout frame;
private TextView status,counter;
private EditText search;
private ScrollView pageScroll;
private boolean busy=false,stars=false;
private int generation=0;
private Dialog dialog;
private Uri pendingImport;
private byte[] pendingExport;
private String notice="";
private boolean returnHomeSettings=false;
private boolean refreshMenuOnResume=false;
private boolean resumed=false,automaticFingerprintRequested=false,fingerprintRunning=false,resumeUpdateInstall=false;
private UpdateManifest downloadedUpdate;
private String screen="locked";
private JSONObject currentDetail;
private Runnable navigateBack;
private final Runnable idleLock=()->lock("Хранилище заблокировано");
private final Runnable scheduledSync=()->sync(false);
private static final class Session implements AutoCloseable {
final VaultCrypto.Key key;JSONObject data;String openingNotice="";
Session(VaultCrypto.Key k,JSONObject d) {key=k;data=d;}
public void close() {key.close();data=null;}
}
private interface Work<T> {T run() throws Exception;}
@Override public void onCreate(Bundle saved) {
AppAppearance.applyStyle(this);
super.onCreate(saved);
applyAppearance();
SecureWindows.protect(this);
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
biometrics=new Biometrics(this);
try {repository=Repository.get(this);showLocked();}
catch(Exception e) {page();heading("QSafe",28);paragraph("Не удалось открыть настройки. Данные сохранены. Не очищайте данные приложения; восстановите доступ из резервной копии.");}
}
@Override protected void onSaveInstanceState(Bundle out) {super.onSaveInstanceState(out);out.clear();}
@Override protected void onStop() {resumed=false;super.onStop();if(repository!=null) lock("");}
@Override protected void onResume() {
super.onResume();
resumed=true;automaticFingerprintRequested=false;
if(refreshMenuOnResume){
refreshMenuOnResume=false;if(session!=null&&!busy)settings();
int resumedGeneration=generation;
for(long delay:new long[]{100,500,1500})handler.postDelayed(()->{
if(generation!=resumedGeneration||session==null||busy||!screen.equals("menu"))return;
Switch control=findViewById(R.id.autofill_enabled);android.view.autofill.AutofillManager manager=getSystemService(android.view.autofill.AutofillManager.class);
if(control!=null&&manager!=null&&control.isChecked()!=manager.hasEnabledAutofillServices())settings();
},delay);
}
if(returnHomeSettings&&repository!=null){returnHomeSettings=false;handler.post(this::homeWifiSettings);SyncRuntime.IO.execute(()->{try{HomeWifiSync.install(this,repository);}catch(Exception ignored){}});}
else if(resumeUpdateInstall){resumeUpdateInstall=false;if(getPackageManager().canRequestPackageInstalls())handler.post(this::installDownloadedUpdate);else error("Установка обновлений не разрешена. Разрешение можно включить при следующей попытке.");}
else scheduleFingerprint();
}
@Override protected void onDestroy() {if(biometrics!=null) biometrics.cancel();super.onDestroy();}
@Override public void onConfigurationChanged(Configuration configuration) {
super.onConfigurationChanged(configuration);
refreshAppearanceIfNeeded();
}
private void refreshAppearanceIfNeeded() {
if(busy)return;
if(darkTheme!=AppAppearance.dark(this)) {
Runnable redraw=appearanceRefresh;applyAppearance();dismiss();
if(isAutofill()&&session!=null)showAutofill();else if(redraw!=null)redraw.run();else if(session==null)showLocked();else showHome();
}
}
private void applyAppearance() {
AppAppearance.applyStyle(this);darkTheme=AppAppearance.dark(this);
INK=Color.parseColor(darkTheme?"#E5ECEA":"#19302D");MUTED=Color.parseColor(darkTheme?"#A0B3AD":"#647874");
TEAL=Color.parseColor(darkTheme?"#78D5BF":"#087F70");BG=Color.parseColor(darkTheme?"#101817":"#F5F8F7");
SURFACE=Color.parseColor(darkTheme?"#1B2724":"#FFFFFF");SOFT=Color.parseColor(darkTheme?"#263D35":"#E1EFEA");
LINE=Color.parseColor(darkTheme?"#34443F":"#E6EDE9");ON_ACCENT=Color.parseColor(darkTheme?"#10291F":"#FFFFFF");
DANGER=Color.parseColor(darkTheme?"#FFADAD":"#B14040");AppAppearance.applyWindow(this);
}
@Override public void onUserInteraction() {super.onUserInteraction();resetTimer();}
private void resetTimer() {handler.removeCallbacks(idleLock);if(session!=null) handler.postDelayed(idleLock,120_000);}
private void lock(String message) {
generation++;busy=false;handler.removeCallbacks(idleLock);handler.removeCallbacks(scheduledSync);
fingerprintRunning=false;
if(biometrics!=null) biometrics.cancel();dismiss();
Session old=session;session=null;
list=null;search=null;counter=null;
navigateBack=null;appearanceRefresh=null;currentDetail=null;screen="locked";
if(old!=null&&!io.isShutdown()) io.execute(old::close);
if(repository!=null&&!isFinishing()&&!isDestroyed()) {notice=message;showLocked();}
}
private void dismiss() {if(dialog!=null) {dialog.dismiss();dialog=null;}}
private int dp(float n) {return Math.round(n*getResources().getDisplayMetrics().density);}
private GradientDrawable shape(int color,int radius) {GradientDrawable d=new GradientDrawable();d.setColor(color);d.setCornerRadius(dp(radius));return d;}
private LinearLayout column() {LinearLayout l=new LinearLayout(this);l.setOrientation(LinearLayout.VERTICAL);return l;}
private LinearLayout row() {LinearLayout l=new LinearLayout(this);l.setOrientation(LinearLayout.HORIZONTAL);l.setGravity(Gravity.CENTER_VERTICAL);return l;}
private LinearLayout.LayoutParams wrap() {return new LinearLayout.LayoutParams(-1,-2);}
private void gap(LinearLayout target,int height) {View v=new View(this);target.addView(v,new LinearLayout.LayoutParams(1,dp(height)));}
private TextView label(String text,int size,int color) {TextView t=new TextView(this);t.setText(text);t.setTextSize(size);t.setTextColor(color);t.setPadding(0,dp(4),0,dp(4));return t;}
private void shell() {
applyAppearance();
frame=new FrameLayout(this);frame.setBackgroundColor(BG);frame.setSaveFromParentEnabled(false);
root=column();root.setPadding(dp(20),0,dp(20),0);root.setSaveFromParentEnabled(false);
root.setOnApplyWindowInsetsListener((v,insets)->{
v.setPadding(dp(20),insets.getSystemWindowInsetTop()+dp(10),dp(20),Math.max(dp(12),insets.getSystemWindowInsetBottom()));return insets;
});
frame.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(frame);root.requestApplyInsets();
}
private void page() {
shell();
ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);content=column();content.setPadding(0,dp(8),0,dp(20));
scroll.addView(content);root.addView(scroll,new LinearLayout.LayoutParams(-1,-1));
}
private void heading(String text,int size) {TextView t=label(text,size,INK);t.setTypeface(null,Typeface.BOLD);content.addView(t);}
private void paragraph(String text) {content.addView(label(text,15,MUTED));}
private EditText input(LinearLayout parent,String hint,String value,boolean secret,int id) {
EditText e=new EditText(this);e.setHint(hint);e.setText(value);e.setTextSize(16);e.setTextColor(INK);e.setHintTextColor(MUTED);
e.setSingleLine(true);e.setSaveEnabled(false);e.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS);
e.setInputType(secret?android.text.InputType.TYPE_CLASS_TEXT|android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD:
android.text.InputType.TYPE_CLASS_TEXT|android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
if(secret) e.setTransformationMethod(PasswordTransformationMethod.getInstance());
e.setPadding(dp(14),dp(13),dp(14),dp(13));e.setBackground(shape(SURFACE,12));if(id!=0)e.setId(id);
LinearLayout.LayoutParams p=wrap();p.topMargin=dp(8);p.bottomMargin=dp(8);parent.addView(e,p);return e;
}
private Button button(LinearLayout parent,String title,boolean primary,Runnable action) {
Button b=new Button(this);b.setText(title);b.setAllCaps(false);b.setTextSize(15);b.setMinHeight(dp(48));
b.setTextColor(primary?ON_ACCENT:TEAL);b.setBackground(shape(primary?TEAL:SOFT,12));
b.setPadding(dp(12),dp(7),dp(12),dp(7));b.setOnClickListener(v->{resetTimer();action.run();});
LinearLayout.LayoutParams p=wrap();p.topMargin=dp(7);p.bottomMargin=dp(3);parent.addView(b,p);return b;
}
private void showStatus(String message) {if(status!=null){status.setText(message);status.setVisibility(message.isEmpty()?View.GONE:View.VISIBLE);}}
private void error(String message) {notice=message;showStatus(message);Toast.makeText(this,message,Toast.LENGTH_LONG).show();}
private String message(Exception e) {
if(e instanceof javax.crypto.AEADBadTagException) return "Неверный мастер-пароль или повреждённая база";
if(e instanceof GeneralSecurityException) return "Не удалось расшифровать базу. Проверьте мастер-пароль.";
if(e instanceof java.net.SocketTimeoutException || e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
return "Pi недоступен. Локальная база сохранена; синхронизацию можно повторить позже.";
String m=e.getMessage();return m==null?"Не удалось выполнить действие":m;
}
private <T> void task(String title,Work<T> work,Consumer<T> success) {
task(title,work,success,null);
}
private <T> void task(String title,Work<T> work,Consumer<T> success,Runnable failure) {
if(busy||io.isShutdown()) return;busy=true;int started=generation;showStatus(title);
io.execute(()->{
try {
T result=work.run();handler.post(()->{
if(started!=generation||isFinishing()||isDestroyed()) {if(result instanceof AutoCloseable) try {((AutoCloseable)result).close();}catch(Exception ignored){}return;}
busy=false;success.accept(result);refreshAppearanceIfNeeded();
});
} catch(Exception e) {handler.post(()->{if(started==generation&&!isFinishing()&&!isDestroyed()) {busy=false;if(failure!=null)failure.run();error(message(e));refreshAppearanceIfNeeded();}});}
});
}
private void showLocked() {
appearanceRefresh=this::showLocked;
page();gap(content,64);
ImageView mark=new ImageView(this);mark.setImageDrawable(new GlyphDrawable("shield",TEAL,dp(48)));mark.setPadding(dp(18),dp(18),dp(18),dp(18));mark.setBackground(shape(SOFT,25));
LinearLayout.LayoutParams logo=new LinearLayout.LayoutParams(dp(84),dp(84));logo.bottomMargin=dp(18);content.addView(mark,logo);
heading("QSafe",36);paragraph("Личное пространство для ваших паролей");gap(content,32);
boolean exists=repository.exists();heading(exists?"С возвращением":"Ваше хранилище",24);
paragraph(exists?(biometrics.enabled()?"Коснитесь датчика отпечатка или введите мастер-пароль.":"Введите мастер-пароль, чтобы открыть карточки."):"Подключите Pi и введите мастер-пароль вашей базы.");
EditText password=input(content,"Мастер-пароль","",true,R.id.master_password);
password.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_DONE);
password.setOnEditorActionListener((v,action,event)->{if(action==android.view.inputmethod.EditorInfo.IME_ACTION_DONE){unlockPassword(password);return true;}return false;});
button(content,"Разблокировать",true,()->unlockPassword(password)).setId(R.id.open_vault);
if(!exists&&!isAutofill())button(content,repository.connected()?"Синхронизировать карточки с Pi":"Подключить Pi и синхронизировать",false,()->{if(repository.connected())unlockPassword(password);else firstSetup();}).setId(R.id.connect_pi);
gap(content,16);status=label(notice,13,MUTED);status.setId(R.id.status);content.addView(status);
paragraph("Мастер-пароль остаётся только у вас.");
}
private void unlockPassword(EditText password) {
if(busy||fingerprintRunning)return;
boolean exists=repository.exists();
if(exists&&password.length()==0&&biometrics.enabled()){unlockFingerprint();return;}
if(!exists&&!repository.connected()){firstSetup();return;}
if(password.length()==0){password.setError("Введите мастер-пароль");return;}
char[] pass=password.getText().toString().toCharArray();password.setText("");hideKeyboard(password);
task(exists?"Открываю хранилище…":"Восстанавливаю карточки с Pi…",()->{
VaultCrypto.Key key=null;
try {key=exists?VaultCrypto.unlock(repository.encrypted(),pass):repository.restorePi(pass);return loadSession(key,repository.read(key));}
catch(Exception e){if(key!=null)key.close();throw e;}finally{Arrays.fill(pass,'\0');}
},this::opened);
}
private void firstSetup() {
if(busy)return;LinearLayout body=column();
body.addView(label("Выберите личный файл connection.qsafe. Затем введите мастер-пароль существующей базы — карточки будут восстановлены с Pi.",16,INK));
button(body,"Выбрать файл подключения Pi",true,()->pick(CONNECTION,"*/*"));
gap(body,16);body.addView(label("Если базы ещё нет",13,MUTED));
button(body,"Создать новое хранилище",false,this::newVaultDialog);
button(body,"Восстановить из файла .qsafe",false,()->pick(RESTORE,"*/*"));
showDialog("Первое подключение",body,null,null);
}
private void newVaultDialog() {
LinearLayout body=column();body.addView(label("Придумайте мастер-пароль от 12 символов. Он понадобится для восстановления базы на другом устройстве.",15,INK));
EditText password=input(body,"Мастер-пароль","",true,R.id.master_password),confirm=input(body,"Повторите мастер-пароль","",true,R.id.confirm_password);
showDialog("Новое хранилище",body,"Создать",()->{
if(busy)return;char[] pass=password.getText().toString().toCharArray();
if(pass.length<12||!Arrays.equals(pass,confirm.getText().toString().toCharArray())){Arrays.fill(pass,'\0');password.setError("От 12 символов; оба пароля должны совпадать");return;}
password.setText("");confirm.setText("");
task("Создаю хранилище…",()->{
VaultCrypto.Key key=null;try{key=VaultCrypto.create(pass);JSONObject data=Vault.empty();repository.save(data,key);return new Session(key,data);}
catch(Exception e){if(key!=null)key.close();throw e;}finally{Arrays.fill(pass,'\0');}
},result->{dismiss();opened(result);});
});
}
private void scheduleFingerprint() {
int started=generation;handler.postDelayed(()->{
if(resumed&&started==generation&&session==null&&repository!=null&&repository.exists()&&biometrics.enabled()&&!automaticFingerprintRequested&&dialog==null&&!busy){automaticFingerprintRequested=true;unlockFingerprint();}
},300);
}
private void unlockFingerprint() {
if(busy||fingerprintRunning||!resumed)return;int started=generation;fingerprintRunning=true;
biometrics.unlock(new Biometrics.Result() {
public void done(VaultCrypto.Key key) {
fingerprintRunning=false;if(started!=generation||!resumed){key.close();return;}
task("Открываю хранилище…",()->{try{return loadSession(key,repository.read(key));}catch(Exception e){key.close();throw e;}},MainActivity.this::opened);
}
public void error(String m){fingerprintRunning=false;if(started==generation&&resumed)MainActivity.this.error(m);}
});
}
private void hideKeyboard(View v) {((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(v.getWindowToken(),0);}
private void opened(Session result) {
session=result;notice=result.openingNotice;showHome();resetTimer();
if(pendingImport!=null) {Uri uri=pendingImport;pendingImport=null;importXml(uri);}
else if(!isAutofill()) {
if(repository.connected()&&repository.autoSync())handler.postDelayed(scheduledSync,400);
if(!biometrics.enabled()&&!repository.fingerprintOffered()&&biometrics.available())handler.postDelayed(this::offerFingerprint,700);
}
}
private Session loadSession(VaultCrypto.Key key,JSONObject fallback) throws Exception {
Session result=new Session(key,fallback);
try {
Repository.SyncResult applied=repository.applyPending(key);result.data=applied.data;
if(applied.conflicts>0)result.openingNotice="Сохранены конфликтующие версии с Pi: "+applied.conflicts;
}catch(Exception e){result.data=repository.read(key);result.openingNotice="Копия с Pi не применена: "+message(e);}
return result;
}
private void showHome() {
appearanceRefresh=this::showHome;
if(session==null) {showLocked();return;}
if(isAutofill()) {showAutofill();return;}
screen="home";navigateBack=null;currentDetail=null;
String query=search==null?"":search.getText().toString();
shell();content=root;LinearLayout header=row();TextView name=label("QSafe",27,INK);name.setTypeface(null,Typeface.BOLD);
header.addView(name,new LinearLayout.LayoutParams(0,-2,1));
ImageButton menu=new ImageButton(this);menu.setImageDrawable(new GlyphDrawable("menu",INK,dp(24)));menu.setBackground(shape(SURFACE,14));menu.setContentDescription("Настройки");menu.setId(R.id.settings);
menu.setOnClickListener(v->{resetTimer();settings();});menu.setContentDescription("Меню");header.addView(menu,new LinearLayout.LayoutParams(dp(48),dp(48)));root.addView(header);
gap(root,12);search=input(root,"Найти карточку",query,false,R.id.search);
search.setCompoundDrawablesWithIntrinsicBounds(new GlyphDrawable("search",MUTED,dp(20)),null,null,null);search.setCompoundDrawablePadding(dp(12));
LinearLayout filters=row();TextView all=chip(stars?"Все":"Все карточки",!stars),favorite=chip("Избранное",stars);
all.setOnClickListener(v->{stars=false;showHome();});favorite.setOnClickListener(v->{stars=true;showHome();});
filters.addView(all);LinearLayout.LayoutParams fp=new LinearLayout.LayoutParams(-2,dp(36));fp.leftMargin=dp(8);filters.addView(favorite,fp);
counter=label("",12,MUTED);counter.setId(R.id.card_counter);counter.setGravity(Gravity.END);filters.addView(counter,new LinearLayout.LayoutParams(0,-2,1));root.addView(filters);
status=label(notice.isEmpty()?syncCaption():notice,11,MUTED);status.setId(R.id.status);status.setSingleLine(true);status.setEllipsize(TextUtils.TruncateAt.END);root.addView(status);
ScrollView scroll=new ScrollView(this);scroll.setId(R.id.card_scroller);scroll.setClipToPadding(false);scroll.setVerticalScrollBarEnabled(false);
list=column();list.setId(R.id.entry_list);list.setPadding(0,dp(10),0,dp(96));scroll.addView(list);root.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));
ImageButton add=new ImageButton(this);add.setId(R.id.add_entry);add.setContentDescription("Добавить карточку");add.setImageDrawable(new GlyphDrawable("plus",ON_ACCENT,dp(28)));add.setBackground(new RippleDrawable(ColorStateList.valueOf(0x33FFFFFF),shape(TEAL,22),null));add.setElevation(dp(8));
add.setOnClickListener(v->{resetTimer();newCardMenu();});FrameLayout.LayoutParams floating=new FrameLayout.LayoutParams(dp(62),dp(62),Gravity.BOTTOM|Gravity.END);floating.rightMargin=dp(22);floating.bottomMargin=dp(28);frame.addView(add,floating);
add.setOnApplyWindowInsetsListener((v,insets)->{FrameLayout.LayoutParams p=(FrameLayout.LayoutParams)v.getLayoutParams();p.bottomMargin=insets.getSystemWindowInsetBottom()+dp(24);v.setLayoutParams(p);return insets;});add.requestApplyInsets();
search.addTextChangedListener(new TextWatcher() {public void beforeTextChanged(CharSequence s,int a,int c,int f){}public void onTextChanged(CharSequence s,int a,int b,int c){renderList();}public void afterTextChanged(Editable e){}});
renderList();
}
private TextView chip(String title,boolean selected) {
TextView view=label(title,13,selected?TEAL:MUTED);view.setTypeface(null,selected?Typeface.BOLD:Typeface.NORMAL);view.setGravity(Gravity.CENTER);view.setPadding(dp(14),0,dp(14),0);view.setBackground(shape(selected?SOFT:SURFACE,12));view.setMinHeight(dp(36));view.setClickable(true);return view;
}
private String syncCaption() {
if(!repository.connected())return "● На телефоне · подключите Pi в настройках";
return repository.lastSync().isEmpty()?"● Pi подключён · ещё не синхронизировано":"● Pi · последняя синхронизация "+repository.lastSync();
}
private void renderList() {
if(session==null||list==null)return;list.removeAllViews();
try {
List<JSONObject> entries=Vault.visible(session.data,search.getText().toString(),stars);counter.setText("Карточек: "+entries.size());
if(entries.isEmpty()) {
LinearLayout empty=column();empty.setPadding(dp(18),dp(22),dp(18),dp(22));empty.setBackground(shape(SURFACE,16));
empty.addView(label(search.length()>0?"Ничего не найдено":"Здесь будут ваши пароли",19,INK));
empty.addView(label("Добавьте карточку или перенесите данные из SafeInCloud.",15,MUTED));
if(search.length()==0) button(empty,"Импорт из SafeInCloud",false,this::importHelp);list.addView(empty);return;
}
for(JSONObject entry:entries) {
LinearLayout card=row();GradientDrawable surface=shape(SURFACE,18);surface.setStroke(dp(1),LINE);card.setBackground(new RippleDrawable(ColorStateList.valueOf(0x12087F70),surface,null));card.setPadding(dp(13),dp(14),dp(13),dp(14));
String kind=CardTemplates.kind(entry);int[] colors=darkTheme?new int[]{0xFF78D5BF,0xFFABC2FF,0xFFE7B28E,0xFFD2B8FF,0xFF88D3EA}:new int[]{0xFF087F70,0xFF4762B0,0xFF965C30,0xFF7653A1,0xFF277C9C};int tint=colors[Math.floorMod(entry.optString("id").hashCode(),colors.length)];
ImageView picture=new ImageView(this);picture.setImageDrawable(new GlyphDrawable(kind,tint,dp(25)));picture.setPadding(dp(12),dp(12),dp(12),dp(12));picture.setBackground(shape((tint&0x00ffffff)|0x15000000,14));card.addView(picture,new LinearLayout.LayoutParams(dp(48),dp(48)));
LinearLayout text=column();LinearLayout.LayoutParams tp=new LinearLayout.LayoutParams(0,-2,1);tp.leftMargin=dp(14);tp.rightMargin=dp(6);card.addView(text,tp);
TextView title=label(entry.optString("title"),16,INK);title.setTypeface(null,Typeface.BOLD);title.setSingleLine(true);title.setEllipsize(TextUtils.TruncateAt.END);text.addView(title);
TextView subtitle=label(CardTemplates.subtitle(entry),13,MUTED);subtitle.setSingleLine(true);subtitle.setEllipsize(TextUtils.TruncateAt.END);text.addView(subtitle);
if(entry.optBoolean("star")){TextView star=label("★",17,Color.rgb(184,133,37));star.setPadding(0,0,dp(10),0);card.addView(star);}
ImageView chevron=new ImageView(this);chevron.setImageDrawable(new GlyphDrawable("chevron",MUTED,dp(16)));card.addView(chevron,new LinearLayout.LayoutParams(dp(16),dp(20)));card.setOnClickListener(v->{resetTimer();detail(entry);});
card.setContentDescription(entry.optString("title")+", открыть карточку");
LinearLayout.LayoutParams p=wrap();p.bottomMargin=dp(9);list.addView(card,p);
}
} catch(Exception e) {error("Не удалось прочитать список карточек");}
}
private void newCardMenu() {
if(session==null||busy)return;LinearLayout body=column();body.addView(label("Что хотите сохранить?",15,MUTED));
GridLayout grid=new GridLayout(this);grid.setColumnCount(2);grid.setId(R.id.template_grid);body.addView(grid);
for(int i=0;i<CardTemplates.KINDS.length;i++) {
String kind=CardTemplates.KINDS[i];LinearLayout option=column();option.setGravity(Gravity.CENTER);option.setPadding(dp(10),dp(16),dp(10),dp(16));option.setBackground(new RippleDrawable(ColorStateList.valueOf(0x20087F70),shape(BG,16),null));
ImageView icon=new ImageView(this);icon.setImageDrawable(new GlyphDrawable(kind,TEAL,dp(28)));option.addView(icon,new LinearLayout.LayoutParams(dp(28),dp(28)));
TextView text=label(CardTemplates.TITLES[i],13,INK);text.setGravity(Gravity.CENTER);text.setMaxLines(2);option.addView(text);option.setContentDescription(CardTemplates.TITLES[i]);
option.setOnClickListener(v->{resetTimer();try{editDraft(CardTemplates.create(kind),true);}catch(Exception e){error(message(e));}});
GridLayout.LayoutParams p=new GridLayout.LayoutParams(GridLayout.spec(i/2),GridLayout.spec(i%2,1f));p.width=0;p.height=dp(106);p.setMargins(dp(4),dp(5),dp(4),dp(5));grid.addView(option,p);
}
showDialog("Новая карточка",body,null,null);
Window window=dialog.getWindow();window.setGravity(Gravity.BOTTOM);window.setLayout(-1,-2);window.setBackgroundDrawable(shape(SURFACE,28));
}
private void showDialog(String title,LinearLayout body,String positive,Runnable action) {
dismiss();ScrollView scroll=new ScrollView(this);scroll.setPadding(dp(20),dp(4),dp(20),dp(8));scroll.addView(body);
AlertDialog.Builder builder=new AlertDialog.Builder(this).setTitle(title).setView(scroll).setNegativeButton("Закрыть",null);
if(positive!=null)builder.setPositiveButton(positive,null);
AlertDialog d=builder.create();dialog=d;SecureWindows.show(d);
if(positive!=null)d.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v->action.run());
}
protected void showProtectedDialog(AlertDialog confirmation) {dismiss();dialog=confirmation;SecureWindows.show(confirmation);}
@Override public void onBackPressed() {
if(navigateBack!=null){resetTimer();navigateBack.run();}else super.onBackPressed();
}
private void documentPage(String title,Runnable back,String action,Runnable run) {
dismiss();shell();appearanceRefresh=null;navigateBack=back;LinearLayout bar=row();
ImageButton arrow=new ImageButton(this);arrow.setId(R.id.navigate_back);arrow.setImageDrawable(new GlyphDrawable("back",INK,dp(24)));arrow.setBackground(shape(SURFACE,14));arrow.setContentDescription("Назад");arrow.setOnClickListener(v->{resetTimer();back.run();});bar.addView(arrow,new LinearLayout.LayoutParams(dp(46),dp(46)));
TextView caption=label(title,17,INK);caption.setId(R.id.page_title);caption.setTypeface(null,Typeface.BOLD);caption.setPadding(dp(14),0,0,0);bar.addView(caption,new LinearLayout.LayoutParams(0,-2,1));
if(action!=null){Button button=button(bar,action,false,run);button.setLayoutParams(new LinearLayout.LayoutParams(-2,dp(46)));}
root.addView(bar);status=label("",12,MUTED);status.setId(R.id.status);status.setVisibility(View.GONE);root.addView(status);pageScroll=new ScrollView(this);pageScroll.setVerticalScrollBarEnabled(false);content=column();content.setPadding(0,dp(18),0,dp(30));content.setFocusableInTouchMode(true);pageScroll.addView(content);root.addView(pageScroll,new LinearLayout.LayoutParams(-1,0,1));
}
private void detail(JSONObject entry) {
if(session==null||busy)return;resetTimer();screen="detail";currentDetail=entry;
try {
documentPage("Карточка",this::showHome,"Изменить",()->edit(entry));
appearanceRefresh=()->detail(entry);
LinearLayout hero=row();ImageView icon=new ImageView(this);icon.setImageDrawable(new GlyphDrawable(CardTemplates.kind(entry),TEAL,dp(32)));icon.setPadding(dp(16),dp(16),dp(16),dp(16));icon.setBackground(shape(SOFT,20));hero.addView(icon,new LinearLayout.LayoutParams(dp(64),dp(64)));
LinearLayout summary=column();LinearLayout.LayoutParams sp=new LinearLayout.LayoutParams(0,-2,1);sp.leftMargin=dp(16);hero.addView(summary,sp);
TextView title=label(entry.optString("title"),24,INK);title.setTypeface(null,Typeface.BOLD);summary.addView(title);summary.addView(label(CardTemplates.title(CardTemplates.kind(entry)),13,MUTED));content.addView(hero);
gap(content,16);paragraph("Нажмите на поле, чтобы скопировать");gap(content,8);
JSONArray fields=entry.getJSONArray("fields");
for(int i=0;i<fields.length();i++) {
JSONObject field=fields.getJSONObject(i);String value=field.optString("value"),name=field.optString("name");boolean secret=Vault.secret(field);
LinearLayout block=row();block.setPadding(dp(17),dp(11),dp(8),dp(11));block.setBackground(new RippleDrawable(ColorStateList.valueOf(0x18087F70),shape(SURFACE,16),null));
LinearLayout labels=column();labels.addView(label(name,12,MUTED));TextView text=label(value.isEmpty()?"Не заполнено":secret?"•••• •••• ••••":value,18,value.isEmpty()?MUTED:INK);text.setTextIsSelectable(false);labels.addView(text);block.addView(labels,new LinearLayout.LayoutParams(0,-2,1));
if(secret&&!value.isEmpty()) {
ImageButton reveal=new ImageButton(this);reveal.setImageDrawable(new GlyphDrawable("eye",MUTED,dp(22)));reveal.setContentDescription("Показать "+name);reveal.setBackground(shape(SURFACE,12));
final boolean[] visible={false};reveal.setOnClickListener(v->{resetTimer();visible[0]=!visible[0];text.setText(visible[0]?value:"•••• •••• ••••");reveal.setContentDescription((visible[0]?"Скрыть ":"Показать ")+name);});block.addView(reveal,new LinearLayout.LayoutParams(dp(46),dp(46)));
}
block.setContentDescription("Скопировать "+name);block.setOnClickListener(v->{resetTimer();if(!value.isEmpty()){v.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);copy(value);}});
LinearLayout.LayoutParams bp=wrap();bp.bottomMargin=dp(10);content.addView(block,bp);
}
if(!entry.optString("notes").isEmpty()) {
gap(content,10);LinearLayout notes=column();notes.setPadding(dp(17),dp(13),dp(17),dp(13));notes.setBackground(shape(SURFACE,16));notes.addView(label("Заметки",12,MUTED));notes.addView(label(entry.optString("notes"),16,INK));content.addView(notes);
}
JSONArray tags=entry.optJSONArray("tags");if(tags!=null&&tags.length()>0){gap(content,12);content.addView(label(join(tags),13,TEAL));}
gap(content,20);button(content,entry.optBoolean("star")?"★ В избранном":"☆ Добавить в избранное",false,()->{
try{mutate(Vault.copy(entry).put("star",!entry.optBoolean("star")));}catch(Exception e){error(message(e));}
});
TextView remove=label("Удалить карточку",14,DANGER);remove.setGravity(Gravity.CENTER);remove.setPadding(dp(12),dp(18),dp(12),dp(18));remove.setOnClickListener(v->{resetTimer();
dialog=new AlertDialog.Builder(this).setTitle("Удалить карточку?").setMessage("Удаление будет синхронизировано с Pi. Предыдущие зашифрованные версии сохраняются в резервных копиях.")
.setNegativeButton("Отмена",null).setPositiveButton("Удалить",(d,w)->{try{mutate(Vault.copy(entry).put("deleted",true));}catch(Exception e){error(message(e));}}).create();SecureWindows.show((AlertDialog)dialog);
});content.addView(remove);
}catch(Exception e){error(message(e));}
}
private void edit(JSONObject original) {
try{editDraft(original==null?CardTemplates.create("login"):Vault.copy(original),original==null);}catch(Exception e){error(message(e));}
}
private void editDraft(JSONObject draft,boolean isNew) {
editDraft(draft,isNew,null);
}
private void editDraft(JSONObject draft,boolean isNew,JSONObject baseline) {
if(session==null||busy)return;
try {
JSONObject original=baseline==null?Vault.copy(draft):baseline;screen="edit";
Runnable[] save={null},back={null};documentPage(isNew?"Новая карточка":"Редактирование",()->back[0].run(),"Сохранить",()->save[0].run());
JSONArray fields=draft.getJSONArray("fields");
LinearLayout body=content;body.addView(label(CardTemplates.title(CardTemplates.kind(draft)),13,TEAL));
body.addView(label("Название",13,MUTED));EditText title=input(body,"Например, личная почта",draft.optString("title"),false,R.id.entry_title);title.setTextSize(22);title.setTypeface(null,Typeface.BOLD);
gap(body,12);
List<EditText> names=new ArrayList<>(),values=new ArrayList<>();List<String> types=new ArrayList<>();
LinearLayout fieldArea=column();body.addView(fieldArea);
for(int i=0;i<fields.length();i++)addField(fieldArea,fields.getJSONObject(i),names,values,types);
button(body,"+ Дополнительное секретное поле",false,()->{
try {addField(fieldArea,Vault.field("","password",""),names,values,types);}catch(Exception e){error(message(e));}
});
gap(body,16);body.addView(label("Метки",13,MUTED));EditText tags=input(body,"Например, личное, работа",join(draft.optJSONArray("tags")),false,R.id.entry_tags);
body.addView(label("Заметки",13,MUTED));EditText notes=input(body,"Заметки",draft.optString("notes"),false,R.id.entry_notes);
notes.setSingleLine(false);notes.setMinLines(3);notes.setInputType(android.text.InputType.TYPE_CLASS_TEXT|android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE|android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
Work<JSONObject> capture=()->{
JSONArray saved=new JSONArray();for(int i=0;i<values.size();i++)saved.put(Vault.field(names.get(i).getText().toString(),types.get(i),values.get(i).getText().toString()));
JSONArray tagList=new JSONArray();for(String tag:tags.getText().toString().split(","))if(!tag.trim().isEmpty())tagList.put(tag.trim());
return draft.put("title",title.getText().toString()).put("fields",saved).put("notes",notes.getText().toString()).put("tags",tagList);
};
appearanceRefresh=()->{try{capture.run();editDraft(draft,isNew,original);}catch(Exception e){error(message(e));}};
save[0]=()->{
if(busy)return;
if(title.getText().toString().trim().isEmpty()) {title.setError("Укажите название");return;}
try {
capture.run();draft.put("title",title.getText().toString().trim());
mutate(draft);
}catch(Exception e){error(message(e));}
};
back[0]=()->{
JSONArray originalFields=original.optJSONArray("fields");
if(busy)return;boolean dirty=!title.getText().toString().equals(original.optString("title"))||!notes.getText().toString().equals(original.optString("notes"))||!tags.getText().toString().equals(join(original.optJSONArray("tags")))||names.size()!=originalFields.length();
for(int i=0;i<Math.min(names.size(),originalFields.length());i++) {JSONObject f=originalFields.optJSONObject(i);if(f==null||!names.get(i).getText().toString().equals(f.optString("name"))||!values.get(i).getText().toString().equals(f.optString("value")))dirty=true;}
Runnable leave=()->{hideKeyboard(title);if(isNew)showHome();else detail(original);};
if(!dirty){leave.run();return;}
LinearLayout question=column();question.addView(label("Несохранённые изменения карточки будут потеряны.",16,INK));showDialog("Выйти без сохранения?",question,"Не сохранять",leave);
};
body.requestFocus();
}catch(Exception e){error(message(e));}
}
private void addField(LinearLayout area,JSONObject f,List<EditText> names,List<EditText> values,List<String> types) {
int index=names.size();LinearLayout block=column();block.setBackground(shape(SURFACE,16));block.setPadding(dp(14),dp(5),dp(14),dp(7));
EditText name=input(block,"Название поля",f.optString("name"),false,3200+index);name.setTextSize(12);name.setTextColor(MUTED);name.setPadding(0,dp(6),0,0);name.setMinHeight(dp(32));name.setBackgroundColor(Color.TRANSPARENT);
EditText value=input(block,"Значение",f.optString("value"),Vault.secret(f),3100+index);value.setPadding(0,dp(4),0,dp(7));value.setTextSize(17);value.setBackgroundColor(Color.TRANSPARENT);
names.add(name);values.add(value);types.add(f.optString("type","password"));
if(f.optString("type").equals("password")) {
TextView generate=label("Сгенерировать пароль",13,TEAL);generate.setMinHeight(dp(48));generate.setGravity(Gravity.CENTER_VERTICAL);generate.setPadding(0,dp(5),0,dp(10));generate.setOnClickListener(v->{resetTimer();showPasswordGenerator(value);});block.addView(generate);
}
LinearLayout.LayoutParams bp=wrap();bp.bottomMargin=dp(12);area.addView(block,bp);
}
private void showPasswordGenerator(EditText target) {
if(session==null)return;
// Dismiss the editor keyboard so the preview and settings have the full dialog height.
((InputMethodManager)getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(target.getWindowToken(),0);
LinearLayout body=column();body.setSaveFromParentEnabled(false);
body.addView(label("Сгенерированный пароль",12,MUTED));
TextView output=label("",20,INK);output.setId(R.id.generator_preview);output.setTypeface(Typeface.MONOSPACE);
if(Build.VERSION.SDK_INT>=29)output.setBreakStrategy(android.graphics.text.LineBreaker.BREAK_STRATEGY_HIGH_QUALITY);
output.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE);
output.setPadding(dp(14),dp(14),dp(14),dp(14));output.setBackground(shape(BG,12));output.setSaveEnabled(false);
output.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS);output.setContentDescription("Сгенерированный пароль");body.addView(output);
gap(body,12);body.addView(label("Тип пароля",12,MUTED));
RadioGroup modes=new RadioGroup(this);modes.setOrientation(RadioGroup.VERTICAL);
String[] titles={"Сложный","Только цифры","Запоминающийся"};
int[] ids={R.id.generator_complex,R.id.generator_digits,R.id.generator_memorable};
for(int i=0;i<titles.length;i++) {
RadioButton choice=new RadioButton(this);choice.setId(ids[i]);choice.setText(titles[i]);choice.setTextColor(INK);choice.setMinHeight(dp(48));modes.addView(choice);
}
modes.check(ids[0]);body.addView(modes);
TextView lengthLabel=label("",14,INK);lengthLabel.setLabelFor(R.id.generator_length);body.addView(lengthLabel);
SeekBar length=new SeekBar(this);length.setId(R.id.generator_length);length.setMinimumHeight(dp(48));body.addView(length,wrap());
CheckBox symbols=new CheckBox(this);symbols.setId(R.id.generator_symbols);symbols.setText("Специальные символы (!@#…)");symbols.setTextColor(INK);symbols.setMinHeight(dp(48));symbols.setChecked(true);body.addView(symbols);
CheckBox digits=new CheckBox(this);digits.setId(R.id.generator_add_digits);digits.setText("Добавлять цифры (0–9)");digits.setTextColor(INK);digits.setMinHeight(dp(48));digits.setChecked(true);body.addView(digits);
TextView hint=label("",13,MUTED);body.addView(hint);
int[] selected={0},sizes={24,12,8};boolean[] updating={false};
Runnable generate=()->{
int mode=selected[0],size=sizes[mode];
output.setText(PasswordGenerator.generate(PasswordGenerator.Mode.values()[mode],size,symbols.isChecked(),digits.isChecked()));
String description=mode==2?"Слов: "+size+" · символов: "+output.length():"Длина: "+size+(mode==1?" цифр":" символов");
lengthLabel.setText(description);length.setContentDescription(description);
};
Runnable configure=()->{
updating[0]=true;int mode=selected[0];
length.setMin(0);length.setMax(128);
length.setMin(mode==0?16:mode==1?4:2);length.setMax(mode==2?12:64);length.setProgress(sizes[mode]);
symbols.setVisibility(mode!=1?View.VISIBLE:View.GONE);digits.setVisibility(mode==2?View.VISIBLE:View.GONE);
hint.setText(mode==0?"Заглавные и строчные латинские буквы, цифры. Похожие символы исключены.":mode==1?
"Цифры 0–9, в том числе в начале. Короткий код легче подобрать.":
"От 2 случайных английских слов. Цифры и знаки размещаются случайно перед словами, между ними или в конце. Слова остаются целыми.");
updating[0]=false;generate.run();
};
modes.setOnCheckedChangeListener((group,id)->{resetTimer();selected[0]=id==ids[0]?0:id==ids[1]?1:2;configure.run();});
length.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener(){
public void onProgressChanged(SeekBar bar,int value,boolean fromUser){if(!updating[0]){resetTimer();sizes[selected[0]]=value;generate.run();}}
public void onStartTrackingTouch(SeekBar bar){resetTimer();}
public void onStopTrackingTouch(SeekBar bar){ }
});
symbols.setOnCheckedChangeListener((view,checked)->{resetTimer();generate.run();});
digits.setOnCheckedChangeListener((view,checked)->{resetTimer();generate.run();});
button(body,"Другой вариант",false,generate).setId(R.id.generator_regenerate);configure.run();
showDialog("Генератор паролей",body,"Использовать пароль",()->{
resetTimer();if(session!=null&&target.isAttachedToWindow()){target.setText(output.getText());target.setSelection(target.length());}dismiss();
});
AlertDialog generator=(AlertDialog)dialog;generator.getButton(AlertDialog.BUTTON_NEGATIVE).setText("Отмена");
generator.setOnDismissListener(ignored->{output.setText("");if(dialog==generator)dialog=null;});
}
private String join(JSONArray a) {StringBuilder s=new StringBuilder();if(a!=null)for(int i=0;i<a.length();i++){if(i>0)s.append(", ");s.append(a.optString(i));}return s.toString();}
private void mutate(JSONObject entry) {
if(session==null||busy)return;Session current=session;
task("Сохраняю…",()->{JSONObject next=Vault.copy(current.data),saved=Vault.applyEdit(next,entry);repository.save(next,current.key);return new JSONObject[]{next,saved};},result->{
current.data=result[0];dismiss();notice="Сохранено на телефоне";if(result[1].optBoolean("deleted"))showHome();else detail(result[1]);autoSync();
});
}
private void autoSync() {handler.removeCallbacks(scheduledSync);if(repository.connected()&&repository.autoSync())handler.postDelayed(scheduledSync,1200);}
private void sync(boolean manual) {
if(session==null||busy)return;
if(!repository.connected()) {if(manual)settings();return;}
if(HomeWifiSync.homeNetwork(this,repository.homeSsid(),null,false)==null) {
String problem=HomeWifiSync.permissionProblem(this,false);
showStatus(problem.isEmpty()?"Ожидается домашний Wi-Fi «"+repository.homeSsid()+"»":problem);return;
}
handler.removeCallbacks(scheduledSync);Session current=session;
task("Синхронизация с Pi…",()->repository.sync(current.key),result->{
current.data=result.data;notice=result.conflicts>0?"Синхронизировано. Сохранены конфликтующие версии: "+result.conflicts:"Синхронизировано с Pi · "+repository.lastSync();
if(screen.equals("home"))showHome();else if(screen.equals("detail")&&currentDetail!=null){JSONObject updated=findEntry(currentDetail.optString("id"));if(updated==null||updated.optBoolean("deleted"))showHome();else detail(updated);}
});
}
private JSONObject findEntry(String id) {
if(session==null)return null;JSONArray entries=session.data.optJSONArray("entries");if(entries!=null)for(int i=0;i<entries.length();i++){JSONObject e=entries.optJSONObject(i);if(e!=null&&id.equals(e.optString("id")))return e;}return null;
}
private void copy(String value) {
android.content.ClipboardManager clipboard=(android.content.ClipboardManager)getSystemService(CLIPBOARD_SERVICE);String token="QSafe-"+UUID.randomUUID();
ClipData clip=ClipData.newPlainText(token,value);PersistableBundle extras=new PersistableBundle();extras.putBoolean("android.content.extra.IS_SENSITIVE",true);clip.getDescription().setExtras(extras);
clipboard.setPrimaryClip(clip);Toast.makeText(this,"Скопировано. Буфер очистится через 30 секунд.",Toast.LENGTH_SHORT).show();
handler.postDelayed(()->{try{ClipDescription d=clipboard.getPrimaryClipDescription();if(d!=null&&token.contentEquals(d.getLabel()))clipboard.clearPrimaryClip();}catch(RuntimeException ignored){}},30_000);
}
private void settings() {
if(session==null||busy)return;
int position=screen.equals("menu")&&pageScroll!=null?pageScroll.getScrollY():0;
documentPage("Меню",this::showHome,null,null);screen="menu";appearanceRefresh=this::settings;
content.setPadding(0,0,0,dp(30));
LinearLayout appearance=settingsSection("Внешний вид");
settingsItem(appearance,"Тема приложения",themeTitle(),R.id.theme_settings,this::themeSettings);
LinearLayout sync=settingsSection("Синхронизация");
settingsItem(sync,"Подключение Raspberry Pi",repository.connected()?repository.url():"Укажите сервер или выберите файл подключения",R.id.connection_settings,this::manualConnection);
settingsToggle(sync,"Автосинхронизация","После входа и сохранения карточек",repository.autoSync(),R.id.auto_sync,(control,value)->saveMenuSetting(()->{repository.setAutoSync(value);return true;},this::autoSync));
String backgroundProblem=repository.homeWifiEnabled()?HomeWifiSync.permissionProblem(this,true):"";
settingsToggle(sync,"Фоновая синхронизация",backgroundProblem.isEmpty()?"В домашнем Wi-Fi, даже когда QSafe закрыт":backgroundProblem,repository.homeWifiEnabled(),R.id.background_sync,(control,value)->{
if(value&&!repository.connected()){manualConnection(true);return;}
if(value&&(repository.homeSsid().isEmpty()||!HomeWifiSync.permissionProblem(this,true).isEmpty())){homeWifiSettings(true,null);return;}
saveMenuSetting(()->{repository.setHomeWifi(value,repository.homeSsid());HomeWifiSync.install(this,repository);return true;},null);
});
settingsItem(sync,"Домашний Wi-Fi",repository.homeSsid().isEmpty()?"Имя сети и разрешения Android":repository.homeSsid(),R.id.home_wifi_settings,()->homeWifiSettings(false,null));
settingsItem(sync,"Синхронизировать сейчас",syncCaption(),R.id.sync,()->{if(repository.connected())sync(true);else manualConnection();});
LinearLayout security=settingsSection("Защита");
settingsToggle(security,"Вход по отпечатку",biometrics.available()?"Разблокировка системной биометрией":"Для включения настройте отпечаток в Android",biometrics.enabled(),R.id.fingerprint,(control,value)->{
if(value)enableFingerprint();else saveMenuSetting(()->{biometrics.disable();return true;},null);
});
android.view.autofill.AutofillManager autofill=getSystemService(android.view.autofill.AutofillManager.class);
settingsToggle(security,"Автозаполнение","Пароли в приложениях и поддерживаемых браузерах",autofill!=null&&autofill.hasEnabledAutofillServices(),R.id.autofill_enabled,(control,value)->{
if(!value){
saveMenuSetting(()->{
if(autofill!=null){autofill.disableAutofillServices();for(int attempt=0;attempt<30&&autofill.hasEnabledAutofillServices();attempt++)Thread.sleep(100);if(autofill.hasEnabledAutofillServices())throw new IOException("Android ещё не отключил автозаполнение. Повторите попытку.");}
return true;
},null);return;
}
try{settings();refreshMenuOnResume=true;startActivity(new Intent(android.provider.Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE,Uri.parse("package:"+getPackageName())));}
catch(ActivityNotFoundException e){refreshMenuOnResume=false;settings();error("Откройте системные настройки автозаполнения Android");}
});
settingsItem(security,"Заблокировать хранилище","При уходе из приложения блокировка включается автоматически",R.id.lock_vault,()->lock(""));
LinearLayout data=settingsSection("Данные");
settingsItem(data,"Импорт из SafeInCloud","Перенос карточек из XML",R.id.import_settings,this::importHelp);
settingsItem(data,"Резервные копии","Экспорт и восстановление зашифрованного .qsafe",R.id.backup_settings,this::backupSettings);
LinearLayout app=settingsSection("Приложение");
settingsItem(app,"Проверить обновления","QSafe "+BuildConfig.VERSION_NAME+" · Аргус",R.id.check_updates,this::checkUpdates);
TextView footer=label("Блокировка через 2 минуты бездействия. Буфер обмена очищается через 30 секунд.",12,MUTED);footer.setPadding(dp(4),dp(14),dp(4),dp(4));content.addView(footer);
ScrollView menuScroll=pageScroll;menuScroll.post(()->menuScroll.scrollTo(0,position));
}
private LinearLayout settingsSection(String title) {
TextView heading=label(title,13,MUTED);heading.setTypeface(null,Typeface.BOLD);heading.setPadding(dp(4),dp(18),0,dp(9));content.addView(heading);
LinearLayout section=column();section.setPadding(dp(8),dp(3),dp(8),dp(3));section.setBackground(shape(SURFACE,16));content.addView(section);return section;
}
private LinearLayout settingsRow(LinearLayout section,String title,String subtitle) {
if(section.getChildCount()>0){View divider=new View(this);divider.setBackgroundColor(LINE);LinearLayout.LayoutParams line=wrap();line.height=dp(1);line.leftMargin=dp(10);line.rightMargin=dp(10);section.addView(divider,line);}
LinearLayout item=row();item.setPadding(dp(10),dp(12),dp(8),dp(12));item.setMinimumHeight(dp(70));
item.setBackground(new RippleDrawable(ColorStateList.valueOf(darkTheme?0x2478D5BF:0x18087F70),shape(SURFACE,12),null));
LinearLayout text=column();TextView name=label(title,15,INK);name.setTypeface(null,Typeface.BOLD);text.addView(name);if(!subtitle.isEmpty())text.addView(label(subtitle,12,MUTED));
item.addView(text,new LinearLayout.LayoutParams(0,-2,1));section.addView(item,wrap());return item;
}
private void settingsItem(LinearLayout section,String title,String subtitle,int id,Runnable action) {
LinearLayout item=settingsRow(section,title,subtitle);item.setId(id);item.setContentDescription(title);
ImageView arrow=new ImageView(this);arrow.setImageDrawable(new GlyphDrawable("chevron",MUTED,dp(18)));item.addView(arrow,new LinearLayout.LayoutParams(dp(30),dp(26)));
item.setOnClickListener(v->{resetTimer();if(!busy)action.run();});
}
private void settingsToggle(LinearLayout section,String title,String subtitle,boolean value,int id,BiConsumer<Switch,Boolean> change) {
LinearLayout item=settingsRow(section,title,subtitle);Switch control=new Switch(this);control.setId(id);control.setContentDescription(title);control.setChecked(value);control.setSaveEnabled(false);
control.setMinimumHeight(dp(48));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-2,dp(48));p.leftMargin=dp(10);item.addView(control,p);
boolean[] binding={false};control.setOnCheckedChangeListener((button,checked)->{
if(binding[0])return;resetTimer();
if(busy){binding[0]=true;control.setChecked(!checked);binding[0]=false;return;}
control.setEnabled(false);change.accept(control,checked);
});
item.setOnClickListener(v->{if(control.isEnabled())control.performClick();});
}
private void saveMenuSetting(Work<Boolean> save,Runnable after) {
Runnable refresh=()->{if(screen.equals("menu")&&session!=null)settings();};
task("Сохраняю настройки…",save,ignored->{if(after!=null)after.run();refresh.run();},refresh);
}
private String themeTitle() {
String mode=AppAppearance.mode(this);return mode.equals(AppAppearance.DARK)?"Тёмная":mode.equals(AppAppearance.LIGHT)?"Светлая":"Как в системе";
}
private void themeSettings() {
if(session==null||busy)return;documentPage("Тема приложения",this::settings,null,null);screen="theme";appearanceRefresh=this::themeSettings;
paragraph("Выберите оформление. Оно применяется сразу и сохраняется на этом устройстве.");
RadioGroup options=new RadioGroup(this);content.addView(options);
String[] titles={"Как в системе","Светлая","Тёмная"},modes={AppAppearance.SYSTEM,AppAppearance.LIGHT,AppAppearance.DARK};
int[] ids={R.id.theme_system,R.id.theme_light,R.id.theme_dark};
for(int i=0;i<titles.length;i++){
RadioButton option=new RadioButton(this);option.setId(ids[i]);option.setText(titles[i]);option.setTextSize(17);option.setTextColor(INK);option.setMinHeight(dp(64));option.setPadding(dp(12),dp(8),dp(12),dp(8));options.addView(option);
if(modes[i].equals(AppAppearance.mode(this)))options.check(ids[i]);
}
options.setOnCheckedChangeListener((group,id)->{resetTimer();String selected=id==ids[0]?modes[0]:id==ids[1]?modes[1]:modes[2];AppAppearance.save(this,selected);applyAppearance();themeSettings();});
gap(content,16);paragraph("Режим «Как в системе» следует светлой или тёмной теме Android.");
}
private void backupSettings() {
if(session==null||busy)return;documentPage("Резервные копии",this::settings,null,null);screen="backups";appearanceRefresh=this::backupSettings;
paragraph("Копии .qsafe зашифрованы мастер-паролем. Для восстановления нужен пароль выбранной копии.");
button(content,"Сохранить копию .qsafe",true,()->task("Подготавливаю копию…",repository::encrypted,bytes->{
pendingExport=bytes;Intent i=new Intent(Intent.ACTION_CREATE_DOCUMENT).setType("application/octet-stream").addCategory(Intent.CATEGORY_OPENABLE).putExtra(Intent.EXTRA_TITLE,"QSafe-"+new java.text.SimpleDateFormat("yyyy-MM-dd",Locale.ROOT).format(new Date())+".qsafe");startActivityForResult(i,EXPORT);
})).setId(R.id.export_backup);
button(content,"Восстановить из файла .qsafe",false,()->pick(RESTORE,"*/*")).setId(R.id.restore_backup);
gap(content,20);paragraph("На телефоне автоматически сохраняются последние 10 зашифрованных версий базы.");
}
private void offerFingerprint() {
if(session==null||isAutofill()||!screen.equals("home")||(dialog!=null&&dialog.isShowing())||biometrics.enabled()||repository.fingerprintOffered()||!biometrics.available())return;
if(busy){handler.postDelayed(this::offerFingerprint,500);return;}
SyncRuntime.IO.execute(()->{try{repository.fingerprintOffered(true);}catch(Exception ignored){}});
LinearLayout body=column();body.addView(label("Открывайте карточки касанием датчика. Мастер-пароль сохранится как способ входа и восстановления.",16,INK));
body.addView(label("Включить или отключить вход по отпечатку можно в меню ☰.",14,MUTED));
showDialog("Вход по отпечатку",body,"Включить",this::enableFingerprint);
}
private void enableFingerprint() {
if(session==null||busy)return;
if(!biometrics.available()) {
documentPage("Вход по отпечатку",this::settings,null,null);screen="fingerprint_setup";appearanceRefresh=this::enableFingerprint;
paragraph("Добавьте отпечаток пальца в настройках безопасности Android, затем включите вход в меню QSafe.");
button(content,"Открыть настройки Android",true,()->{try{
Intent intent=Build.VERSION.SDK_INT>=30?new Intent(android.provider.Settings.ACTION_BIOMETRIC_ENROLL).putExtra(android.provider.Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED,android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG):new Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS);startActivity(intent);
}catch(ActivityNotFoundException e){error("Откройте настройки отпечатков пальцев Android");}});return;
}
Session current=session;int started=generation;boolean fromMenu=screen.equals("menu");
biometrics.enable(current.key,new Biometrics.Result() {
public void done(VaultCrypto.Key ignored){if(started==generation&&session==current){dismiss();notice="Вход по отпечатку включён";if(fromMenu)settings();else showHome();}}
public void error(String m){if(started==generation){if(fromMenu&&session==current)settings();MainActivity.this.error(m);}}
});
}
private void checkUpdates() {
if(busy)return;
task("Проверяю обновления…",AppUpdates::check,release->{
LinearLayout body=column();
if(!release.newerThan(BuildConfig.VERSION_CODE)) {
body.addView(label("Установлена актуальная версия QSafe "+BuildConfig.VERSION_NAME,17,INK));showDialog("Обновлений нет",body,null,null);return;
}
body.addView(label("QSafe "+release.version,21,INK));body.addView(label(release.notes,15,MUTED));
body.addView(label(String.format(Locale.ROOT,"Размер: %.1f МБ",release.size/1048576.0),13,MUTED));
showDialog("Доступно обновление",body,"Скачать и установить",()->task("Загружаю обновление…",()->AppUpdates.download(this,release),file->{downloadedUpdate=release;dismiss();requestUpdateInstall();}));
});
}
private void requestUpdateInstall() {
if(downloadedUpdate==null)return;
if(getPackageManager().canRequestPackageInstalls()){installDownloadedUpdate();return;}
LinearLayout body=column();body.addView(label("APK проверен. Разрешите QSafe устанавливать обновления в следующем системном окне. Установку каждой версии подтверждает Android.",16,INK));
showDialog("Разрешение на обновления",body,"Открыть настройки",()->{
dismiss();resumeUpdateInstall=true;
try{startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,Uri.parse("package:"+getPackageName())));}
catch(ActivityNotFoundException e){resumeUpdateInstall=false;error("Не удалось открыть разрешение на установку обновлений");}
});
}
private void installDownloadedUpdate() {
if(downloadedUpdate==null||busy)return;UpdateManifest release=downloadedUpdate;
task("Проверяю APK…",()->{AppUpdates.verify(this,AppUpdates.apk(this),release);return true;},ok->{
try{if(getPackageManager().canRequestPackageInstalls())startActivity(AppUpdates.installer(this));else requestUpdateInstall();}
catch(ActivityNotFoundException e){error("Установщик Android недоступен");}
});
}
private void manualConnection() {
manualConnection(false);
}
private void manualConnection(boolean enableBackground) {
manualConnection(enableBackground,repository.url(),"",repository.homeSsid(),repository.certificateSha256());
}
private void manualConnection(boolean enableBackground,String addressDraft,String tokenDraft,String ssidDraft,String pinDraft) {
if(session==null||busy)return;
Runnable[] save={null};documentPage("Подключение Pi",this::settings,"Сохранить",()->save[0].run());screen="connection";
paragraph(enableBackground?"Сначала настройте подключение Pi. После сохранения включится фоновая синхронизация.":"Связь с базой разрешена только внутри домашнего Wi-Fi. Мастер-пароль сюда вводить не нужно.");
button(content,"Выбрать файл подключения Pi",false,()->pick(CONNECTION,"*/*")).setId(R.id.connection_file);
content.addView(label("Локальный HTTPS-адрес Pi",13,MUTED));EditText url=input(content,"https://192.168…",addressDraft,false,R.id.connection_url);
content.addView(label(repository.connected()?"Ключ подключения · пустое поле сохраняет прежний ключ":"Ключ подключения",13,MUTED));EditText token=input(content,"Ключ доступа Pi",tokenDraft,true,R.id.connection_token);
content.addView(label("Домашний Wi-Fi (SSID)",13,MUTED));EditText ssid=input(content,"Точное имя сети",ssidDraft,false,R.id.home_ssid);
content.addView(label("SHA-256 сертификата Pi",13,MUTED));EditText pin=input(content,"64 шестнадцатеричных символа",pinDraft,false,R.id.connection_pin);
appearanceRefresh=()->manualConnection(enableBackground,url.getText().toString(),token.getText().toString(),ssid.getText().toString(),pin.getText().toString());
save[0]=()->{
if(busy)return;
String address=url.getText().toString(),credential=token.getText().toString(),home=ssid.getText().toString(),fingerprint=pin.getText().toString();
task("Сохраняю подключение…",()->{repository.updateLan(address,credential,home,fingerprint);if(enableBackground)repository.setHomeWifi(true,home);HomeWifiSync.install(this,repository);return true;},v->{hideKeyboard(token);token.setText("");notice="Локальное подключение сохранено";settings();autoSync();});
};
}
private void homeWifiSettings() {
homeWifiSettings(enableWifiOnSave,wifiDraft);
}
private void homeWifiSettings(boolean enableOnSave,String draft) {
if(repository==null||busy)return;
enableWifiOnSave=enableOnSave;Runnable[] save={null};
documentPage("Домашний Wi-Fi",()->{enableWifiOnSave=false;if(session==null)showLocked();else settings();},"Сохранить",()->save[0].run());screen="wifi";
LinearLayout body=content;
body.addView(label(enableOnSave?"Укажите домашнюю сеть. После сохранения включится фоновая синхронизация.":"Связь с базой разрешена только в этой сети, включая ручную синхронизацию. Переключатель фоновой синхронизации находится в меню.",15,INK));
String current=HomeWifiSync.currentSsid(this);
EditText ssid=input(body,"Имя домашнего Wi-Fi (SSID)",draft!=null?draft:repository.homeSsid().isEmpty()?current:repository.homeSsid(),false,R.id.home_ssid);
wifiDraft=ssid.getText().toString();ssid.addTextChangedListener(new TextWatcher(){public void beforeTextChanged(CharSequence s,int a,int c,int f){}public void onTextChanged(CharSequence s,int a,int b,int c){wifiDraft=s.toString();}public void afterTextChanged(Editable e){}});
appearanceRefresh=()->homeWifiSettings(enableOnSave,ssid.getText().toString());
button(body,"Использовать текущую сеть",false,()->{
String name=HomeWifiSync.currentSsid(this);
if(name.isEmpty())error(HomeWifiSync.permissionProblem(this,false).isEmpty()?"Подключитесь к домашнему Wi-Fi":HomeWifiSync.permissionProblem(this,false));
else ssid.setText(name);
});
String problem=HomeWifiSync.permissionProblem(this,true);
body.addView(label(problem.isEmpty()?"Разрешения для распознавания сети доступны":problem,14,MUTED));
body.addView(label("Android считает имя Wi-Fi данными о местоположении. QSafe не запрашивает координаты и не сохраняет историю перемещений.",14,MUTED));
if(!HomeWifiSync.locationAllowed(this))button(body,"Разрешить доступ к имени Wi-Fi",false,()->requestPermissions(new String[]{android.Manifest.permission.ACCESS_FINE_LOCATION,android.Manifest.permission.ACCESS_COARSE_LOCATION},60));
if(android.os.Build.VERSION.SDK_INT>=29&&HomeWifiSync.locationAllowed(this)&&!HomeWifiSync.backgroundAllowed(this)) {
button(body,"Разрешить распознавание Wi-Fi в фоне",false,()->{
if(android.os.Build.VERSION.SDK_INT==29)requestPermissions(new String[]{android.Manifest.permission.ACCESS_BACKGROUND_LOCATION},61);
else {
dismiss();AlertDialog prompt=new AlertDialog.Builder(this).setTitle("Доступ в фоне")
.setMessage("В настройках приложения откройте Разрешения → Местоположение → Разрешить всегда. Это нужно Android для проверки имени Wi-Fi, когда QSafe закрыт.")
.setNegativeButton("Позже",null).setPositiveButton("Открыть настройки",(d,w)->{returnHomeSettings=true;startActivity(new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,Uri.parse("package:"+getPackageName())));}).create();
dialog=prompt;SecureWindows.show(prompt);
}
});
}
button(body,"Настройки геолокации Android",false,()->{returnHomeSettings=true;startActivity(new Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS));});
body.addView(label("Новые копии с Pi проверяются мастер-ключом при следующем входе. При одновременных правках сохраняются обе версии карточки. Android может отложить фоновую задачу ради экономии заряда.",14,MUTED));
if(!repository.backgroundStatus().isEmpty())body.addView(label(repository.backgroundStatus(),14,MUTED));
save[0]=()->{
if(busy)return;String name=CipherSync.ssid(ssid.getText().toString());
if(name.isEmpty()||name.getBytes(StandardCharsets.UTF_8).length>32){ssid.setError("Укажите имя домашней сети длиной до 32 байт");return;}
boolean value=enableOnSave||repository.homeWifiEnabled();
task("Сохраняю настройки Wi-Fi…",()->{repository.setHomeWifi(value,name);HomeWifiSync.install(this,repository);return true;},v->{hideKeyboard(ssid);enableWifiOnSave=false;wifiDraft=null;notice="Настройки домашней сети сохранены";if(session==null)showLocked();else settings();});
};
}
@Override public void onRequestPermissionsResult(int request,String[] permissions,int[] grants) {
super.onRequestPermissionsResult(request,permissions,grants);
if(request==60||request==61){homeWifiSettings();SyncRuntime.IO.execute(()->{try{HomeWifiSync.install(this,repository);}catch(Exception ignored){}});}
}
private void importHelp() {
if(session==null||busy)return;documentPage("Импорт из SafeInCloud",this::settings,null,null);screen="import";appearanceRefresh=this::importHelp;
LinearLayout body=content;body.addView(label("Экспортируйте базу SafeInCloud в XML, затем выберите этот файл. Карточки, поля, заметки и метки будут добавлены к текущей базе после проверки.",16,INK));
body.addView(label("XML содержит открытые пароли. Не отправляйте его в чат или облако. После проверки переноса удалите экспорт с устройств. Исходную базу SafeInCloud пока сохраните.",14,MUTED));
body.addView(label("Если на телефоне нет XML-экспорта, используйте SafeInCloud для Windows: Файл → Экспорт → XML.",14,MUTED));
button(body,"Выбрать XML",true,()->pick(IMPORT,"*/*"));
}
private void pick(int request,String type) {if(busy)return;dismiss();Intent i=new Intent(Intent.ACTION_OPEN_DOCUMENT).setType(type).addCategory(Intent.CATEGORY_OPENABLE);startActivityForResult(i,request);}
@Override protected void onActivityResult(int request,int result,Intent intent) {
super.onActivityResult(request,result,intent);if(result!=RESULT_OK||intent==null||intent.getData()==null)return;Uri uri=intent.getData();
if(request==CONNECTION) {
task("Читаю подключение Pi…",()->{
byte[] bytes=Repository.bounded(getContentResolver().openInputStream(uri),16*1024);
JSONObject c=new JSONObject(new String(bytes,StandardCharsets.UTF_8));
if(!c.optString("kind").equals("qsafe-connection")||c.optInt("version")!=1)throw new IOException("Это не файл подключения QSafe");
Repository.validateUrl(c.getString("url"));LanPolicy.identity(c.optString("homeWifiSsid"),c.optString("certificateSha256"));return c;
},c->{
LinearLayout body=column();body.addView(label("Подключить хранилище к серверу:",16,INK));body.addView(label(c.optString("url"),16,TEAL));
showDialog("Ваш Raspberry Pi",body,"Подключить",()->task("Сохраняю подключение…",()->{
repository.configureLan(c.getString("url"),c.getString("token"),c.getString("homeWifiSsid"),c.getString("certificateSha256"));
if(!c.optString("homeWifiSsid").isEmpty())repository.setHomeWifi(true,c.getString("homeWifiSsid"));
HomeWifiSync.install(this,repository);return true;
},v->{dismiss();notice="Pi подключён. Создайте базу или восстановите её с сервера.";showLocked();}));
});
} else if(request==IMPORT) {pendingImport=uri;notice="Разблокируйте хранилище для импорта XML";showLocked();}
else if(request==EXPORT) {
byte[] data=pendingExport;pendingExport=null;if(data==null){error("Повторите создание резервной копии");return;}
task("Сохраняю зашифрованную копию…",()->{try(OutputStream out=getContentResolver().openOutputStream(uri,"wt")){if(out==null)throw new IOException("Не удалось открыть файл");out.write(data);}return true;},v->error("Зашифрованная копия сохранена"));
} else if(request==RESTORE) restoreDialog(uri);
}
private void importXml(Uri uri) {
if(session==null)return;Session current=session;
task("Проверяю XML…",()->SafeInCloudImport.preview(Repository.bounded(getContentResolver().openInputStream(uri),24*1024*1024),current.data),preview->{
LinearLayout body=column();body.addView(label("Новых карточек: "+preview.added+"\nПовторных: "+preview.duplicates+"\nУдалённых и шаблонов пропущено: "+preview.skipped+"\nИсходных вложений сохранено: "+preview.attachments,17,INK));
body.addView(label("Открытые пароли будут зашифрованы перед сохранением. Исходный файл не изменяется.",14,MUTED));
showDialog("Проверка переноса",body,preview.added>0?"Импортировать":null,()->{
task("Шифрую импортированные карточки…",()->{repository.save(preview.vault,current.key);return preview.vault;},data->{current.data=data;dismiss();notice="Перенесено карточек: "+preview.added;showHome();autoSync();});
});
});
}
private void restoreDialog(Uri uri) {
restoreDialog(uri,"");
}
private void restoreDialog(Uri uri,String draftPassword) {
Runnable[] restore={null};documentPage("Восстановление базы",()->{if(session==null)showLocked();else backupSettings();},"Восстановить",()->restore[0].run());screen="restore";
LinearLayout body=content;body.addView(label("Введите мастер-пароль выбранной копии. Текущая локальная база будет заменена; её зашифрованная резервная копия сохранится на телефоне.",15,INK));
EditText password=input(body,"Мастер-пароль копии",draftPassword,true,R.id.restore_password);
appearanceRefresh=()->restoreDialog(uri,password.getText().toString());
restore[0]=()->{
if(busy)return;char[] pass=password.getText().toString().toCharArray();password.setText("");
task("Проверяю и восстанавливаю копию…",()->{
try {byte[] data=Repository.bounded(getContentResolver().openInputStream(uri),VaultCrypto.MAX_BYTES);repository.restoreFile(data,pass);biometrics.disable();return true;}
finally{Arrays.fill(pass,'\0');}
},v->lock("Копия восстановлена. Введите её мастер-пароль."));
};
}
protected boolean isAutofill() {return false;}
protected void showAutofill() { }
protected JSONObject unlockedData() {return session==null?null:session.data;}
}
@@ -0,0 +1,56 @@
package ru.kusoft.qsafe;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.*;
/** Offline generation; each digit and word is sampled independently with SecureRandom. */
public final class PasswordGenerator {
public enum Mode { COMPLEX, DIGITS, MEMORABLE }
private static final SecureRandom RANDOM=new SecureRandom();
private static final String[] WORDS=loadWords();
private PasswordGenerator() { }
private static String[] loadWords() {
try(InputStream stream=PasswordGenerator.class.getResourceAsStream("/password-words.txt")) {
if(stream==null)throw new IOException("Missing password word list");
ByteArrayOutputStream bytes=new ByteArrayOutputStream();byte[] buffer=new byte[4096];int n;
while((n=stream.read(buffer))!=-1)bytes.write(buffer,0,n);
String[] words=bytes.toString(StandardCharsets.UTF_8.name()).trim().split("\\s+");
if(words.length!=256||new HashSet<>(Arrays.asList(words)).size()!=256)throw new IOException("Invalid password word list");
return words;
}catch(IOException e){throw new IllegalStateException(e);}
}
public static String generate(Mode mode,int length,boolean symbols) {
return generate(mode,length,symbols,true);
}
public static String generate(Mode mode,int length,boolean symbols,boolean digits) {
Objects.requireNonNull(mode);
if(mode==Mode.COMPLEX)return VaultCrypto.generate(length,symbols);
if(mode==Mode.DIGITS) {
if(length<4||length>128)throw new IllegalArgumentException("Digit count must be 4–128");
StringBuilder value=new StringBuilder(length);
for(int i=0;i<length;i++)value.append((char)('0'+RANDOM.nextInt(10)));
return value.toString();
}
if(length<2||length>12)throw new IllegalArgumentException("Word count must be 2–12");
String signs="!@#$%&*+-=?";
// Only the boundaries around whole words can receive extra characters.
StringBuilder[] boundaries=new StringBuilder[length+1];
for(int i=0;i<boundaries.length;i++)boundaries[i]=new StringBuilder();
if(digits)for(int i=0;i<2;i++)insertAtBoundary(boundaries,(char)('0'+RANDOM.nextInt(10)));
if(symbols)for(int i=1;i<length;i++)insertAtBoundary(boundaries,signs.charAt(RANDOM.nextInt(signs.length())));
StringBuilder value=new StringBuilder();
for(int i=0;i<length;i++) {
String word=WORDS[RANDOM.nextInt(WORDS.length)];
value.append(boundaries[i]).append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return value.append(boundaries[length]).toString();
}
private static void insertAtBoundary(StringBuilder[] boundaries,char character) {
StringBuilder boundary=boundaries[RANDOM.nextInt(boundaries.length)];
boundary.insert(RANDOM.nextInt(boundary.length()+1),character);
}
}
@@ -0,0 +1,17 @@
package ru.kusoft.qsafe;
import android.app.Application;
public final class QSafeApplication extends Application {
@Override public void onCreate() {
super.onCreate();
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
public void onActivityCreated(android.app.Activity a,android.os.Bundle state){SecureWindows.protect(a);}
public void onActivityStarted(android.app.Activity a) { }
public void onActivityResumed(android.app.Activity a){SecureWindows.protect(a);}
public void onActivityPaused(android.app.Activity a) { }
public void onActivityStopped(android.app.Activity a) { }
public void onActivitySaveInstanceState(android.app.Activity a,android.os.Bundle state){state.clear();}
public void onActivityDestroyed(android.app.Activity a) { }
});
SyncRuntime.IO.execute(()->{try{HomeWifiSync.install(this,Repository.get(this));}catch(Exception ignored){}});
}
}
@@ -0,0 +1,63 @@
package ru.kusoft.qsafe;
import android.app.PendingIntent;
import android.app.assist.AssistStructure;
import android.content.Intent;
import android.os.CancellationSignal;
import android.service.autofill.*;
import android.view.View;
import android.view.autofill.AutofillId;
import android.widget.RemoteViews;
import java.util.*;
/** Authentication is required for each fill request. Never reads an unlocked vault in the background. */
public final class QSafeAutofillService extends AutofillService {
private static final class Fields {
final ArrayList<AutofillId> logins=new ArrayList<>(),passwords=new ArrayList<>();
String domain="";boolean ambiguous=false;int visited=0;
}
@Override public void onFillRequest(FillRequest request,CancellationSignal cancellation,FillCallback callback) {
if(cancellation.isCanceled())return;
try {
List<FillContext> contexts=request.getFillContexts();if(contexts.isEmpty()){callback.onSuccess(null);return;}
AssistStructure s=contexts.get(contexts.size()-1).getStructure();
if(s.getActivityComponent()==null||getPackageName().equals(s.getActivityComponent().getPackageName())){callback.onSuccess(null);return;}
Fields f=new Fields();for(int i=0;i<s.getWindowNodeCount();i++)walk(s.getWindowNodeAt(i).getRootViewNode(),f,0);
if(f.passwords.isEmpty()||f.ambiguous||f.passwords.size()>8||f.logins.size()>8){callback.onSuccess(null);return;}
if(!f.domain.isEmpty()) {
// A third-party app must not impersonate a website by supplying its own webDomain.
Intent browser=new Intent(Intent.ACTION_VIEW,android.net.Uri.parse("https://example.com")).addCategory(Intent.CATEGORY_BROWSABLE);
android.content.pm.ResolveInfo resolved=getPackageManager().resolveActivity(browser,android.content.pm.PackageManager.MATCH_DEFAULT_ONLY);
if(resolved==null||resolved.activityInfo==null||!resolved.activityInfo.packageName.equals(s.getActivityComponent().getPackageName())) {callback.onSuccess(null);return;}
}
Intent intent=new Intent(this,AutofillActivity.class).putParcelableArrayListExtra("logins",f.logins)
.putParcelableArrayListExtra("passwords",f.passwords).putExtra("domain",f.domain)
.putExtra("targetPackage",s.getActivityComponent().getPackageName());
// A unique intent isolates simultaneous autofill requests and their origin/IDs.
intent.setData(android.net.Uri.parse("qsafe-autofill://request/"+UUID.randomUUID()));
PendingIntent pending=PendingIntent.getActivity(this,0,intent,PendingIntent.FLAG_CANCEL_CURRENT|PendingIntent.FLAG_IMMUTABLE);
ArrayList<AutofillId> all=new ArrayList<>(f.logins);all.addAll(f.passwords);
RemoteViews presentation=new RemoteViews(getPackageName(),android.R.layout.simple_list_item_1);
presentation.setTextViewText(android.R.id.text1,"QSafe · разблокировать пароли");
callback.onSuccess(new FillResponse.Builder().setAuthentication(all.toArray(new AutofillId[0]),pending.getIntentSender(),presentation).build());
}catch(RuntimeException e){callback.onSuccess(null);}
}
private void walk(AssistStructure.ViewNode node,Fields f,int depth) {
if(depth>50||++f.visited>2000)return;
String domain=node.getWebDomain();
if(domain!=null&&!domain.isEmpty()) {
if(!"https".equalsIgnoreCase(node.getWebScheme()))f.ambiguous=true;
domain=domain.toLowerCase(Locale.ROOT);
if(!f.domain.isEmpty()&&!f.domain.equals(domain))f.ambiguous=true;else f.domain=domain;
}
if(node.getAutofillId()!=null&&node.getVisibility()==View.VISIBLE) {
String[] hints=node.getAutofillHints();
if(hints!=null) for(String hint:hints) {
if(hint.equalsIgnoreCase("password")) {if(!f.passwords.contains(node.getAutofillId()))f.passwords.add(node.getAutofillId());}
else if(hint.equalsIgnoreCase("username")||hint.equalsIgnoreCase("emailAddress")) {if(!f.logins.contains(node.getAutofillId()))f.logins.add(node.getAutofillId());}
}
}
for(int i=0;i<node.getChildCount();i++)walk(node.getChildAt(i),f,depth+1);
}
@Override public void onSaveRequest(SaveRequest request,SaveCallback callback) {callback.onFailure("Сохраните новую карточку в QSafe вручную");}
}
@@ -0,0 +1,283 @@
package ru.kusoft.qsafe;
import android.content.Context;
import android.net.Network;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.LinkAddress;
import android.net.RouteInfo;
import android.security.keystore.*;
import android.util.AtomicFile;
import org.json.*;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.*;
import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.security.cert.X509Certificate;
import java.security.cert.CertificateException;
public final class Repository {
private final File directory;
private final AtomicFile vault,base,settings,pending;
private JSONObject config;
private final Context context;
private static Repository instance;
public static synchronized Repository get(Context context) throws Exception {
if(instance==null)instance=new Repository(context.getApplicationContext());
return instance;
}
public static final String DEFAULT_URL="";
public Repository(Context context) throws Exception {
this.context=context.getApplicationContext();
directory=new File(context.getFilesDir(),"vault");
if(!directory.exists()&&!directory.mkdirs()) throw new IOException("Не удалось создать каталог хранилища");
vault=new AtomicFile(new File(directory,"vault.qsafe"));
base=new AtomicFile(new File(directory,"synced.qsafe"));
pending=new AtomicFile(new File(directory,"pending.qsafe"));
settings=new AtomicFile(new File(directory,"connection.enc"));
config=settings.getBaseFile().exists()?new JSONObject(new String(unseal(settings.readFully()),StandardCharsets.UTF_8)):new JSONObject();
}
private SecretKey deviceKey() throws Exception {
KeyStore store=KeyStore.getInstance("AndroidKeyStore");store.load(null);
String alias="qsafe.connection.v1";
if(!store.containsAlias(alias)) {
KeyGenerator generator=KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,"AndroidKeyStore");
generator.init(new KeyGenParameterSpec.Builder(alias,KeyProperties.PURPOSE_ENCRYPT|KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setKeySize(256).build());
generator.generateKey();
}
return (SecretKey)store.getKey(alias,null);
}
private byte[] seal(byte[] data) throws Exception {
Cipher c=Cipher.getInstance("AES/GCM/NoPadding");c.init(Cipher.ENCRYPT_MODE,deviceKey());
c.updateAAD("QSafe/config/1".getBytes(StandardCharsets.UTF_8));byte[] encrypted=c.doFinal(data);
return java.nio.ByteBuffer.allocate(12+encrypted.length).put(c.getIV()).put(encrypted).array();
}
private byte[] unseal(byte[] data) throws Exception {
if(data.length<28) throw new IOException("Повреждены настройки подключения");
Cipher c=Cipher.getInstance("AES/GCM/NoPadding");c.init(Cipher.DECRYPT_MODE,deviceKey(),new GCMParameterSpec(128,Arrays.copyOf(data,12)));
c.updateAAD("QSafe/config/1".getBytes(StandardCharsets.UTF_8));return c.doFinal(data,12,data.length-12);
}
public static void write(AtomicFile file,byte[] bytes) throws IOException {
FileOutputStream stream=null;
try {stream=file.startWrite();stream.write(bytes);file.finishWrite(stream);}
catch(IOException|RuntimeException e) {if(stream!=null) file.failWrite(stream);throw e;}
}
private void saveConfig() throws Exception {write(settings,seal(config.toString().getBytes(StandardCharsets.UTF_8)));}
private void replaceConfig(JSONObject next) throws Exception {write(settings,seal(next.toString().getBytes(StandardCharsets.UTF_8)));config=next;}
public synchronized String url() {return config.optString("url",DEFAULT_URL);}
public synchronized boolean connected() {return !config.optString("token").isEmpty();}
public synchronized boolean autoSync() {return config.optBoolean("autoSync",true);}
public synchronized boolean fingerprintOffered() {return config.optBoolean("fingerprintOffered",false);}
public synchronized void fingerprintOffered(boolean value) throws Exception {config.put("fingerprintOffered",value);saveConfig();}
public synchronized String lastSync() {return config.optString("lastSync","");}
public synchronized boolean homeWifiEnabled() {return config.optBoolean("homeWifiEnabled",false);}
public synchronized String homeSsid() {return config.optString("homeSsid","");}
public synchronized String certificateSha256() {return config.optString("certificateSha256","");}
public synchronized String backgroundStatus() {return config.optString("backgroundStatus","");}
public synchronized void setHomeWifi(boolean enabled,String ssid) throws Exception {
String name=CipherSync.ssid(ssid);
if(enabled&&(name.isEmpty()||name.getBytes(StandardCharsets.UTF_8).length>32))throw new IOException("Укажите имя Wi-Fi длиной до 32 байт");
replaceConfig(new JSONObject(config.toString()).put("homeWifiEnabled",enabled).put("homeSsid",name));
}
public synchronized void backgroundStatus(String status) throws Exception {config.put("backgroundStatus",status);saveConfig();}
public synchronized void setAutoSync(boolean value) throws Exception {replaceConfig(new JSONObject(config.toString()).put("autoSync",value));}
public static String validateUrl(String address) throws Exception {
return LanPolicy.url(address);
}
public synchronized void configure(String address,String token) throws Exception {
configureLan(address,token,homeSsid(),certificateSha256());
}
public synchronized void updateLan(String address,String token,String ssid,String pin) throws Exception {
configureLan(address,token.isEmpty()?config.optString("token"):token,ssid,pin);
}
public synchronized void configureLan(String address,String token,String ssid,String pin) throws Exception {
String normalized=validateUrl(address);
LanPolicy.identity(ssid,pin);
if(!token.matches("[A-Za-z0-9_-]{32,128}")) throw new IOException("Неверный ключ подключения");
if(!normalized.equals(url()) || !token.equals(config.optString("token"))) {
config.remove("etag");config.remove("lastSync");base.delete();pending.delete();
}
config.put("url",normalized).put("token",token).put("homeSsid",ssid).put("certificateSha256",pin.toLowerCase(Locale.ROOT));saveConfig();
}
public boolean exists() {return vault.getBaseFile().exists();}
public byte[] encrypted() throws IOException {return vault.readFully();}
public JSONObject read(VaultCrypto.Key key) throws Exception {
byte[] plain=VaultCrypto.decrypt(encrypted(),key);
try {return Vault.parse(plain);} finally {Arrays.fill(plain,(byte)0);}
}
public void save(JSONObject data,VaultCrypto.Key key) throws Exception {
byte[] plain=Vault.bytes(data);
try {saveEncrypted(VaultCrypto.encrypt(plain,key));} finally {Arrays.fill(plain,(byte)0);}
}
private void snapshot() throws IOException {
if(!exists()) return;
File dir=new File(directory,"backups");if(!dir.exists()&&!dir.mkdirs()) throw new IOException("Не удалось сохранить резервную копию");
File next=new File(dir,System.currentTimeMillis()+"-"+UUID.randomUUID().toString().substring(0,8)+".qsafe");
write(new AtomicFile(next),encrypted());File[] files=dir.listFiles((d,n)->n.endsWith(".qsafe"));
if(files!=null) {Arrays.sort(files,Comparator.comparing(File::getName).reversed());for(int i=10;i<files.length;i++) if(!files[i].delete()) throw new IOException("Не удалось обновить резервные копии");}
}
public void saveEncrypted(byte[] bytes) throws Exception {VaultCrypto.validate(bytes);snapshot();write(vault,bytes);}
public void restoreFile(byte[] bytes,char[] password) throws Exception {
try(VaultCrypto.Key key=VaultCrypto.unlock(bytes,password)) {
byte[] plain=VaultCrypto.decrypt(bytes,key);try {Vault.parse(plain);} finally {Arrays.fill(plain,(byte)0);}
saveEncrypted(bytes);base.delete();pending.delete();synchronized(this) {config.remove("etag");config.remove("lastSync");saveConfig();}
}
}
public static byte[] bounded(InputStream stream,int limit) throws IOException {
try(InputStream in=stream;ByteArrayOutputStream out=new ByteArrayOutputStream()) {
byte[] buffer=new byte[8192];int count,total=0;
while((count=in.read(buffer))!=-1) {total+=count;if(total>limit) throw new IOException("Файл слишком большой");out.write(buffer,0,count);}
return out.toByteArray();
}
}
private static final class Response {
int code;byte[] body;String etag;
}
private Response request(String method,byte[] body,String etag) throws Exception {
return request(method,body,etag,null);
}
private Response request(String method,byte[] body,String etag,Network network) throws Exception {
String address,token,ssid,pin;synchronized(this) {address=url();token=config.optString("token");ssid=homeSsid();pin=certificateSha256();}
if(token.isEmpty()) throw new IOException("Сначала подключите Pi в настройках");
address=validateUrl(address);LanPolicy.identity(ssid,pin);
Network home=HomeWifiSync.homeNetwork(context,ssid,network,false);
if(home==null)throw new IOException("Автономный режим: синхронизация разрешена только в домашнем Wi-Fi «"+ssid+"»");
URL url=new URL(address+"v1/vault");byte[] target=LanPolicy.ipv4(url.getHost());
LinkProperties links=context.getSystemService(ConnectivityManager.class).getLinkProperties(home);
boolean onLink=false,directRoute=false;
if(links!=null) {
for(LinkAddress link:links.getLinkAddresses())if(LanPolicy.sameSubnet(target,link.getAddress().getAddress(),link.getPrefixLength()))onLink=true;
InetAddress ip=InetAddress.getByAddress(target);
for(RouteInfo route:links.getRoutes())if(!route.isDefaultRoute()&&(route.getGateway()==null||route.getGateway().isAnyLocalAddress())&&route.getDestination().contains(ip))directRoute=true;
}
if(!onLink||!directRoute)throw new IOException("Pi должен находиться непосредственно в подсети домашнего Wi-Fi. Интернет и VPN для базы запрещены");
HttpsURLConnection c=(HttpsURLConnection)home.openConnection(url,Proxy.NO_PROXY);
final String expectedPin=pin;
SSLContext tls=SSLContext.getInstance("TLS");
tls.init(null,new TrustManager[]{new X509TrustManager(){
public X509Certificate[] getAcceptedIssuers(){return new X509Certificate[0];}
public void checkClientTrusted(X509Certificate[] chain,String auth)throws CertificateException{throw new CertificateException("Client certificates unsupported");}
public void checkServerTrusted(X509Certificate[] chain,String auth)throws CertificateException{
if(chain==null||chain.length==0)throw new CertificateException("Missing Pi certificate");
chain[0].checkValidity();
try{if(!VaultCrypto.hash(chain[0].getEncoded()).equalsIgnoreCase(expectedPin))throw new CertificateException("Не совпадает локальный сертификат Pi");}
catch(java.security.cert.CertificateEncodingException e){throw new CertificateException(e);}
}
}},null);
c.setSSLSocketFactory(tls.getSocketFactory());c.setUseCaches(false);c.setRequestProperty("Connection","close");
c.setInstanceFollowRedirects(false);c.setConnectTimeout(10_000);c.setReadTimeout(30_000);c.setRequestMethod(method);
c.setRequestProperty("Authorization","Bearer "+token);c.setRequestProperty("Accept","application/octet-stream");
try {
if(body!=null) {
c.setDoOutput(true);c.setFixedLengthStreamingMode(body.length);c.setRequestProperty("Content-Type","application/octet-stream");
c.setRequestProperty(etag==null?"If-None-Match":"If-Match",etag==null?"*":'"'+etag+'"');
try(OutputStream out=c.getOutputStream()) {out.write(body);}
}
Response r=new Response();r.code=c.getResponseCode();
if(r.code==401||r.code==403) throw new IOException("Pi отклонил ключ подключения");
if(r.code==412) throw new IOException("База на Pi обновилась во время синхронизации. Повторите ещё раз.");
if(r.code!=200 && !(r.code==404 && method.equals("GET"))) throw new IOException("Сервер вернул HTTP "+r.code);
r.body=r.code==200?bounded(c.getInputStream(),VaultCrypto.MAX_BYTES):new byte[0];
r.etag=c.getHeaderField("ETag");
if(r.etag!=null && r.etag.startsWith("\"")&&r.etag.endsWith("\"")) r.etag=r.etag.substring(1,r.etag.length()-1);
if(method.equals("GET")&&r.code==200) {
VaultCrypto.validate(r.body);String actual=VaultCrypto.hash(r.body);
if(!actual.equals(r.etag)) throw new IOException("Не совпадает контрольная сумма ответа Pi");
}
return r;
} finally {c.disconnect();}
}
private synchronized void markSynced(byte[] blob) throws Exception {
write(base,blob);config.put("etag",VaultCrypto.hash(blob))
.put("lastSync",new java.text.SimpleDateFormat("dd.MM HH:mm",Locale.getDefault()).format(new Date()));saveConfig();
}
public VaultCrypto.Key restorePi(char[] password) throws Exception {
Response remote=request("GET",null,null);if(remote.code==404) throw new IOException("На Pi пока нет базы QSafe");
VaultCrypto.Key key=VaultCrypto.unlock(remote.body,password);
try {
byte[] plain=VaultCrypto.decrypt(remote.body,key);try {Vault.parse(plain);} finally {Arrays.fill(plain,(byte)0);}
saveEncrypted(remote.body);markSynced(remote.body);pending.delete();return key;
} catch(Exception e) {key.close();throw e;}
}
public static final class SyncResult {
public final JSONObject data; public final int conflicts;
SyncResult(JSONObject data,int conflicts) {this.data=data;this.conflicts=conflicts;}
}
/** No master password, derived key, or plaintext is required or loaded by background work. */
public String backgroundSync(Network network,java.util.function.BooleanSupplier cancelled) throws Exception {
if(!exists()||!connected())return "Создайте или восстановите базу и подключите Pi";
if(cancelled.getAsBoolean())throw new IOException("Фоновая задача остановлена Android");
byte[] local=encrypted();String localHash=VaultCrypto.hash(local),common;
synchronized(this){common=config.optString("etag");}
Response remote=request("GET",null,null,network);
if(cancelled.getAsBoolean())throw new IOException("Фоновая задача остановлена Android");
CipherSync.Plan plan=CipherSync.plan(localHash,common,remote.code==404?null:remote.etag);
switch(plan) {
case MISSING_REMOTE:throw new IOException("База исчезла с Pi. Проверьте сервер и резервные копии.");
case CREATE:request("PUT",local,null,network);markSynced(local);pending.delete();return "Зашифрованная база отправлена на Pi";
case UPLOAD:request("PUT",local,remote.etag,network);markSynced(local);pending.delete();return "Изменения отправлены на Pi";
case UNCHANGED:markSynced(local);pending.delete();return "Телефон и Pi синхронизированы";
case STAGE:
write(pending,remote.body);
return "Копия с Pi загружена. Проверка и объединение — при разблокировке.";
default:throw new IOException("Неизвестное состояние синхронизации");
}
}
/** Authenticate staged remote data only after the user unlocks; never replace trusted data before verification. */
public SyncResult applyPending(VaultCrypto.Key key) throws Exception {
JSONObject localVault=read(key);
if(!pending.getBaseFile().exists())return new SyncResult(localVault,0);
byte[] remote=pending.readFully(),local=encrypted();
if(VaultCrypto.hash(remote).equals(VaultCrypto.hash(local))) {markSynced(local);pending.delete();return new SyncResult(localVault,0);}
byte[] plain=VaultCrypto.decrypt(remote,key);JSONObject remoteVault;
try{remoteVault=Vault.parse(plain);}finally{Arrays.fill(plain,(byte)0);}
if(!remoteVault.getString("id").equals(localVault.getString("id")))throw new IOException("На Pi другое хранилище. Локальная база сохранена.");
byte[] ancestor=base.getBaseFile().exists()?base.readFully():null;
if(ancestor!=null&&VaultCrypto.hash(ancestor).equals(VaultCrypto.hash(local))) {
saveEncrypted(remote);markSynced(remote);pending.delete();return new SyncResult(remoteVault,0);
}
JSONObject baseVault=null;
if(ancestor!=null) {plain=VaultCrypto.decrypt(ancestor,key);try{baseVault=Vault.parse(plain);}finally{Arrays.fill(plain,(byte)0);}}
Vault.Merge merged=Vault.merge(baseVault,localVault,remoteVault);save(merged.vault,key);markSynced(remote);pending.delete();
return new SyncResult(merged.vault,merged.conflicts);
}
/** Whole encrypted blobs with optimistic concurrency and a local encrypted common ancestor. */
public SyncResult sync(VaultCrypto.Key key) throws Exception {
byte[] local=encrypted();String localHash=VaultCrypto.hash(local);
Response remote=request("GET",null,null);
String previous;synchronized(this) {previous=config.optString("etag");}
if(remote.code==404) {
if(!previous.isEmpty()) throw new IOException("База исчезла с Pi. Сначала проверьте сервер и резервные копии.");
request("PUT",local,null);markSynced(local);pending.delete();return new SyncResult(read(key),0);
}
if(localHash.equals(remote.etag)) {markSynced(local);pending.delete();return new SyncResult(read(key),0);}
byte[] remotePlain=VaultCrypto.decrypt(remote.body,key);JSONObject remoteVault;
try {remoteVault=Vault.parse(remotePlain);} finally {Arrays.fill(remotePlain,(byte)0);}
JSONObject localVault=read(key);
if(!localVault.getString("id").equals(remoteVault.getString("id"))) throw new IOException("На Pi другое хранилище. Сохраните локальную копию и восстановите базу Pi.");
if(remote.etag.equals(previous)) {
request("PUT",local,remote.etag);markSynced(local);pending.delete();return new SyncResult(localVault,0);
}
byte[] ancestor=base.getBaseFile().exists()?base.readFully():null;
if(ancestor!=null && VaultCrypto.hash(ancestor).equals(localHash)) {
saveEncrypted(remote.body);markSynced(remote.body);pending.delete();return new SyncResult(remoteVault,0);
}
JSONObject baseVault=null;
if(ancestor!=null) {
byte[] plain=VaultCrypto.decrypt(ancestor,key);try {baseVault=Vault.parse(plain);} finally {Arrays.fill(plain,(byte)0);}
}
Vault.Merge merged=Vault.merge(baseVault,localVault,remoteVault);
byte[] plain=Vault.bytes(merged.vault),next;
try {next=VaultCrypto.encrypt(plain,key);} finally {Arrays.fill(plain,(byte)0);}
request("PUT",next,remote.etag);saveEncrypted(next);markSynced(next);pending.delete();
return new SyncResult(merged.vault,merged.conflicts);
}
}
@@ -0,0 +1,81 @@
package ru.kusoft.qsafe;
import org.json.*;
import org.w3c.dom.*;
import org.xml.sax.InputSource;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import javax.xml.parsers.*;
import javax.xml.transform.*;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
public final class SafeInCloudImport {
public static final class Preview {
public final JSONObject vault;
public final int added,duplicates,skipped,attachments;
Preview(JSONObject v,int a,int d,int s,int f) {vault=v;added=a;duplicates=d;skipped=s;attachments=f;}
}
private static Document document(byte[] data) throws Exception {
if(data.length>24*1024*1024) throw new IOException("XML превышает 24 МиБ");
// Reject declarations before constructing the DOM, including UTF-16 exports.
String xml;
if(data.length>1 && ((data[0]==(byte)0xff&&data[1]==(byte)0xfe)||(data[0]==(byte)0xfe&&data[1]==(byte)0xff)))
xml=new String(data,StandardCharsets.UTF_16);
else xml=new String(data,StandardCharsets.UTF_8);
if(xml.startsWith("\ufeff")) xml=xml.substring(1);
String upper=xml.toUpperCase(Locale.ROOT);
if(upper.contains("<!DOCTYPE")||upper.contains("<!ENTITY")) throw new IOException("DTD и внешние сущности запрещены");
DocumentBuilderFactory f=DocumentBuilderFactory.newInstance(); f.setExpandEntityReferences(false);
try { f.setFeature("http://xml.org/sax/features/external-general-entities",false); } catch(ParserConfigurationException ignored) { }
try { f.setFeature("http://xml.org/sax/features/external-parameter-entities",false); } catch(ParserConfigurationException ignored) { }
DocumentBuilder b=f.newDocumentBuilder();
b.setEntityResolver((pub,sys)->{throw new org.xml.sax.SAXException("Внешние сущности запрещены");});
return b.parse(new InputSource(new StringReader(xml)));
}
private static String raw(Node node) throws TransformerException {
Transformer t=TransformerFactory.newInstance().newTransformer();t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION,"yes");
StringWriter w=new StringWriter();t.transform(new DOMSource(node),new StreamResult(w));return w.toString();
}
private static boolean yes(String value) {return value.equalsIgnoreCase("true")||value.equals("1");}
public static Preview preview(byte[] data,JSONObject current) throws Exception {
Document d=document(data);
if(!d.getDocumentElement().getNodeName().equals("database")) throw new IOException("Ожидается XML-экспорт SafeInCloud с корнем database");
JSONObject result=Vault.copy(current); JSONArray entries=result.getJSONArray("entries");
Set<String> fingerprints=new HashSet<>();
for(int i=0;i<entries.length();i++) fingerprints.add(entries.getJSONObject(i).optString("sourceFingerprint"));
Map<String,String> labels=new HashMap<>();
NodeList children=d.getDocumentElement().getChildNodes();
for(int i=0;i<children.getLength();i++) if(children.item(i) instanceof Element) {
Element e=(Element)children.item(i);if(e.getTagName().equals("label")) labels.put(e.getAttribute("id"),e.getAttribute("name"));
}
int added=0,duplicates=0,skipped=0,attachments=0;
for(int i=0;i<children.getLength();i++) {
if(!(children.item(i) instanceof Element)) continue;Element card=(Element)children.item(i);
if(!card.getTagName().equals("card")) continue;
if(yes(card.getAttribute("deleted"))||yes(card.getAttribute("template"))) {skipped++;continue;}
String original=raw(card);String fingerprint=VaultCrypto.hash(original.getBytes(StandardCharsets.UTF_8));
if(!fingerprints.add(fingerprint)) {duplicates++;continue;}
String title=card.getAttribute("title");if(title.trim().isEmpty()) title="Без названия";
JSONObject e=Vault.entry(title).put("star",yes(card.getAttribute("star"))).put("sourceFingerprint",fingerprint)
.put("safeInCloudXml",original);
JSONArray fields=new JSONArray(),tags=new JSONArray(),files=new JSONArray();
NodeList parts=card.getChildNodes();
for(int j=0;j<parts.getLength();j++) {
if(!(parts.item(j) instanceof Element)) continue;Element part=(Element)parts.item(j);
switch(part.getTagName()) {
case "field": fields.put(Vault.field(part.getAttribute("name"),part.getAttribute("type"),part.getTextContent()));break;
case "notes":e.put("notes",part.getTextContent());break;
case "label_id":String id=part.getTextContent().trim();tags.put(labels.getOrDefault(id,"Метка "+id));break;
case "file": case "image":
files.put(new JSONObject().put("kind",part.getTagName()).put("name",part.getAttribute("name"))
.put("data",part.getTextContent()));attachments++;break;
default: break; // Original card XML retains unknown metadata and history.
}
}
e.put("fields",fields).put("tags",tags).put("attachments",files);entries.put(e);added++;
}
return new Preview(result,added,duplicates,skipped,attachments);
}
}
@@ -0,0 +1,25 @@
package ru.kusoft.qsafe;
import android.app.Activity;
import android.app.AlertDialog;
import android.os.Build;
import android.view.Window;
import android.view.WindowManager;
/** Apply before a window becomes visible, including confirmation dialogs. */
public final class SecureWindows {
private SecureWindows() { }
public static void protect(Activity activity) {
protect(activity.getWindow());
if (Build.VERSION.SDK_INT >= 33) activity.setRecentsScreenshotEnabled(false);
}
public static void protect(Window window) {
if (window != null) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE);
}
public static AlertDialog show(AlertDialog dialog) {
protect(dialog.getWindow());
dialog.show();
AppAppearance.disableForceDark(dialog.getWindow());
return dialog;
}
}
@@ -0,0 +1,7 @@
package ru.kusoft.qsafe;
import java.util.concurrent.*;
/** All app vault I/O, including jobs and autofill, is serialized within this process. */
public final class SyncRuntime {
private SyncRuntime() {}
public static final ExecutorService IO=Executors.newSingleThreadExecutor();
}
@@ -0,0 +1,27 @@
package ru.kusoft.qsafe;
import org.json.*;
import java.io.IOException;
import java.util.Locale;
public final class UpdateManifest {
public static final String ORIGIN="https://argus.kusoft.xyz";
public static final int MAX_APK_BYTES=100*1024*1024;
public final String version,sha256,downloadPath,notes;
public final long versionCode,size;
private UpdateManifest(String version,long code,long size,String hash,String path,String notes) {
this.version=version;versionCode=code;this.size=size;sha256=hash;downloadPath=path;this.notes=notes;
}
public static UpdateManifest parse(JSONObject manifest) throws JSONException,IOException {
if(!manifest.optString("slug").equals("qsafe"))throw new IOException("Каталог вернул другое приложение");
JSONObject release=manifest.getJSONObject("release");
String hash=release.getString("sha256").toLowerCase(Locale.ROOT),path=release.getString("downloadPath"),version=release.getString("version");
long size=release.getLong("packageSizeBytes"),code=release.getLong("androidVersionCode");
if(!release.optString("platform").equals("android")||!release.optString("packageKind").equals("apk")||!release.optString("channel").equals("stable")||
!hash.matches("[0-9a-f]{64}")||!version.matches("[0-9]+\\.[0-9]+\\.[0-9]+")||code<1||code>Integer.MAX_VALUE||size<56||size>MAX_APK_BYTES||
!path.matches("/api/apps/qsafe/releases/[0-9a-fA-F-]{36}/download"))throw new IOException("Некорректные сведения об обновлении");
String notes=release.optString("notes");if(notes.length()>8000)notes=notes.substring(0,8000);
return new UpdateManifest(version,code,size,hash,path,notes);
}
public boolean newerThan(long installedCode){return versionCode>installedCode;}
}
@@ -0,0 +1,32 @@
package ru.kusoft.qsafe;
import android.content.*;
import android.database.*;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import java.io.*;
/** Only the verified cached APK is shared with Android's installer, with a URI grant. */
public final class UpdateProvider extends ContentProvider {
@Override public boolean onCreate(){return true;}
private File file(Uri uri)throws FileNotFoundException {
if(!"content".equals(uri.getScheme())||!(getContext().getPackageName()+".updates").equals(uri.getAuthority())||
!"/update.apk".equals(uri.getPath())||uri.getQuery()!=null||uri.getFragment()!=null)throw new FileNotFoundException("Invalid update URI");
File file=AppUpdates.apk(getContext());if(!file.isFile())throw new FileNotFoundException("Update unavailable");return file;
}
@Override public ParcelFileDescriptor openFile(Uri uri,String mode)throws FileNotFoundException {
if(!"r".equals(mode))throw new FileNotFoundException("Read-only update");return ParcelFileDescriptor.open(file(uri),ParcelFileDescriptor.MODE_READ_ONLY);
}
@Override public String getType(Uri uri){return "application/vnd.android.package-archive";}
@Override public Cursor query(Uri uri,String[] projection,String selection,String[] args,String sort) {
try {
File f=file(uri);String[] columns=projection==null?new String[]{OpenableColumns.DISPLAY_NAME,OpenableColumns.SIZE}:projection;
MatrixCursor cursor=new MatrixCursor(columns);Object[] row=new Object[columns.length];
for(int i=0;i<columns.length;i++){if(OpenableColumns.DISPLAY_NAME.equals(columns[i]))row[i]="QSafe-update.apk";else if(OpenableColumns.SIZE.equals(columns[i]))row[i]=f.length();}cursor.addRow(row);return cursor;
}catch(FileNotFoundException e){return null;}
}
@Override public Uri insert(Uri uri,ContentValues values){throw new SecurityException("Read only");}
@Override public int update(Uri uri,ContentValues values,String where,String[] args){throw new SecurityException("Read only");}
@Override public int delete(Uri uri,String where,String[] args){throw new SecurityException("Read only");}
}
@@ -0,0 +1,110 @@
package ru.kusoft.qsafe;
import org.json.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
public final class Vault {
private Vault() {}
public static JSONObject empty() throws JSONException {
return new JSONObject().put("format",1).put("id",UUID.randomUUID().toString()).put("entries",new JSONArray());
}
public static JSONObject parse(byte[] bytes) throws JSONException {
JSONObject o=new JSONObject(new String(bytes,StandardCharsets.UTF_8));
if(o.optInt("format")!=1 || o.optString("id").isEmpty()) throw new JSONException("Неверный формат базы");
JSONArray a=o.getJSONArray("entries"); Set<String> ids=new HashSet<>();
for(int i=0;i<a.length();i++) {
JSONObject e=a.getJSONObject(i);
if(e.optString("id").isEmpty() || e.optString("rev").isEmpty() || !ids.add(e.getString("id"))) throw new JSONException("Повреждённая карточка");
e.getJSONArray("fields");
}
return o;
}
public static JSONObject copy(JSONObject o) throws JSONException { return new JSONObject(o.toString()); }
public static byte[] bytes(JSONObject o) { return o.toString().getBytes(StandardCharsets.UTF_8); }
public static JSONObject field(String name,String type,String value) throws JSONException {
return new JSONObject().put("name",name).put("type",type).put("value",value);
}
public static JSONObject entry(String title) throws JSONException {
return new JSONObject().put("id",UUID.randomUUID().toString()).put("title",title).put("notes","")
.put("fields",new JSONArray()).put("tags",new JSONArray()).put("star",false).put("deleted",false)
.put("rev",UUID.randomUUID().toString()).put("modified",System.currentTimeMillis());
}
public static void update(JSONObject root,JSONObject entry) throws JSONException {
entry.put("rev",UUID.randomUUID().toString()).put("modified",System.currentTimeMillis());
JSONArray a=root.getJSONArray("entries");
for(int i=0;i<a.length();i++) if(a.getJSONObject(i).getString("id").equals(entry.getString("id"))) { a.put(i,entry); return; }
a.put(entry);
}
/** A form may stay open while sync receives a newer revision; preserve both edits. */
public static JSONObject applyEdit(JSONObject root,JSONObject draft) throws JSONException {
JSONObject saved=copy(draft);JSONArray entries=root.getJSONArray("entries");
for(int i=0;i<entries.length();i++) {
JSONObject current=entries.getJSONObject(i);
if(current.getString("id").equals(draft.getString("id"))&&!current.getString("rev").equals(draft.getString("rev"))) {
if(draft.optBoolean("deleted"))throw new JSONException("Карточка изменилась на Pi. Откройте новую версию перед удалением.");
saved.put("id",UUID.randomUUID().toString()).put("title",draft.optString("title")+" · моя версия (конфликт)");break;
}
}
update(root,saved);return saved;
}
private static Map<String,JSONObject> index(JSONObject root) throws JSONException {
Map<String,JSONObject> map=new LinkedHashMap<>(); if(root==null) return map;
JSONArray a=root.getJSONArray("entries");
for(int i=0;i<a.length();i++) { JSONObject e=a.getJSONObject(i); map.put(e.getString("id"),e); }
return map;
}
private static String rev(JSONObject e) { return e==null?"":e.optString("rev"); }
public static final class Merge {
public final JSONObject vault; public final int conflicts;
Merge(JSONObject vault,int conflicts) {this.vault=vault;this.conflicts=conflicts;}
}
/** Three-way merge by revision. Concurrent edits survive as separate cards, including delete/edit conflicts. */
public static Merge merge(JSONObject base,JSONObject local,JSONObject remote) throws JSONException {
if(!local.getString("id").equals(remote.getString("id"))) throw new JSONException("На Pi другое хранилище. Восстановите его отдельно.");
if(base!=null && !base.getString("id").equals(local.getString("id"))) throw new JSONException("Неверная исходная версия синхронизации");
Map<String,JSONObject> b=index(base),l=index(local),r=index(remote);
Set<String> ids=new LinkedHashSet<>(l.keySet()); ids.addAll(r.keySet());
JSONArray merged=new JSONArray(); int conflicts=0;
for(String id:ids) {
JSONObject le=l.get(id),re=r.get(id),be=b.get(id),chosen;
if(re==null) chosen=le;
else if(le==null || rev(le).equals(rev(be))) chosen=re;
else if(rev(re).equals(rev(be)) || rev(le).equals(rev(re))) chosen=le;
else if(le.optBoolean("deleted") && re.optBoolean("deleted")) chosen=re;
else {
conflicts++;
if(le.optBoolean("deleted")) {
chosen=copy(re); chosen.put("title",re.optString("title")+" · конфликт удаления");
chosen.put("rev",UUID.randomUUID().toString());
} else if(re.optBoolean("deleted")) {
chosen=copy(le); chosen.put("title",le.optString("title")+" · конфликт удаления");
chosen.put("rev",UUID.randomUUID().toString());
} else {
chosen=re;
JSONObject duplicate=copy(le).put("id",UUID.randomUUID().toString()).put("rev",UUID.randomUUID().toString())
.put("title",le.optString("title")+" · локальная версия (конфликт)");
merged.put(duplicate);
}
}
if(chosen!=null) merged.put(copy(chosen));
}
return new Merge(copy(local).put("entries",merged),conflicts);
}
public static List<JSONObject> visible(JSONObject root,String query,boolean stars) throws JSONException {
List<JSONObject> out=new ArrayList<>(); JSONArray a=root.getJSONArray("entries"); String q=query.toLowerCase(Locale.ROOT);
for(int i=0;i<a.length();i++) {
JSONObject e=a.getJSONObject(i);
if(e.optBoolean("deleted") || (stars&&!e.optBoolean("star"))) continue;
StringBuilder search=new StringBuilder(e.optString("title")).append(' ').append(e.optJSONArray("tags"));
JSONArray f=e.getJSONArray("fields");
for(int j=0;j<f.length();j++) {JSONObject v=f.getJSONObject(j);if(!secret(v)) search.append(' ').append(v.optString("value"));}
if(search.toString().toLowerCase(Locale.ROOT).contains(q)) out.add(e);
}
out.sort(Comparator.comparing(o->o.optString("title").toLowerCase(Locale.ROOT))); return out;
}
public static boolean secret(JSONObject field) {
String t=field.optString("type").toLowerCase(Locale.ROOT);
return !Arrays.asList("login","website","url","email","phone","text","date").contains(t);
}
}
@@ -0,0 +1,80 @@
package ru.kusoft.qsafe;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.Arrays;
import javax.crypto.*;
import javax.crypto.spec.*;
/** Versioned authenticated envelope. No plaintext password or derived key is persisted. */
public final class VaultCrypto {
public static final int ITERATIONS = 600_000;
public static final int MAX_BYTES = 32 * 1024 * 1024;
private static final byte[] MAGIC = "QSAFE01!".getBytes(StandardCharsets.US_ASCII);
private static final SecureRandom RANDOM = new SecureRandom();
public static final class Key implements AutoCloseable {
public final byte[] bytes, salt;
public final int iterations;
Key(byte[] bytes, byte[] salt, int iterations) { this.bytes=bytes; this.salt=salt; this.iterations=iterations; }
public void close() { Arrays.fill(bytes,(byte)0); }
}
public static Key create(char[] password) throws GeneralSecurityException {
byte[] salt=new byte[16]; RANDOM.nextBytes(salt);
return derive(password,salt,ITERATIONS);
}
private static Key derive(char[] password, byte[] salt, int iterations) throws GeneralSecurityException {
PBEKeySpec spec=new PBEKeySpec(password,salt,iterations,256);
try { return new Key(SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(),salt.clone(),iterations); }
finally { spec.clearPassword(); }
}
public static void validate(byte[] envelope) {
if (envelope.length<56 || envelope.length>MAX_BYTES || !Arrays.equals(MAGIC,Arrays.copyOf(envelope,8)))
throw new IllegalArgumentException("Неверный или повреждённый файл QSafe");
int count=ByteBuffer.wrap(envelope,8,4).getInt();
if (count<ITERATIONS || count>2_000_000) throw new IllegalArgumentException("Неподдерживаемые параметры шифрования");
}
public static Key unlock(byte[] envelope,char[] password) throws GeneralSecurityException {
validate(envelope);
Key key=derive(password,Arrays.copyOfRange(envelope,12,28),ByteBuffer.wrap(envelope,8,4).getInt());
try { byte[] test=decrypt(envelope,key); Arrays.fill(test,(byte)0); return key; }
catch (GeneralSecurityException | RuntimeException e) { key.close(); throw e; }
}
public static byte[] encrypt(byte[] plaintext,Key key) throws GeneralSecurityException {
if(plaintext.length>MAX_BYTES-56) throw new IllegalArgumentException("Хранилище превышает 32 МиБ");
byte[] nonce=new byte[12]; RANDOM.nextBytes(nonce);
byte[] header=ByteBuffer.allocate(40).put(MAGIC).putInt(key.iterations).put(key.salt).put(nonce).array();
Cipher c=Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(key.bytes,"AES"),new GCMParameterSpec(128,nonce));
c.updateAAD(header);
byte[] encrypted=c.doFinal(plaintext);
return ByteBuffer.allocate(header.length+encrypted.length).put(header).put(encrypted).array();
}
public static byte[] decrypt(byte[] envelope,Key key) throws GeneralSecurityException {
validate(envelope);
if (!MessageDigest.isEqual(key.salt,Arrays.copyOfRange(envelope,12,28)) || key.iterations!=ByteBuffer.wrap(envelope,8,4).getInt())
throw new GeneralSecurityException("У базы другой мастер-пароль или ключ");
Cipher c=Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE,new SecretKeySpec(key.bytes,"AES"),new GCMParameterSpec(128,Arrays.copyOfRange(envelope,28,40)));
c.updateAAD(Arrays.copyOf(envelope,40));
return c.doFinal(envelope,40,envelope.length-40);
}
public static String hash(byte[] data) {
try {
byte[] digest=MessageDigest.getInstance("SHA-256").digest(data); StringBuilder s=new StringBuilder();
for(byte b:digest) s.append(String.format(java.util.Locale.ROOT,"%02x",b&255)); return s.toString();
} catch(NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
public static String generate(int length) {
return generate(length,true);
}
public static String generate(int length,boolean symbols) {
if(length<16 || length>128) throw new IllegalArgumentException();
String[] groups=symbols?new String[]{"ABCDEFGHJKLMNPQRSTUVWXYZ","abcdefghijkmnopqrstuvwxyz","23456789","!@#$%&*+-=?"}:new String[]{"ABCDEFGHJKLMNPQRSTUVWXYZ","abcdefghijkmnopqrstuvwxyz","23456789"};
String alphabet=String.join("",groups); char[] result=new char[length];
for(int i=0;i<length;i++) result[i]=alphabet.charAt(RANDOM.nextInt(alphabet.length()));
for(int i=0;i<groups.length;i++) result[i]=groups[i].charAt(RANDOM.nextInt(groups[i].length()));
for(int i=length-1;i>0;i--) { int j=RANDOM.nextInt(i+1); char t=result[i]; result[i]=result[j]; result[j]=t; }
return new String(result);
}
}
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#087F70" android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#FFFFFF" android:pathData="M54,20L82,30V52C82,70 70,82 54,89C38,82 26,70 26,52V30ZM54,37A10,10 0,0 0,49 56V69H59V56A10,10 0,0 0,54 37"/>
</vector>
+50
View File
@@ -0,0 +1,50 @@
<resources>
<item type="id" name="master_password" />
<item type="id" name="confirm_password" />
<item type="id" name="open_vault" />
<item type="id" name="connect_pi" />
<item type="id" name="entry_list" />
<item type="id" name="add_entry" />
<item type="id" name="search" />
<item type="id" name="settings" />
<item type="id" name="sync" />
<item type="id" name="status" />
<item type="id" name="entry_title" />
<item type="id" name="entry_tags" />
<item type="id" name="entry_notes" />
<item type="id" name="card_scroller" />
<item type="id" name="card_counter" />
<item type="id" name="template_grid" />
<item type="id" name="check_updates" />
<item type="id" name="fingerprint" />
<item type="id" name="generator_preview" />
<item type="id" name="generator_complex" />
<item type="id" name="generator_digits" />
<item type="id" name="generator_memorable" />
<item type="id" name="generator_length" />
<item type="id" name="generator_symbols" />
<item type="id" name="generator_add_digits" />
<item type="id" name="generator_regenerate" />
<item type="id" name="navigate_back" />
<item type="id" name="page_title" />
<item type="id" name="restore_password" />
<item type="id" name="theme_settings" />
<item type="id" name="theme_system" />
<item type="id" name="theme_light" />
<item type="id" name="theme_dark" />
<item type="id" name="connection_settings" />
<item type="id" name="connection_file" />
<item type="id" name="connection_url" />
<item type="id" name="connection_token" />
<item type="id" name="connection_pin" />
<item type="id" name="home_ssid" />
<item type="id" name="auto_sync" />
<item type="id" name="background_sync" />
<item type="id" name="home_wifi_settings" />
<item type="id" name="autofill_enabled" />
<item type="id" name="lock_vault" />
<item type="id" name="import_settings" />
<item type="id" name="backup_settings" />
<item type="id" name="export_backup" />
<item type="id" name="restore_backup" />
</resources>
+25
View File
@@ -0,0 +1,25 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#087F70</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">#F5F8F7</item>
<item name="android:navigationBarColor">#F5F8F7</item>
<item name="android:windowLightNavigationBar">true</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowBackground">#F5F8F7</item>
</style>
<style name="AppTheme.Dark" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#78D5BF</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">#101817</item>
<item name="android:navigationBarColor">#101817</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowBackground">#101817</item>
<item name="android:colorBackground">#101817</item>
<item name="android:textColorPrimary">#E5ECEA</item>
<item name="android:textColorSecondary">#A0B3AD</item>
</style>
</resources>
@@ -0,0 +1 @@
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" android:settingsActivity="ru.kusoft.qsafe.MainActivity" />
@@ -0,0 +1,4 @@
<data-extraction-rules>
<cloud-backup><exclude domain="root" path="." /><exclude domain="file" path="." /><exclude domain="database" path="." /><exclude domain="sharedpref" path="." /><exclude domain="external" path="." /></cloud-backup>
<device-transfer><exclude domain="root" path="." /><exclude domain="file" path="." /><exclude domain="database" path="." /><exclude domain="sharedpref" path="." /><exclude domain="external" path="." /></device-transfer>
</data-extraction-rules>
@@ -0,0 +1,23 @@
package ru.kusoft.qsafe;
import org.junit.Test;
import static org.junit.Assert.*;
public class CipherSyncTests {
@Test public void lockedVaultNeverOverwritesUnknownRemoteChanges() {
assertEquals(CipherSync.Plan.STAGE,CipherSync.plan("phone-new","base","pi-new"));
assertEquals(CipherSync.Plan.STAGE,CipherSync.plan("phone-new","","pi-new"));
assertEquals(CipherSync.Plan.STAGE,CipherSync.plan("base","base","pi-new"));
assertEquals(CipherSync.Plan.UPLOAD,CipherSync.plan("phone-new","base","base"));
}
@Test public void firstUploadAndMissingRemoteHaveDifferentOutcomes() {
assertEquals(CipherSync.Plan.CREATE,CipherSync.plan("phone-new","",null));
assertEquals(CipherSync.Plan.MISSING_REMOTE,CipherSync.plan("phone-new","known",null));
assertEquals(CipherSync.Plan.UNCHANGED,CipherSync.plan("same","older","same"));
}
@Test public void ssidMatchingIsExactAndRejectsUnknown() {
assertTrue(CipherSync.matches("gen","\"gen\""));
assertFalse(CipherSync.matches("gen","Gen"));
assertFalse(CipherSync.matches("gen","<unknown ssid>"));
assertFalse(CipherSync.matches("",null));
assertTrue(CipherSync.matches(" gen ","\" gen \""));
}
}
@@ -0,0 +1,29 @@
package ru.kusoft.qsafe;
import org.junit.Test;
import java.io.IOException;
import static org.junit.Assert.*;
public class LanPolicyTests {
@Test public void vaultRejectsEveryExternalOrDnsAddress()throws Exception {
for(String url:new String[]{"https://argus.kusoft.xyz/qsafe/","https://8.8.8.8/","https://127.0.0.1/","https://169.254.1.1/","https://[::1]/","https://192.168.0.185.evil.test/","http://192.168.0.185/","https://user@192.168.0.185/","https://192.168.0.185/?x=1"}) {
try{LanPolicy.url(url);fail(url);}catch(IOException expected){}
}
assertEquals("https://192.168.0.185:18443/qsafe/",LanPolicy.url("https://192.168.0.185:18443/qsafe"));
assertEquals("https://10.1.2.3/",LanPolicy.url("https://10.1.2.3"));
assertEquals("https://172.16.1.2/",LanPolicy.url("https://172.16.1.2"));
}
@Test public void requiresHomeSsidAndPinnedCertificate()throws Exception {
LanPolicy.identity("gen","a".repeat(64));
for(String[] pair:new String[][]{{"","a".repeat(64)},{"gen",""},{"gen","not-a-hash"}})try{LanPolicy.identity(pair[0],pair[1]);fail();}catch(IOException expected){}
}
@Test public void piMustBeOnLinkAndCannotBeLocalNetworkOrBroadcast()throws Exception {
byte[] local=LanPolicy.ipv4("192.168.0.61");
assertTrue(LanPolicy.sameSubnet(LanPolicy.ipv4("192.168.0.185"),local,24));
for(String target:new String[]{"192.168.1.185","192.168.0.0","192.168.0.255","192.168.0.61"})assertFalse(LanPolicy.sameSubnet(LanPolicy.ipv4(target),local,24));
assertFalse(LanPolicy.sameSubnet(LanPolicy.ipv4("192.168.0.185"),local,8));
}
@Test public void argusUpdatesRemainIndependentOfPrivateVaultPolicy() {
assertEquals("https://argus.kusoft.xyz",UpdateManifest.ORIGIN);
}
}
@@ -0,0 +1,61 @@
package ru.kusoft.qsafe;
import org.junit.Test;
import static org.junit.Assert.*;
public class PasswordGeneratorTests {
@Test public void complexPasswordsRespectSymbolsAndLength() {
for(int length:new int[]{16,24,64,128})for(int i=0;i<50;i++) {
String value=PasswordGenerator.generate(PasswordGenerator.Mode.COMPLEX,length,true);
assertEquals(length,value.length());assertTrue(value.matches(".*[A-Z].*"));assertTrue(value.matches(".*[a-z].*"));
assertTrue(value.matches(".*[2-9].*"));assertTrue(value.matches(".*[!@#$%&*+\\-=?].*"));
assertTrue(PasswordGenerator.generate(PasswordGenerator.Mode.COMPLEX,length,false).matches("[A-Za-z2-9]{"+length+"}"));
}
}
@Test public void numericCodesKeepExactLengthAndUseOnlyDigits() {
for(int length:new int[]{4,12,64,128})for(int i=0;i<50;i++) {
String value=PasswordGenerator.generate(PasswordGenerator.Mode.DIGITS,length,true);
assertEquals(length,value.length());assertTrue(value.matches("[0-9]+"));
}
}
@Test public void memorablePasswordsHaveRequestedWordCount() {
for(int count:new int[]{2,3,8,12})for(boolean symbols:new boolean[]{true,false})for(boolean digits:new boolean[]{true,false})for(int i=0;i<50;i++) {
String value=PasswordGenerator.generate(PasswordGenerator.Mode.MEMORABLE,count,symbols,digits);
java.util.regex.Matcher words=java.util.regex.Pattern.compile("[A-Z]?[a-z]+").matcher(value);
int actual=0;while(words.find())actual++;
assertEquals(count,actual);assertEquals(digits,value.matches(".*[0-9].*"));
assertEquals(symbols,value.matches(".*[!@#$%&*+\\-=?].*"));
assertTrue(value.matches("[A-Za-z0-9!@#$%&*+\\-=?]+"));
String gap="[0-9!@#$%&*+\\-=?]*";
assertTrue("Words must stay whole",value.matches(gap+"[A-Z][a-z]+(?:"+gap+"[A-Z][a-z]+){"+(count-1)+"}"+gap));
if(digits)assertEquals(2,value.replaceAll("[^0-9]","").length());
}
}
@Test public void digitsAndSignsCanOccurAtEveryWordBoundary() {
boolean[] digitPositions=new boolean[3],signPositions=new boolean[3];
boolean signBeforeDigit=false,signAfterDigit=false;
for(int i=0;i<1000;i++) {
String value=PasswordGenerator.generate(PasswordGenerator.Mode.MEMORABLE,2,true);
java.util.regex.Matcher words=java.util.regex.Pattern.compile("[A-Z][a-z]+").matcher(value);
assertTrue(words.find());int firstStart=words.start(),firstEnd=words.end();
assertTrue(words.find());int secondStart=words.start(),secondEnd=words.end();assertFalse(words.find());
String[] boundaries={value.substring(0,firstStart),value.substring(firstEnd,secondStart),value.substring(secondEnd)};
for(int position=0;position<3;position++) {
digitPositions[position]|=boundaries[position].matches(".*[0-9].*");
signPositions[position]|=boundaries[position].matches(".*[!@#$%&*+\\-=?].*");
}
int sign=-1;for(int j=0;j<value.length();j++)if("!@#$%&*+-=?".indexOf(value.charAt(j))>=0)sign=j;
for(int j=0;j<value.length();j++)if(Character.isDigit(value.charAt(j))){signBeforeDigit|=sign<j;signAfterDigit|=sign>j;}
}
for(int i=0;i<3;i++){assertTrue(digitPositions[i]);assertTrue(signPositions[i]);}
assertTrue(signBeforeDigit);assertTrue(signAfterDigit);
}
@Test public void invalidLengthsAreRejected() {
rejects(PasswordGenerator.Mode.COMPLEX,15);rejects(PasswordGenerator.Mode.COMPLEX,129);
rejects(PasswordGenerator.Mode.DIGITS,3);rejects(PasswordGenerator.Mode.DIGITS,129);
rejects(PasswordGenerator.Mode.MEMORABLE,1);rejects(PasswordGenerator.Mode.MEMORABLE,13);
}
private void rejects(PasswordGenerator.Mode mode,int length) {
try{PasswordGenerator.generate(mode,length,true);fail("Invalid length accepted");}catch(IllegalArgumentException expected){ }
}
}
@@ -0,0 +1,54 @@
package ru.kusoft.qsafe;
import org.json.*;
import org.junit.Test;
import java.io.IOException;
import static org.junit.Assert.*;
public class UpdateAndTemplateTests {
private JSONObject manifest()throws Exception {
return new JSONObject().put("slug","qsafe").put("release",new JSONObject()
.put("version","1.0.1").put("androidVersionCode",4).put("platform","android").put("channel","stable").put("packageKind","apk")
.put("packageSizeBytes",200000).put("sha256","a".repeat(64))
.put("downloadPath","/api/apps/qsafe/releases/3d149d9b-16fb-4935-8537-b447fce96aeb/download"));
}
@Test public void upgradeUsesNumericCodeAndRejectsDowngrades()throws Exception {
UpdateManifest update=UpdateManifest.parse(manifest());assertTrue(update.newerThan(3));assertFalse(update.newerThan(4));assertFalse(update.newerThan(5));
}
@Test public void manifestCannotRedirectDownloadsOrChooseAnotherApp()throws Exception {
for(String path:new String[]{"https://other.example/update.apk","//other.example/update.apk","/api/apps/another/releases/3d149d9b-16fb-4935-8537-b447fce96aeb/download","/api/apps/qsafe/releases/../../private/download"}) {
JSONObject m=manifest();m.getJSONObject("release").put("downloadPath",path);
try{UpdateManifest.parse(m);fail("Untrusted download location accepted");}catch(IOException expected) { }
}
JSONObject wrong=manifest().put("slug","other");try{UpdateManifest.parse(wrong);fail();}catch(IOException expected) { }
}
@Test public void invalidUpdateSizeHashAndPlatformAreRejected()throws Exception {
Object[][] changes={{"packageSizeBytes",-1},{"packageSizeBytes",UpdateManifest.MAX_APK_BYTES+1L},{"sha256","invalid"},{"platform","windows"},{"channel","preview"},{"androidVersionCode",0}};
for(Object[] change:changes){JSONObject m=manifest();m.getJSONObject("release").put((String)change[0],change[1]);try{UpdateManifest.parse(m);fail("Invalid update accepted");}catch(IOException expected) { }}
}
@Test public void templatesKeepSensitiveDataOutOfCardPreviews()throws Exception {
JSONObject bank=CardTemplates.create("bank");bank.getJSONArray("fields").getJSONObject(0).put("value","SYNTHETIC_CARD_SECRET");
bank.getJSONArray("fields").getJSONObject(3).put("value","SYNTHETIC_CVV");
assertTrue(Vault.secret(bank.getJSONArray("fields").getJSONObject(0)));assertTrue(Vault.secret(bank.getJSONArray("fields").getJSONObject(3)));
assertFalse(CardTemplates.subtitle(bank).contains("SYNTHETIC"));assertEquals("bank",CardTemplates.kind(bank));
JSONObject note=CardTemplates.create("note").put("notes","SYNTHETIC_NOTE_SECRET");assertEquals(0,note.getJSONArray("fields").length());assertFalse(CardTemplates.subtitle(note).contains("SECRET"));
}
@Test public void templateEditsAndMetadataSurviveEncryptionAndMerge()throws Exception {
JSONObject base=Vault.empty(),bank=CardTemplates.create("bank").put("title","Example bank");Vault.update(base,bank);
JSONObject local=Vault.copy(base),remote=Vault.copy(base);Vault.update(local,Vault.copy(bank).put("notes","local note"));
JSONObject second=CardTemplates.create("wifi").put("title","Example Wi-Fi");Vault.update(remote,second);
Vault.Merge merged=Vault.merge(base,local,remote);assertEquals(0,merged.conflicts);assertEquals(2,Vault.visible(merged.vault,"",false).size());
try(VaultCrypto.Key key=VaultCrypto.create("synthetic template password!".toCharArray())) {
JSONObject decoded=Vault.parse(VaultCrypto.decrypt(VaultCrypto.encrypt(Vault.bytes(merged.vault),key),key));
assertEquals("bank",decoded.getJSONArray("entries").getJSONObject(0).getString("kind"));assertEquals("local note",decoded.getJSONArray("entries").getJSONObject(0).getString("notes"));
}
}
@Test public void savingAnOpenFormCannotOverwriteANewerSyncedRevision()throws Exception {
JSONObject data=Vault.empty(),entry=CardTemplates.create("login").put("title","Example");Vault.update(data,entry);
JSONObject draft=Vault.copy(entry).put("notes","form edit");Vault.update(data,Vault.copy(entry).put("notes","newer remote edit"));
JSONObject saved=Vault.applyEdit(data,draft);assertNotEquals(entry.getString("id"),saved.getString("id"));assertEquals(2,Vault.visible(data,"",false).size());
assertEquals("newer remote edit",data.getJSONArray("entries").getJSONObject(0).getString("notes"));
try{Vault.applyEdit(data,Vault.copy(entry).put("deleted",true));fail();}catch(JSONException expected) { }
assertEquals(2,Vault.visible(data,"",false).size());
}
}
@@ -0,0 +1,71 @@
package ru.kusoft.qsafe;
import org.json.*;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.*;
import static org.junit.Assert.*;
public class VaultTests {
@Test public void authenticatedEncryptionAndWrongPassword() throws Exception {
byte[] plain="Секретный пароль".getBytes(StandardCharsets.UTF_8);
try(VaultCrypto.Key key=VaultCrypto.create("correct horse battery staple".toCharArray())) {
byte[] a=VaultCrypto.encrypt(plain,key),b=VaultCrypto.encrypt(plain,key);
assertFalse(Arrays.equals(a,b));assertArrayEquals(plain,VaultCrypto.decrypt(a,key));
try(VaultCrypto.Key unlocked=VaultCrypto.unlock(a,"correct horse battery staple".toCharArray())) {assertArrayEquals(plain,VaultCrypto.decrypt(a,unlocked));}
try {VaultCrypto.unlock(a,"wrong password".toCharArray());fail();}catch(GeneralSecurityException expected) { }
a[a.length-1]^=1;
try {VaultCrypto.decrypt(a,key);fail();}catch(GeneralSecurityException expected) { }
}
}
@Test public void boundsAreCheckedBeforeDerivation() throws Exception {
try {VaultCrypto.validate(new byte[8]);fail();}catch(IllegalArgumentException expected) { }
try(VaultCrypto.Key k=VaultCrypto.create("long test password".toCharArray())) {
byte[] blob=VaultCrypto.encrypt(new byte[]{1},k);Arrays.fill(blob,8,12,(byte)127);
try {VaultCrypto.unlock(blob,"test".toCharArray());fail();}catch(IllegalArgumentException expected) { }
}
}
@Test public void generatedPasswordsHaveEveryCharacterGroup() {
for(int i=0;i<100;i++) {
String p=VaultCrypto.generate(24);assertEquals(24,p.length());
assertTrue(p.matches(".*[A-Z].*"));assertTrue(p.matches(".*[a-z].*"));assertTrue(p.matches(".*[2-9].*"));assertTrue(p.matches(".*[!@#$%&*+\\-=?].*"));
}
}
private byte[] xml(String cards) {return ("<?xml version=\"1.0\" encoding=\"UTF-8\"?><database><label id=\"2\" name=\"Работа\"/>"+cards+"</database>").getBytes(StandardCharsets.UTF_8);}
@Test public void migrationRetainsFieldsNotesTagsAndAttachmentsAndDeduplicates() throws Exception {
byte[] data=xml("<card id=\"7\" title=\"Почта\" star=\"true\"><field name=\"Логин\" type=\"login\">me@example.test</field><field name=\"Пароль\" type=\"password\">a&amp;b</field><field name=\"Код\" type=\"pin\">1234</field><notes>Привет\nмир</notes><label_id>2</label_id><file name=\"a.bin\">YWJj</file><history><old>value</old></history></card><card title=\"Удалено\" deleted=\"true\"/><card title=\"Шаблон\" template=\"true\"/>");
SafeInCloudImport.Preview p=SafeInCloudImport.preview(data,Vault.empty());
assertEquals(1,p.added);assertEquals(2,p.skipped);assertEquals(1,p.attachments);
JSONObject e=p.vault.getJSONArray("entries").getJSONObject(0);
assertEquals("a&b",e.getJSONArray("fields").getJSONObject(1).getString("value"));
assertEquals("Работа",e.getJSONArray("tags").getString(0));assertEquals("Привет\nмир",e.getString("notes"));
assertTrue(e.getString("safeInCloudXml").contains("<history>"));assertTrue(e.getBoolean("star"));
SafeInCloudImport.Preview again=SafeInCloudImport.preview(data,p.vault);assertEquals(0,again.added);assertEquals(1,again.duplicates);
}
@Test public void migrationRejectsXxeAndForeignFormats() throws Exception {
for(String data:new String[]{"<!DOCTYPE database [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><database><card title='x'><notes>&x;</notes></card></database>","<not-a-vault/>"}) {
try {SafeInCloudImport.preview(data.getBytes(StandardCharsets.UTF_8),Vault.empty());fail();}catch(Exception expected) { }
}
}
@Test public void concurrentChangesArePreservedAndIndependentChangesCombine() throws Exception {
JSONObject base=Vault.empty();JSONObject a=Vault.entry("Mail"),b=Vault.entry("Bank");Vault.update(base,a);Vault.update(base,b);
JSONObject local=Vault.copy(base),remote=Vault.copy(base);
Vault.update(local,Vault.copy(a).put("notes","local"));Vault.update(remote,Vault.copy(b).put("notes","remote"));
Vault.Merge independent=Vault.merge(base,local,remote);assertEquals(0,independent.conflicts);assertEquals(2,Vault.visible(independent.vault,"",false).size());
Vault.update(remote,Vault.copy(a).put("notes","also changed"));Vault.Merge conflict=Vault.merge(base,local,remote);
assertEquals(1,conflict.conflicts);assertEquals(3,Vault.visible(conflict.vault,"",false).size());
Set<String> notes=new HashSet<>();for(JSONObject e:Vault.visible(conflict.vault,"",false)) notes.add(e.optString("notes"));
assertTrue(notes.contains("local"));assertTrue(notes.contains("also changed"));
}
@Test public void deleteEditConflictRetainsEditedCard() throws Exception {
JSONObject base=Vault.empty(),entry=Vault.entry("Keep");Vault.update(base,entry);
JSONObject local=Vault.copy(base),remote=Vault.copy(base);
Vault.update(local,Vault.copy(entry).put("deleted",true));Vault.update(remote,Vault.copy(entry).put("notes","must survive"));
Vault.Merge m=Vault.merge(base,local,remote);assertEquals(1,m.conflicts);assertEquals("must survive",Vault.visible(m.vault,"",false).get(0).getString("notes"));
assertEquals(1,Vault.visible(m.vault,"",false).size());
}
@Test public void differentVaultsCannotMerge() throws Exception {
try {Vault.merge(null,Vault.empty(),Vault.empty());fail();}catch(JSONException expected) { }
}
}