Initial commit: QSafe Android 1.1.0 and Windows 1.0.4
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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")&¤tDetail!=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>
|
||||
@@ -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>
|
||||
@@ -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&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) { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user