mirror of
https://github.com/Kunzisoft/KeePassDX.git
synced 2025-12-04 15:49:33 +01:00
Compare commits
463 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ba058515 | ||
|
|
63fbca8029 | ||
|
|
b37966f79c | ||
|
|
bac5b0de5b | ||
|
|
5dac161553 | ||
|
|
528c167a88 | ||
|
|
cfe01aa996 | ||
|
|
0f53c975cc | ||
|
|
b7b99c77c8 | ||
|
|
1eea5412a5 | ||
|
|
4240465930 | ||
|
|
3dfbf7d2ad | ||
|
|
440490a4bb | ||
|
|
746382811b | ||
|
|
967a54dd50 | ||
|
|
289d9a2531 | ||
|
|
c56b4964fe | ||
|
|
79dbb942f9 | ||
|
|
a5bb5635d3 | ||
|
|
3efe43c0fe | ||
|
|
5fb5299d34 | ||
|
|
b988882251 | ||
|
|
85e82e3fb9 | ||
|
|
35cfe261d2 | ||
|
|
fe4faf9ebc | ||
|
|
751392d656 | ||
|
|
2e5ce5e94f | ||
|
|
535eeb2594 | ||
|
|
8f5e0e93ee | ||
|
|
6f17c5dcac | ||
|
|
4e7c7ba8ce | ||
|
|
6bd5b2345c | ||
|
|
63832ef8fd | ||
|
|
3719bf3593 | ||
|
|
7bca41ca72 | ||
|
|
3f6a9c3af5 | ||
|
|
309380bdd5 | ||
|
|
f135bdb905 | ||
|
|
2b926fd157 | ||
|
|
e911eea69c | ||
|
|
9d182b8299 | ||
|
|
61366e000f | ||
|
|
21cf49f4f8 | ||
|
|
30f0de83d3 | ||
|
|
42278a4b66 | ||
|
|
8752f92cea | ||
|
|
064c468e62 | ||
|
|
ef78fb749c | ||
|
|
a5d1db392b | ||
|
|
6234fc2ca3 | ||
|
|
c9cf90cdc9 | ||
|
|
5b033975b6 | ||
|
|
5663a153f7 | ||
|
|
4b73c45e65 | ||
|
|
ac248d8b73 | ||
|
|
726b0d0fa3 | ||
|
|
2d40164549 | ||
|
|
5203152f78 | ||
|
|
b064bb74cd | ||
|
|
843d8e8e77 | ||
|
|
c5f95b243d | ||
|
|
06f1f4c8ad | ||
|
|
9847f834c2 | ||
|
|
b744d58e6c | ||
|
|
c95358b344 | ||
|
|
2f15d6c9f2 | ||
|
|
d13aa047d5 | ||
|
|
7590d18c67 | ||
|
|
a689116c97 | ||
|
|
3bf7459f05 | ||
|
|
c70faaedd1 | ||
|
|
cb2417fbe4 | ||
|
|
65dd996f2e | ||
|
|
01790a6f31 | ||
|
|
8b84bb893d | ||
|
|
8cb99847c5 | ||
|
|
b3e01277d4 | ||
|
|
b10d407659 | ||
|
|
b7c5a5d238 | ||
|
|
90d1ce63e8 | ||
|
|
9e30b4e5f7 | ||
|
|
e541a8c629 | ||
|
|
0afe25c922 | ||
|
|
bca133430f | ||
|
|
9c925518a7 | ||
|
|
18e79b99e7 | ||
|
|
4e02846df9 | ||
|
|
2268b78bba | ||
|
|
ea8acd0677 | ||
|
|
9e931dd03f | ||
|
|
19e3aabca4 | ||
|
|
ae697d82d5 | ||
|
|
33382273c3 | ||
|
|
7d8466d77a | ||
|
|
5ca08a00d2 | ||
|
|
df3942697e | ||
|
|
5700ca5bcf | ||
|
|
54b2419d64 | ||
|
|
d8b1c94b78 | ||
|
|
2d1ffc23b9 | ||
|
|
e6b33d60c3 | ||
|
|
3fd06890d7 | ||
|
|
4af4ad7663 | ||
|
|
6ca8501e28 | ||
|
|
432b385f60 | ||
|
|
6cebdefa4a | ||
|
|
bc665eb83d | ||
|
|
cb187300fe | ||
|
|
da761614bd | ||
|
|
f34e007ecd | ||
|
|
3b6ad080b4 | ||
|
|
9919e90ba5 | ||
|
|
f4af44925b | ||
|
|
4bb366b568 | ||
|
|
7e7ab4ce19 | ||
|
|
4d833d25ce | ||
|
|
a9c508ecd9 | ||
|
|
ef4dbb8fdb | ||
|
|
9eb66face5 | ||
|
|
3fd13f3e3b | ||
|
|
319c9cad4b | ||
|
|
c12297c98d | ||
|
|
7c38361844 | ||
|
|
559554a975 | ||
|
|
7e2ffa2124 | ||
|
|
66dbac4bb2 | ||
|
|
8b6a843a85 | ||
|
|
976cff2751 | ||
|
|
f7c30fa8eb | ||
|
|
7757c8218b | ||
|
|
2928b7daa3 | ||
|
|
3a55dea276 | ||
|
|
2a25213d66 | ||
|
|
035ffd8135 | ||
|
|
b040487f1f | ||
|
|
6fc821aecf | ||
|
|
cdceb1fb6f | ||
|
|
07d185913d | ||
|
|
f2a245a9c8 | ||
|
|
33338f4759 | ||
|
|
f7a4370b29 | ||
|
|
77b7afedda | ||
|
|
caa13039e5 | ||
|
|
02845d93ed | ||
|
|
9ef4695cc7 | ||
|
|
d619e089c0 | ||
|
|
3c50348a79 | ||
|
|
167ea3b82b | ||
|
|
9eda3e62f7 | ||
|
|
99c4319b51 | ||
|
|
790b25db65 | ||
|
|
97d4972f9a | ||
|
|
8e6853756f | ||
|
|
6d3aae187b | ||
|
|
b8c7acf7ce | ||
|
|
17a356ae76 | ||
|
|
bd847e632d | ||
|
|
2bfb9b048d | ||
|
|
dc40b50b65 | ||
|
|
3e2271e596 | ||
|
|
4b4fd2a11d | ||
|
|
23468290df | ||
|
|
a276f6aa06 | ||
|
|
f2a58361a1 | ||
|
|
271023b528 | ||
|
|
e1771ca249 | ||
|
|
ca4f4bd151 | ||
|
|
d81454d618 | ||
|
|
fb43c1c624 | ||
|
|
9e060f878d | ||
|
|
bd9c21ee8a | ||
|
|
3e6d40e8da | ||
|
|
79683cb3fc | ||
|
|
52a2090a31 | ||
|
|
3dfe4ace7b | ||
|
|
bd0d17b134 | ||
|
|
6b0ccc1780 | ||
|
|
d75ac4b825 | ||
|
|
b60d610d02 | ||
|
|
f7a5c5d0ea | ||
|
|
28f79aec11 | ||
|
|
778d963fbf | ||
|
|
a765bc84e7 | ||
|
|
804ecc1baa | ||
|
|
d331c3dc03 | ||
|
|
7010d2f86a | ||
|
|
b1d6117eb2 | ||
|
|
f3b814388d | ||
|
|
b62996a57c | ||
|
|
a49e056f02 | ||
|
|
a6dece16bf | ||
|
|
8e3ddd64d2 | ||
|
|
45a847fa3e | ||
|
|
6b6f03b143 | ||
|
|
5446efca4a | ||
|
|
8d04a7f90b | ||
|
|
626495c19e | ||
|
|
e5a198f524 | ||
|
|
161524843f | ||
|
|
5550e7dea3 | ||
|
|
64f66c290c | ||
|
|
e8925b3c0b | ||
|
|
cf67ce04a8 | ||
|
|
84ee4ca2c7 | ||
|
|
27eb095720 | ||
|
|
d273f21819 | ||
|
|
455fd0cd6d | ||
|
|
c5a8650c81 | ||
|
|
b5f9bbed5e | ||
|
|
45da17adb8 | ||
|
|
e789741090 | ||
|
|
5c6d93bc57 | ||
|
|
697b672038 | ||
|
|
2d9e9c24a8 | ||
|
|
5fb281c800 | ||
|
|
96896c1c42 | ||
|
|
d7052bd9e6 | ||
|
|
8b23932788 | ||
|
|
50912c6966 | ||
|
|
53b51934b9 | ||
|
|
a8a3685965 | ||
|
|
149b67e28b | ||
|
|
a83032bffa | ||
|
|
a5d3a153bf | ||
|
|
4210c155eb | ||
|
|
4bf110a9b1 | ||
|
|
50f2684500 | ||
|
|
e95424b8f9 | ||
|
|
8462882707 | ||
|
|
a5c8d25f64 | ||
|
|
689ce2f9b3 | ||
|
|
54246533ac | ||
|
|
66e4b0fe47 | ||
|
|
3e8ae3e2e3 | ||
|
|
d856ef3772 | ||
|
|
5727880ac7 | ||
|
|
ec4302a780 | ||
|
|
d4203598a1 | ||
|
|
a278c8c718 | ||
|
|
faf5f4b51a | ||
|
|
b2f503b326 | ||
|
|
beb5484bf6 | ||
|
|
ec63d75349 | ||
|
|
4c0e79b245 | ||
|
|
50a77684c1 | ||
|
|
8bb84b486d | ||
|
|
4b05f2536f | ||
|
|
d6f968fe7e | ||
|
|
ed758edd44 | ||
|
|
94b7fce2e5 | ||
|
|
dbd9c6cbb7 | ||
|
|
0f6376fb80 | ||
|
|
9522328238 | ||
|
|
e6ad716119 | ||
|
|
440006bb08 | ||
|
|
ea289ef7cf | ||
|
|
352b171484 | ||
|
|
969ab56bf8 | ||
|
|
062a9852e5 | ||
|
|
dd77d7a5e6 | ||
|
|
f60e32522a | ||
|
|
070a91f19c | ||
|
|
0790e80670 | ||
|
|
20841e3d7b | ||
|
|
0caae233c3 | ||
|
|
5497d8fafb | ||
|
|
f69b43249c | ||
|
|
b606fd98f6 | ||
|
|
ba3b7b0f1f | ||
|
|
058d82dc36 | ||
|
|
6f0b0ac4fa | ||
|
|
35f87b0f94 | ||
|
|
0ead9ce9b4 | ||
|
|
80479a6a7c | ||
|
|
a7cea8201e | ||
|
|
081a7fa798 | ||
|
|
85782c4f93 | ||
|
|
d7b7df26d7 | ||
|
|
b6b1c8e31d | ||
|
|
17156f7ca2 | ||
|
|
0761d356b8 | ||
|
|
6da747ce6f | ||
|
|
87b1a1f527 | ||
|
|
72a8a55faf | ||
|
|
9a6a709746 | ||
|
|
428b53cc56 | ||
|
|
e688859e32 | ||
|
|
98336da116 | ||
|
|
c037e443b0 | ||
|
|
d339a50e0a | ||
|
|
7d836f2633 | ||
|
|
45d8470b4c | ||
|
|
1ca3bfe472 | ||
|
|
066da83d70 | ||
|
|
44ab881751 | ||
|
|
5ab3cf985a | ||
|
|
f271f2b181 | ||
|
|
91d75be0ea | ||
|
|
774dddca54 | ||
|
|
e18b3436c9 | ||
|
|
fcb1b5ae6b | ||
|
|
de980d030a | ||
|
|
0e859646fe | ||
|
|
059c7b7713 | ||
|
|
5fb7bf71c8 | ||
|
|
8b0133ff7f | ||
|
|
8d834946b8 | ||
|
|
2f646395d4 | ||
|
|
f6e79ba37b | ||
|
|
e633c7a861 | ||
|
|
dc02a8d78c | ||
|
|
baa9b88512 | ||
|
|
c522e87da8 | ||
|
|
ef5ebf2c15 | ||
|
|
4b147e770c | ||
|
|
157a5c0b05 | ||
|
|
f2288b0c64 | ||
|
|
d8506450aa | ||
|
|
f9b085e73f | ||
|
|
388cf6a91b | ||
|
|
9e6e77b363 | ||
|
|
ec33ca8173 | ||
|
|
6be0457947 | ||
|
|
f3b84aa845 | ||
|
|
bd0b5b0954 | ||
|
|
7dc93604ad | ||
|
|
0ab22698a6 | ||
|
|
c885ce7aaf | ||
|
|
92d1a7b901 | ||
|
|
6119054b45 | ||
|
|
e7aed72398 | ||
|
|
cee7fa50f5 | ||
|
|
39a38bb223 | ||
|
|
7159a993db | ||
|
|
23933e80e3 | ||
|
|
abc971b5cc | ||
|
|
7dedcc8a21 | ||
|
|
10d46e5dee | ||
|
|
139f7eb36d | ||
|
|
1ddfa894b6 | ||
|
|
d1695ab8c2 | ||
|
|
f27979e729 | ||
|
|
6e61e8172a | ||
|
|
21890894ae | ||
|
|
1feecd559d | ||
|
|
6ea4afe75b | ||
|
|
fd96f6367d | ||
|
|
8ce183c4c9 | ||
|
|
407a1db101 | ||
|
|
622d096e31 | ||
|
|
bf27fb1f89 | ||
|
|
860b9055c5 | ||
|
|
b3ae3a4148 | ||
|
|
0abd7d5762 | ||
|
|
0aac2bc55b | ||
|
|
fa08dc5cfb | ||
|
|
8d18970b4c | ||
|
|
173f5ce979 | ||
|
|
2e7088310a | ||
|
|
c75d99030c | ||
|
|
e4ba1d9bae | ||
|
|
e2886c342a | ||
|
|
e600d8a56c | ||
|
|
caeb305475 | ||
|
|
3d3a9d9bad | ||
|
|
5499ad5b94 | ||
|
|
0e29cd0cee | ||
|
|
24fb1b1a8f | ||
|
|
03fb4cbf0c | ||
|
|
e909280d5b | ||
|
|
d41ddf60b4 | ||
|
|
1e01a74986 | ||
|
|
96a007aace | ||
|
|
9f23bb6129 | ||
|
|
b7e8559773 | ||
|
|
5b247575c8 | ||
|
|
eb0e5b478f | ||
|
|
08906ae1da | ||
|
|
395a5efecd | ||
|
|
0452dd14f6 | ||
|
|
3906df314d | ||
|
|
ce49aa2ebd | ||
|
|
f2cb062b1e | ||
|
|
f25819a940 | ||
|
|
3075a9f9f4 | ||
|
|
52f1a672c8 | ||
|
|
45785fde1c | ||
|
|
6a7649e1d7 | ||
|
|
8b3831eb2b | ||
|
|
73e7f4669c | ||
|
|
c9f7bbbd25 | ||
|
|
ee67238133 | ||
|
|
b425da8d0f | ||
|
|
754a7f70bc | ||
|
|
a857ffa987 | ||
|
|
391ce2ebba | ||
|
|
086723adf4 | ||
|
|
e993279c35 | ||
|
|
aa64310875 | ||
|
|
795baf2c01 | ||
|
|
68ac453100 | ||
|
|
79d1f512e5 | ||
|
|
e739211314 | ||
|
|
d3f6374bb4 | ||
|
|
5add632cbc | ||
|
|
d210d1bcce | ||
|
|
6d6422cd63 | ||
|
|
66e8b7702b | ||
|
|
b75502ad87 | ||
|
|
3fba96d11f | ||
|
|
58d10672ea | ||
|
|
3571905705 | ||
|
|
acf0e2a1cb | ||
|
|
9e7dcb0d7c | ||
|
|
3c261e3cf7 | ||
|
|
b6f324f399 | ||
|
|
f2459489fa | ||
|
|
f8691cf285 | ||
|
|
2e631d3c42 | ||
|
|
1044dca936 | ||
|
|
56c3f495d5 | ||
|
|
0f3036dd9c | ||
|
|
af445ef157 | ||
|
|
25eb09f11c | ||
|
|
16f255aeca | ||
|
|
d0b340837d | ||
|
|
893828ac44 | ||
|
|
a3ca03636a | ||
|
|
582ffe3f23 | ||
|
|
3caad2cceb | ||
|
|
618dcf014d | ||
|
|
d88e20bb56 | ||
|
|
8a8b2b027e | ||
|
|
41cb223099 | ||
|
|
b93ea5e662 | ||
|
|
31c35939fd | ||
|
|
20a35f4221 | ||
|
|
bc6aeb2e93 | ||
|
|
a561299809 | ||
|
|
76efb938ab | ||
|
|
56abf73eaf | ||
|
|
2bc068d65a | ||
|
|
f191259f37 | ||
|
|
1384c6661d | ||
|
|
c047621548 | ||
|
|
017aaf2e54 | ||
|
|
e4b2b930af | ||
|
|
2646c0f0ee | ||
|
|
4afadb779c | ||
|
|
ad6e4daa22 | ||
|
|
d5cd07fe76 | ||
|
|
364065ed51 | ||
|
|
b4f05d4da7 | ||
|
|
3d12a0e8e9 | ||
|
|
e29f3194f3 | ||
|
|
6bac86638b | ||
|
|
d6ee1cdf6e | ||
|
|
e9d0efaf93 | ||
|
|
85467fa15b | ||
|
|
84bb47aa53 | ||
|
|
75f245c7dc | ||
|
|
6c5be88432 | ||
|
|
590b22de69 |
64
CHANGELOG
64
CHANGELOG
@@ -1,3 +1,67 @@
|
||||
KeePassDX(2.9.13)
|
||||
* Binary image viewer #473 #749
|
||||
* Fix TOTP plugin settings #878
|
||||
* Allow Emoji #796
|
||||
* Scroll and better UI in entry edition screen #876
|
||||
* Better UI #876
|
||||
* Fix themes and add Purple Dark #889
|
||||
* Allow OTP with many padding #585
|
||||
* Add notes in groups #734
|
||||
|
||||
KeePassDX(2.9.12)
|
||||
* Fix OTP token type #863
|
||||
* Fix auto open biometric prompt #862
|
||||
* Fix back appearance setting #865
|
||||
* Fix orientation change in settings #872
|
||||
* Change memory unit to MiB #851
|
||||
* Small changes #642
|
||||
|
||||
KeePassDX(2.9.11)
|
||||
* Add Keyfile XML version 2 (fix hex) #844
|
||||
* Fix hex Keyfile #861
|
||||
|
||||
KeePassDX(2.9.10)
|
||||
* Try to fix autofill #852
|
||||
* Fix database change dialog displayed too often #853
|
||||
|
||||
KeePassDX(2.9.9)
|
||||
* Detect file changes and reload database #794
|
||||
* Inline suggestions autofill with compatible keyboard (Android R) #827
|
||||
* Add Keyfile XML version 2 #844
|
||||
* Fix binaries of 64 bytes #835
|
||||
* Special search in title fields #830
|
||||
* Priority to OTP button in notifications #845
|
||||
* Fix OTP generation for long secret key #848
|
||||
* Fix small bugs #849
|
||||
|
||||
KeePassDX(2.9.8)
|
||||
* Fix specific attachments with kdbx3.1 databases #828
|
||||
* Fix small bugs
|
||||
|
||||
KeePassDX(2.9.7)
|
||||
* Remove write permission since Android 10 #823
|
||||
* Fix small bugs
|
||||
|
||||
KeePassDX(2.9.6)
|
||||
* Fix KeyFile bug #820
|
||||
|
||||
KeePassDX(2.9.5)
|
||||
* Unlock database by device credentials (PIN/Password/Pattern) with Android M+ #102 #152 #811
|
||||
* Prevent auto switch back to previous keyboard if otp field exists #814
|
||||
* Fix timeout reset #817
|
||||
|
||||
KeePassDX(2.9.4)
|
||||
* Fix small bugs #812
|
||||
* Argon2ID implementation #791
|
||||
|
||||
KeePassDX(2.9.3)
|
||||
* Unlock database by device credentials (PIN/Password/Pattern) #779 #102
|
||||
* Advanced unlock with timeout #102 #437 #566
|
||||
* Remove default database parameter when the file is no longer accessible #803
|
||||
* Move OTP button to the first view level in Magikeyboard #587
|
||||
* Tooltips for Magikeyboard #586
|
||||
* Fix small bugs #805
|
||||
|
||||
KeePassDX(2.9.2)
|
||||
* Managing OTP links from QR applications #556
|
||||
* Prevent manual creation of existing field name #718
|
||||
|
||||
@@ -5,22 +5,22 @@ apply plugin: 'kotlin-kapt'
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
buildToolsVersion '30.0.2'
|
||||
buildToolsVersion '30.0.3'
|
||||
ndkVersion '21.3.6528147'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.kunzisoft.keepass"
|
||||
minSdkVersion 14
|
||||
targetSdkVersion 30
|
||||
versionCode = 46
|
||||
versionName = "2.9.2"
|
||||
versionCode = 57
|
||||
versionName = "2.9.13"
|
||||
multiDexEnabled true
|
||||
|
||||
testApplicationId = "com.kunzisoft.keepass.tests"
|
||||
testInstrumentationRunner = "android.test.InstrumentationTestRunner"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField "String[]", "ICON_PACKS", "{\"classic\",\"material\"}"
|
||||
manifestPlaceholders = [ googleAndroidBackupAPIKey:"" ]
|
||||
manifestPlaceholders = [ googleAndroidBackupAPIKey:"unused" ]
|
||||
|
||||
kapt {
|
||||
arguments {
|
||||
@@ -51,7 +51,7 @@ android {
|
||||
buildConfigField "String", "BUILD_VERSION", "\"libre\""
|
||||
buildConfigField "boolean", "FULL_VERSION", "true"
|
||||
buildConfigField "boolean", "CLOSED_STORE", "false"
|
||||
buildConfigField "String[]", "STYLES_DISABLED", "{\"KeepassDXStyle_Red\",\"KeepassDXStyle_Purple\"}"
|
||||
buildConfigField "String[]", "STYLES_DISABLED", "{\"KeepassDXStyle_Red\",\"KeepassDXStyle_Purple\",\"KeepassDXStyle_Purple_Dark\"}"
|
||||
buildConfigField "String[]", "ICON_PACKS_DISABLED", "{}"
|
||||
}
|
||||
pro {
|
||||
@@ -70,7 +70,7 @@ android {
|
||||
buildConfigField "String", "BUILD_VERSION", "\"free\""
|
||||
buildConfigField "boolean", "FULL_VERSION", "false"
|
||||
buildConfigField "boolean", "CLOSED_STORE", "true"
|
||||
buildConfigField "String[]", "STYLES_DISABLED", "{\"KeepassDXStyle_Blue\",\"KeepassDXStyle_Red\",\"KeepassDXStyle_Purple\"}"
|
||||
buildConfigField "String[]", "STYLES_DISABLED", "{\"KeepassDXStyle_Blue\",\"KeepassDXStyle_Red\",\"KeepassDXStyle_Purple\",\"KeepassDXStyle_Purple_Dark\"}"
|
||||
buildConfigField "String[]", "ICON_PACKS_DISABLED", "{}"
|
||||
manifestPlaceholders = [ googleAndroidBackupAPIKey:"AEdPqrEAAAAIbRfbV8fHLItXo8OcHwrO0sSNblqhPwkc0DPTqg" ]
|
||||
}
|
||||
@@ -82,6 +82,10 @@ android {
|
||||
free.res.srcDir 'src/free/res'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
@@ -92,7 +96,7 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
def room_version = "2.2.5"
|
||||
def room_version = "2.2.6"
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
@@ -110,6 +114,8 @@ dependencies {
|
||||
// Database
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
// Autofill
|
||||
implementation "androidx.autofill:autofill:1.1.0-rc01"
|
||||
// Crypto
|
||||
implementation 'org.bouncycastle:bcprov-jdk15on:1.65.01'
|
||||
// Time
|
||||
@@ -118,14 +124,15 @@ dependencies {
|
||||
implementation 'com.github.Kunzisoft:AndroidClearChroma:2.4'
|
||||
// Education
|
||||
implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.0'
|
||||
// Apache Commons Collections
|
||||
// Apache Commons
|
||||
implementation 'commons-collections:commons-collections:3.2.2'
|
||||
// Apache Commons Codec
|
||||
implementation 'commons-codec:commons-codec:1.14'
|
||||
implementation 'commons-io:commons-io:2.8.0'
|
||||
implementation 'commons-codec:commons-codec:1.15'
|
||||
// Icon pack
|
||||
implementation project(path: ':icon-pack-classic')
|
||||
implementation project(path: ':icon-pack-material')
|
||||
|
||||
// Tests
|
||||
androidTestImplementation 'junit:junit:4.13'
|
||||
androidTestImplementation 'androidx.test:runner:1.3.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.3.0'
|
||||
}
|
||||
|
||||
BIN
app/src/androidTest/assets/test_image.png
Normal file
BIN
app/src/androidTest/assets/test_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
133
app/src/androidTest/assets/test_text.txt
Normal file
133
app/src/androidTest/assets/test_text.txt
Normal file
@@ -0,0 +1,133 @@
|
||||
Basic Latin
|
||||
! " # $ % & ' ( ) * + , - . / 0 1 2 3 4 5 6 7 8 9 : ; < = > ? @ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \ ] ^ _ ` a b c d e f g h i j k l m n o p q r s t u v w x y z { | } ~
|
||||
Latin-1 Supplement
|
||||
¡ ¢ £ ¤ ¥ ¦ § ¨ © ª « ¬ ® ¯ ° ± ² ³ ´ µ ¶ · ¸ ¹ º » ¼ ½ ¾ ¿ À Á Â Ã Ä Å Æ Ç È É Ê Ë Ì Í Î Ï Ð Ñ Ò Ó Ô Õ Ö × Ø Ù Ú Û Ü Ý Þ ß à á â ã ä å æ ç è é ê ë ì í î ï ð ñ ò ó ô õ ö ÷ ø ù ú û ü ý þ ÿ
|
||||
Latin Extended-A
|
||||
Ā ā Ă ă Ą ą Ć ć Ĉ ĉ Ċ ċ Č č Ď ď Đ đ Ē ē Ĕ ĕ Ė ė Ę ę Ě ě Ĝ ĝ Ğ ğ Ġ ġ Ģ ģ Ĥ ĥ Ħ ħ Ĩ ĩ Ī ī Ĭ ĭ Į į İ ı IJ ij Ĵ ĵ Ķ ķ ĸ Ĺ ĺ Ļ ļ Ľ ľ Ŀ ŀ Ł ł Ń ń Ņ ņ Ň ň ʼn Ŋ ŋ Ō ō Ŏ ŏ Ő ő Œ œ Ŕ ŕ Ŗ ŗ Ř ř Ś ś Ŝ ŝ Ş ş Š š Ţ ţ Ť ť Ŧ ŧ Ũ ũ Ū ū Ŭ ŭ Ů ů Ű ű Ų ų Ŵ ŵ Ŷ ŷ Ÿ Ź ź Ż ż Ž ž ſ
|
||||
Latin Extended-B
|
||||
ƀ Ɓ Ƃ ƃ Ƅ ƅ Ɔ Ƈ ƈ Ɖ Ɗ Ƌ ƌ ƍ Ǝ Ə Ɛ Ƒ ƒ Ɠ Ɣ ƕ Ɩ Ɨ Ƙ ƙ ƚ ƛ Ɯ Ɲ ƞ Ɵ Ơ ơ Ƣ ƣ Ƥ ƥ Ʀ Ƨ ƨ Ʃ ƪ ƫ Ƭ ƭ Ʈ Ư ư Ʊ Ʋ Ƴ ƴ Ƶ ƶ Ʒ Ƹ ƹ ƺ ƻ Ƽ ƽ ƾ ƿ ǀ ǁ ǂ ǃ DŽ Dž dž LJ Lj lj NJ Nj nj Ǎ ǎ Ǐ ǐ Ǒ ǒ Ǔ ǔ Ǖ ǖ Ǘ ǘ Ǚ ǚ Ǜ ǜ ǝ Ǟ ǟ Ǡ ǡ Ǣ ǣ Ǥ ǥ Ǧ ǧ Ǩ ǩ Ǫ ǫ Ǭ ǭ Ǯ ǯ ǰ DZ Dz dz Ǵ ǵ Ǻ ǻ Ǽ ǽ Ǿ ǿ Ȁ ȁ Ȃ ȃ ...
|
||||
IPA Extensions
|
||||
ɐ ɑ ɒ ɓ ɔ ɕ ɖ ɗ ɘ ə ɚ ɛ ɜ ɝ ɞ ɟ ɠ ɡ ɢ ɣ ɤ ɥ ɦ ɧ ɨ ɩ ɪ ɫ ɬ ɭ ɮ ɯ ɰ ɱ ɲ ɳ ɴ ɵ ɶ ɷ ɸ ɹ ɺ ɻ ɼ ɽ ɾ ɿ ʀ ʁ ʂ ʃ ʄ ʅ ʆ ʇ ʈ ʉ ʊ ʋ ʌ ʍ ʎ ʏ ʐ ʑ ʒ ʓ ʔ ʕ ʖ ʗ ʘ ʙ ʚ ʛ ʜ ʝ ʞ ʟ ʠ ʡ ʢ ʣ ʤ ʥ ʦ ʧ ʨ
|
||||
Spacing Modifier Letters
|
||||
ʰ ʱ ʲ ʳ ʴ ʵ ʶ ʷ ʸ ʹ ʺ ʻ ʼ ʽ ʾ ʿ ˀ ˁ ˂ ˃ ˄ ˅ ˆ ˇ ˈ ˉ ˊ ˋ ˌ ˍ ˎ ˏ ː ˑ ˒ ˓ ˔ ˕ ˖ ˗ ˘ ˙ ˚ ˛ ˜ ˝ ˞ ˠ ˡ ˢ ˣ ˤ ˥ ˦ ˧ ˨ ˩
|
||||
Combining Diacritical Marks
|
||||
̀ ́ ̂ ̃ ̄ ̅ ̆ ̇ ̈ ̉ ̊ ̋ ̌ ̍ ̎ ̏ ̐ ̑ ̒ ̓ ̔ ̕ ̖ ̗ ̘ ̙ ̚ ̛ ̜ ̝ ̞ ̟ ̠ ̡ ̢ ̣ ̤ ̥ ̦ ̧ ̨ ̩ ̪ ̫ ̬ ̭ ̮ ̯ ̰ ̱ ̲ ̳ ̴ ̵ ̶ ̷ ̸ ̹ ̺ ̻ ̼ ̽ ̾ ̿ ̀ ́ ͂ ̓ ̈́ ͅ ͠ ͡
|
||||
Greek
|
||||
ʹ ͵ ͺ ; ΄ ΅ Ά · Έ Ή Ί Ό Ύ Ώ ΐ Α Β Γ Δ Ε Ζ Η Θ Ι Κ Λ Μ Ν Ξ Ο Π Ρ Σ Τ Υ Φ Χ Ψ Ω Ϊ Ϋ ά έ ή ί ΰ α β γ δ ε ζ η θ ι κ λ μ ν ξ ο π ρ ς σ τ υ φ χ ψ ω ϊ ϋ ό ύ ώ ϐ ϑ ϒ ϓ ϔ ϕ ϖ Ϛ Ϝ Ϟ Ϡ Ϣ ϣ Ϥ ϥ Ϧ ϧ Ϩ ϩ Ϫ ϫ Ϭ ϭ Ϯ ϯ ϰ ϱ ϲ ϳ
|
||||
Cyrillic
|
||||
Ё Ђ Ѓ Є Ѕ І Ї Ј Љ Њ Ћ Ќ Ў Џ А Б В Г Д Е Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я а б в г д е ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я ё ђ ѓ є ѕ і ї ј љ њ ћ ќ ў џ Ѡ ѡ Ѣ ѣ Ѥ ѥ Ѧ ѧ Ѩ ѩ Ѫ ѫ Ѭ ѭ Ѯ ѯ Ѱ ѱ Ѳ ѳ Ѵ ѵ Ѷ ѷ Ѹ ѹ Ѻ ѻ Ѽ ѽ Ѿ ѿ Ҁ ҁ ҂ ҃ ...
|
||||
Armenian
|
||||
Ա Բ Գ Դ Ե Զ Է Ը Թ Ժ Ի Լ Խ Ծ Կ Հ Ձ Ղ Ճ Մ Յ Ն Շ Ո Չ Պ Ջ Ռ Ս Վ Տ Ր Ց Ւ Փ Ք Օ Ֆ ՙ ՚ ՛ ՜ ՝ ՞ ՟ ա բ գ դ ե զ է ը թ ժ ի լ խ ծ կ հ ձ ղ ճ մ յ ն շ ո չ պ ջ ռ ս վ տ ր ց ւ փ ք օ ֆ և ։
|
||||
Hebrew
|
||||
֑ ֒ ֓ ֔ ֕ ֖ ֗ ֘ ֙ ֚ ֛ ֜ ֝ ֞ ֟ ֠ ֡ ֣ ֤ ֥ ֦ ֧ ֨ ֩ ֪ ֫ ֬ ֭ ֮ ֯ ְ ֱ ֲ ֳ ִ ֵ ֶ ַ ָ ֹ ֻ ּ ֽ ־ ֿ ׀ ׁ ׂ ׃ ׄ א ב ג ד ה ו ז ח ט י ך כ ל ם מ ן נ ס ע ף פ ץ צ ק ר ש ת װ ױ ײ ׳ ״
|
||||
Arabic
|
||||
، ؛ ؟ ء آ أ ؤ إ ئ ا ب ة ت ث ج ح خ د ذ ر ز س ش ص ض ط ظ ع غ ـ ف ق ك ل م ن ه و ى ي ً ٌ ٍ َ ُ ِ ّ ْ ٠ ١ ٢ ٣ ٤ ٥ ٦ ٧ ٨ ٩ ٪ ٫ ٬ ٭ ٰ ٱ ٲ ٳ ٴ ٵ ٶ ٷ ٸ ٹ ٺ ٻ ټ ٽ پ ٿ ڀ ځ ڂ ڃ ڄ څ چ ڇ ڈ ډ ڊ ڋ ڌ ڍ ڎ ڏ ڐ ڑ ڒ ړ ڔ ڕ ږ ڗ ژ ڙ ښ ڛ ڜ ڝ ڞ ڟ ڠ ڡ ڢ ڣ ڤ ڥ ڦ ڧ ڨ ک ڪ ګ ڬ ڭ ڮ گ ڰ ڱ ...
|
||||
Devanagari
|
||||
ँ ं ः अ आ इ ई उ ऊ ऋ ऌ ऍ ऎ ए ऐ ऑ ऒ ओ औ क ख ग घ ङ च छ ज झ ञ ट ठ ड ढ ण त थ द ध न ऩ प फ ब भ म य र ऱ ल ळ ऴ व श ष स ह ़ ऽ ा ि ी ु ू ृ ॄ ॅ ॆ े ै ॉ ॊ ो ौ ् ॐ ॑ ॒ ॓ ॔ क़ ख़ ग़ ज़ ड़ ढ़ फ़ य़ ॠ ॡ ॢ ॣ । ॥ ० १ २ ३ ४ ५ ६ ७ ८ ९ ॰
|
||||
Bengali
|
||||
ঁ ং ঃ অ আ ই ঈ উ ঊ ঋ ঌ এ ঐ ও ঔ ক খ গ ঘ ঙ চ ছ জ ঝ ঞ ট ঠ ড ঢ ণ ত থ দ ধ ন প ফ ব ভ ম য র ল শ ষ স হ ় া ি ী ু ূ ৃ ৄ ে ৈ ো ৌ ্ ৗ ড় ঢ় য় ৠ ৡ ৢ ৣ ০ ১ ২ ৩ ৪ ৫ ৬ ৭ ৮ ৯ ৰ ৱ ৲ ৳ ৴ ৵ ৶ ৷ ৸ ৹ ৺
|
||||
Gurmukhi
|
||||
ਂ ਅ ਆ ਇ ਈ ਉ ਊ ਏ ਐ ਓ ਔ ਕ ਖ ਗ ਘ ਙ ਚ ਛ ਜ ਝ ਞ ਟ ਠ ਡ ਢ ਣ ਤ ਥ ਦ ਧ ਨ ਪ ਫ ਬ ਭ ਮ ਯ ਰ ਲ ਲ਼ ਵ ਸ਼ ਸ ਹ ਼ ਾ ਿ ੀ ੁ ੂ ੇ ੈ ੋ ੌ ੍ ਖ਼ ਗ਼ ਜ਼ ੜ ਫ਼ ੦ ੧ ੨ ੩ ੪ ੫ ੬ ੭ ੮ ੯ ੰ ੱ ੲ ੳ ੴ
|
||||
Gujarati
|
||||
ઁ ં ઃ અ આ ઇ ઈ ઉ ઊ ઋ ઍ એ ઐ ઑ ઓ ઔ ક ખ ગ ઘ ઙ ચ છ જ ઝ ઞ ટ ઠ ડ ઢ ણ ત થ દ ધ ન પ ફ બ ભ મ ય ર લ ળ વ શ ષ સ હ ઼ ઽ ા િ ી ુ ૂ ૃ ૄ ૅ ે ૈ ૉ ો ૌ ્ ૐ ૠ ૦ ૧ ૨ ૩ ૪ ૫ ૬ ૭ ૮ ૯
|
||||
Oriya
|
||||
ଁ ଂ ଃ ଅ ଆ ଇ ଈ ଉ ଊ ଋ ଌ ଏ ଐ ଓ ଔ କ ଖ ଗ ଘ ଙ ଚ ଛ ଜ ଝ ଞ ଟ ଠ ଡ ଢ ଣ ତ ଥ ଦ ଧ ନ ପ ଫ ବ ଭ ମ ଯ ର ଲ ଳ ଶ ଷ ସ ହ ଼ ଽ ା ି ୀ ୁ ୂ ୃ େ ୈ ୋ ୌ ୍ ୖ ୗ ଡ଼ ଢ଼ ୟ ୠ ୡ ୦ ୧ ୨ ୩ ୪ ୫ ୬ ୭ ୮ ୯ ୰
|
||||
Tamil
|
||||
ஂ ஃ அ ஆ இ ஈ உ ஊ எ ஏ ஐ ஒ ஓ ஔ க ங ச ஜ ஞ ட ண த ந ன ப ம ய ர ற ல ள ழ வ ஷ ஸ ஹ ா ி ீ ு ூ ெ ே ை ொ ோ ௌ ் ௗ ௧ ௨ ௩ ௪ ௫ ௬ ௭ ௮ ௯ ௰ ௱ ௲
|
||||
Telugu
|
||||
ఁ ం ః అ ఆ ఇ ఈ ఉ ఊ ఋ ఌ ఎ ఏ ఐ ఒ ఓ ఔ క ఖ గ ఘ ఙ చ ఛ జ ఝ ఞ ట ఠ డ ఢ ణ త థ ద ధ న ప ఫ బ భ మ య ర ఱ ల ళ వ శ ష స హ ా ి ీ ు ూ ృ ౄ ె ే ై ొ ో ౌ ్ ౕ ౖ ౠ ౡ ౦ ౧ ౨ ౩ ౪ ౫ ౬ ౭ ౮ ౯
|
||||
Kannada
|
||||
ಂ ಃ ಅ ಆ ಇ ಈ ಉ ಊ ಋ ಌ ಎ ಏ ಐ ಒ ಓ ಔ ಕ ಖ ಗ ಘ ಙ ಚ ಛ ಜ ಝ ಞ ಟ ಠ ಡ ಢ ಣ ತ ಥ ದ ಧ ನ ಪ ಫ ಬ ಭ ಮ ಯ ರ ಱ ಲ ಳ ವ ಶ ಷ ಸ ಹ ಾ ಿ ೀ ು ೂ ೃ ೄ ೆ ೇ ೈ ೊ ೋ ೌ ್ ೕ ೖ ೞ ೠ ೡ ೦ ೧ ೨ ೩ ೪ ೫ ೬ ೭ ೮ ೯
|
||||
Malayalam
|
||||
ം ഃ അ ആ ഇ ഈ ഉ ഊ ഋ ഌ എ ഏ ഐ ഒ ഓ ഔ ക ഖ ഗ ഘ ങ ച ഛ ജ ഝ ഞ ട ഠ ഡ ഢ ണ ത ഥ ദ ധ ന പ ഫ ബ ഭ മ യ ര റ ല ള ഴ വ ശ ഷ സ ഹ ാ ി ീ ു ൂ ൃ െ േ ൈ ൊ ോ ൌ ് ൗ ൠ ൡ ൦ ൧ ൨ ൩ ൪ ൫ ൬ ൭ ൮ ൯
|
||||
Thai
|
||||
ก ข ฃ ค ฅ ฆ ง จ ฉ ช ซ ฌ ญ ฎ ฏ ฐ ฑ ฒ ณ ด ต ถ ท ธ น บ ป ผ ฝ พ ฟ ภ ม ย ร ฤ ล ฦ ว ศ ษ ส ห ฬ อ ฮ ฯ ะ ั า ำ ิ ี ึ ื ุ ู ฺ ฿ เ แ โ ใ ไ ๅ ๆ ็ ่ ้ ๊ ๋ ์ ํ ๎ ๏ ๐ ๑ ๒ ๓ ๔ ๕ ๖ ๗ ๘ ๙ ๚ ๛
|
||||
Lao
|
||||
ກ ຂ ຄ ງ ຈ ຊ ຍ ດ ຕ ຖ ທ ນ ບ ປ ຜ ຝ ພ ຟ ມ ຢ ຣ ລ ວ ສ ຫ ອ ຮ ຯ ະ ັ າ ຳ ິ ີ ຶ ື ຸ ູ ົ ຼ ຽ ເ ແ ໂ ໃ ໄ ໆ ່ ້ ໊ ໋ ໌ ໍ ໐ ໑ ໒ ໓ ໔ ໕ ໖ ໗ ໘ ໙ ໜ ໝ
|
||||
Tibetan
|
||||
ༀ ༁ ༂ ༃ ༄ ༅ ༆ ༇ ༈ ༉ ༊ ་ ༌ ། ༎ ༏ ༐ ༑ ༒ ༓ ༔ ༕ ༖ ༗ ༘ ༙ ༚ ༛ ༜ ༝ ༞ ༟ ༠ ༡ ༢ ༣ ༤ ༥ ༦ ༧ ༨ ༩ ༪ ༫ ༬ ༭ ༮ ༯ ༰ ༱ ༲ ༳ ༴ ༵ ༶ ༷ ༸ ༹ ༺ ༻ ༼ ༽ ༾ ༿ ཀ ཁ ག གྷ ང ཅ ཆ ཇ ཉ ཊ ཋ ཌ ཌྷ ཎ ཏ ཐ ད དྷ ན པ ཕ བ བྷ མ ཙ ཚ ཛ ཛྷ ཝ ཞ ཟ འ ཡ ར ལ ཤ ཥ ས ཧ ཨ ཀྵ ཱ ི ཱི ུ ཱུ ྲྀ ཷ ླྀ ཹ ེ ཻ ོ ཽ ཾ ཿ ྀ ཱྀ ྂ ྃ ྄ ྅ ྆ ྇ ...
|
||||
Georgian
|
||||
Ⴀ Ⴁ Ⴂ Ⴃ Ⴄ Ⴅ Ⴆ Ⴇ Ⴈ Ⴉ Ⴊ Ⴋ Ⴌ Ⴍ Ⴎ Ⴏ Ⴐ Ⴑ Ⴒ Ⴓ Ⴔ Ⴕ Ⴖ Ⴗ Ⴘ Ⴙ Ⴚ Ⴛ Ⴜ Ⴝ Ⴞ Ⴟ Ⴠ Ⴡ Ⴢ Ⴣ Ⴤ Ⴥ ა ბ გ დ ე ვ ზ თ ი კ ლ მ ნ ო პ ჟ რ ს ტ უ ფ ქ ღ ყ შ ჩ ც ძ წ ჭ ხ ჯ ჰ ჱ ჲ ჳ ჴ ჵ ჶ ჻
|
||||
Hangul Jamo
|
||||
ᄀ ᄁ ᄂ ᄃ ᄄ ᄅ ᄆ ᄇ ᄈ ᄉ ᄊ ᄋ ᄌ ᄍ ᄎ ᄏ ᄐ ᄑ ᄒ ᄓ ᄔ ᄕ ᄖ ᄗ ᄘ ᄙ ᄚ ᄛ ᄜ ᄝ ᄞ ᄟ ᄠ ᄡ ᄢ ᄣ ᄤ ᄥ ᄦ ᄧ ᄨ ᄩ ᄪ ᄫ ᄬ ᄭ ᄮ ᄯ ᄰ ᄱ ᄲ ᄳ ᄴ ᄵ ᄶ ᄷ ᄸ ᄹ ᄺ ᄻ ᄼ ᄽ ᄾ ᄿ ᅀ ᅁ ᅂ ᅃ ᅄ ᅅ ᅆ ᅇ ᅈ ᅉ ᅊ ᅋ ᅌ ᅍ ᅎ ᅏ ᅐ ᅑ ᅒ ᅓ ᅔ ᅕ ᅖ ᅗ ᅘ ᅙ ᅟ ᅠ ᅡ ᅢ ᅣ ᅤ ᅥ ᅦ ᅧ ᅨ ᅩ ᅪ ᅫ ᅬ ᅭ ᅮ ᅯ ᅰ ᅱ ᅲ ᅳ ᅴ ᅵ ᅶ ᅷ ᅸ ᅹ ᅺ ᅻ ᅼ ᅽ ᅾ ᅿ ᆀ ᆁ ᆂ ᆃ ᆄ ...
|
||||
Latin Extended Additional
|
||||
Ḁ ḁ Ḃ ḃ Ḅ ḅ Ḇ ḇ Ḉ ḉ Ḋ ḋ Ḍ ḍ Ḏ ḏ Ḑ ḑ Ḓ ḓ Ḕ ḕ Ḗ ḗ Ḙ ḙ Ḛ ḛ Ḝ ḝ Ḟ ḟ Ḡ ḡ Ḣ ḣ Ḥ ḥ Ḧ ḧ Ḩ ḩ Ḫ ḫ Ḭ ḭ Ḯ ḯ Ḱ ḱ Ḳ ḳ Ḵ ḵ Ḷ ḷ Ḹ ḹ Ḻ ḻ Ḽ ḽ Ḿ ḿ Ṁ ṁ Ṃ ṃ Ṅ ṅ Ṇ ṇ Ṉ ṉ Ṋ ṋ Ṍ ṍ Ṏ ṏ Ṑ ṑ Ṓ ṓ Ṕ ṕ Ṗ ṗ Ṙ ṙ Ṛ ṛ Ṝ ṝ Ṟ ṟ Ṡ ṡ Ṣ ṣ Ṥ ṥ Ṧ ṧ Ṩ ṩ Ṫ ṫ Ṭ ṭ Ṯ ṯ Ṱ ṱ Ṳ ṳ Ṵ ṵ Ṷ ṷ Ṹ ṹ Ṻ ṻ Ṽ ṽ Ṿ ṿ ...
|
||||
Greek Extended
|
||||
ἀ ἁ ἂ ἃ ἄ ἅ ἆ ἇ Ἀ Ἁ Ἂ Ἃ Ἄ Ἅ Ἆ Ἇ ἐ ἑ ἒ ἓ ἔ ἕ Ἐ Ἑ Ἒ Ἓ Ἔ Ἕ ἠ ἡ ἢ ἣ ἤ ἥ ἦ ἧ Ἠ Ἡ Ἢ Ἣ Ἤ Ἥ Ἦ Ἧ ἰ ἱ ἲ ἳ ἴ ἵ ἶ ἷ Ἰ Ἱ Ἲ Ἳ Ἴ Ἵ Ἶ Ἷ ὀ ὁ ὂ ὃ ὄ ὅ Ὀ Ὁ Ὂ Ὃ Ὄ Ὅ ὐ ὑ ὒ ὓ ὔ ὕ ὖ ὗ Ὑ Ὓ Ὕ Ὗ ὠ ὡ ὢ ὣ ὤ ὥ ὦ ὧ Ὠ Ὡ Ὢ Ὣ Ὤ Ὥ Ὦ Ὧ ὰ ά ὲ έ ὴ ή ὶ ί ὸ ό ὺ ύ ὼ ώ ᾀ ᾁ ᾂ ᾃ ᾄ ᾅ ᾆ ᾇ ᾈ ᾉ ᾊ ᾋ ᾌ ᾍ ...
|
||||
General Punctuation
|
||||
‐ ‑ ‒ – — ― ‖ ‗ ‘ ’ ‚ ‛ “ ” „ ‟ † ‡ • ‣ ․ ‥ … ‧
‰ ‱ ′ ″ ‴ ‵ ‶ ‷ ‸ ‹ › ※ ‼ ‽ ‾ ‿ ⁀ ⁁ ⁂ ⁃ ⁄ ⁅ ⁆
|
||||
Superscripts and Subscripts
|
||||
⁰ ⁴ ⁵ ⁶ ⁷ ⁸ ⁹ ⁺ ⁻ ⁼ ⁽ ⁾ ⁿ ₀ ₁ ₂ ₃ ₄ ₅ ₆ ₇ ₈ ₉ ₊ ₋ ₌ ₍ ₎
|
||||
Currency Symbols
|
||||
₠ ₡ ₢ ₣ ₤ ₥ ₦ ₧ ₨ ₩ ₪ ₫
|
||||
Combining Marks for Symbols
|
||||
⃐ ⃑ ⃒ ⃓ ⃔ ⃕ ⃖ ⃗ ⃘ ⃙ ⃚ ⃛ ⃜ ⃝ ⃞ ⃟ ⃠ ⃡
|
||||
Letterlike Symbols
|
||||
℀ ℁ ℂ ℃ ℄ ℅ ℆ ℇ ℈ ℉ ℊ ℋ ℌ ℍ ℎ ℏ ℐ ℑ ℒ ℓ ℔ ℕ № ℗ ℘ ℙ ℚ ℛ ℜ ℝ ℞ ℟ ℠ ℡ ™ ℣ ℤ ℥ Ω ℧ ℨ ℩ K Å ℬ ℭ ℮ ℯ ℰ ℱ Ⅎ ℳ ℴ ℵ ℶ ℷ ℸ
|
||||
Number Forms
|
||||
⅓ ⅔ ⅕ ⅖ ⅗ ⅘ ⅙ ⅚ ⅛ ⅜ ⅝ ⅞ ⅟ Ⅰ Ⅱ Ⅲ Ⅳ Ⅴ Ⅵ Ⅶ Ⅷ Ⅸ Ⅹ Ⅺ Ⅻ Ⅼ Ⅽ Ⅾ Ⅿ ⅰ ⅱ ⅲ ⅳ ⅴ ⅵ ⅶ ⅷ ⅸ ⅹ ⅺ ⅻ ⅼ ⅽ ⅾ ⅿ ↀ ↁ ↂ
|
||||
Arrows
|
||||
← ↑ → ↓ ↔ ↕ ↖ ↗ ↘ ↙ ↚ ↛ ↜ ↝ ↞ ↟ ↠ ↡ ↢ ↣ ↤ ↥ ↦ ↧ ↨ ↩ ↪ ↫ ↬ ↭ ↮ ↯ ↰ ↱ ↲ ↳ ↴ ↵ ↶ ↷ ↸ ↹ ↺ ↻ ↼ ↽ ↾ ↿ ⇀ ⇁ ⇂ ⇃ ⇄ ⇅ ⇆ ⇇ ⇈ ⇉ ⇊ ⇋ ⇌ ⇍ ⇎ ⇏ ⇐ ⇑ ⇒ ⇓ ⇔ ⇕ ⇖ ⇗ ⇘ ⇙ ⇚ ⇛ ⇜ ⇝ ⇞ ⇟ ⇠ ⇡ ⇢ ⇣ ⇤ ⇥ ⇦ ⇧ ⇨ ⇩ ⇪
|
||||
Mathematical Operators
|
||||
∀ ∁ ∂ ∃ ∄ ∅ ∆ ∇ ∈ ∉ ∊ ∋ ∌ ∍ ∎ ∏ ∐ ∑ − ∓ ∔ ∕ ∖ ∗ ∘ ∙ √ ∛ ∜ ∝ ∞ ∟ ∠ ∡ ∢ ∣ ∤ ∥ ∦ ∧ ∨ ∩ ∪ ∫ ∬ ∭ ∮ ∯ ∰ ∱ ∲ ∳ ∴ ∵ ∶ ∷ ∸ ∹ ∺ ∻ ∼ ∽ ∾ ∿ ≀ ≁ ≂ ≃ ≄ ≅ ≆ ≇ ≈ ≉ ≊ ≋ ≌ ≍ ≎ ≏ ≐ ≑ ≒ ≓ ≔ ≕ ≖ ≗ ≘ ≙ ≚ ≛ ≜ ≝ ≞ ≟ ≠ ≡ ≢ ≣ ≤ ≥ ≦ ≧ ≨ ≩ ≪ ≫ ≬ ≭ ≮ ≯ ≰ ≱ ≲ ≳ ≴ ≵ ≶ ≷ ≸ ≹ ≺ ≻ ≼ ≽ ≾ ≿ ...
|
||||
Miscellaneous Technical
|
||||
⌀ ⌂ ⌃ ⌄ ⌅ ⌆ ⌇ ⌈ ⌉ ⌊ ⌋ ⌌ ⌍ ⌎ ⌏ ⌐ ⌑ ⌒ ⌓ ⌔ ⌕ ⌖ ⌗ ⌘ ⌙ ⌚ ⌛ ⌜ ⌝ ⌞ ⌟ ⌠ ⌡ ⌢ ⌣ ⌤ ⌥ ⌦ ⌧ ⌨ 〈 〉 ⌫ ⌬ ⌭ ⌮ ⌯ ⌰ ⌱ ⌲ ⌳ ⌴ ⌵ ⌶ ⌷ ⌸ ⌹ ⌺ ⌻ ⌼ ⌽ ⌾ ⌿ ⍀ ⍁ ⍂ ⍃ ⍄ ⍅ ⍆ ⍇ ⍈ ⍉ ⍊ ⍋ ⍌ ⍍ ⍎ ⍏ ⍐ ⍑ ⍒ ⍓ ⍔ ⍕ ⍖ ⍗ ⍘ ⍙ ⍚ ⍛ ⍜ ⍝ ⍞ ⍟ ⍠ ⍡ ⍢ ⍣ ⍤ ⍥ ⍦ ⍧ ⍨ ⍩ ⍪ ⍫ ⍬ ⍭ ⍮ ⍯ ⍰ ⍱ ⍲ ⍳ ⍴ ⍵ ⍶ ⍷ ⍸ ⍹ ⍺
|
||||
Control Pictures
|
||||
␀ ␁ ␂ ␃ ␄ ␅ ␆ ␇ ␈ ␉ ␊ ␋ ␌ ␍ ␎ ␏ ␐ ␑ ␒ ␓ ␔ ␕ ␖ ␗ ␘ ␙ ␚ ␛ ␜ ␝ ␞ ␟ ␠ ␡ ␢ ␣ 
|
||||
Optical Character Recognition
|
||||
⑀ ⑁ ⑂ ⑃ ⑄ ⑅ ⑆ ⑇ ⑈ ⑉ ⑊
|
||||
Enclosed Alphanumerics
|
||||
① ② ③ ④ ⑤ ⑥ ⑦ ⑧ ⑨ ⑩ ⑪ ⑫ ⑬ ⑭ ⑮ ⑯ ⑰ ⑱ ⑲ ⑳ ⑴ ⑵ ⑶ ⑷ ⑸ ⑹ ⑺ ⑻ ⑼ ⑽ ⑾ ⑿ ⒀ ⒁ ⒂ ⒃ ⒄ ⒅ ⒆ ⒇ ⒈ ⒉ ⒊ ⒋ ⒌ ⒍ ⒎ ⒏ ⒐ ⒑ ⒒ ⒓ ⒔ ⒕ ⒖ ⒗ ⒘ ⒙ ⒚ ⒛ ⒜ ⒝ ⒞ ⒟ ⒠ ⒡ ⒢ ⒣ ⒤ ⒥ ⒦ ⒧ ⒨ ⒩ ⒪ ⒫ ⒬ ⒭ ⒮ ⒯ ⒰ ⒱ ⒲ ⒳ ⒴ ⒵ Ⓐ Ⓑ Ⓒ Ⓓ Ⓔ Ⓕ Ⓖ Ⓗ Ⓘ Ⓙ Ⓚ Ⓛ Ⓜ Ⓝ Ⓞ Ⓟ Ⓠ Ⓡ Ⓢ Ⓣ Ⓤ Ⓥ Ⓦ Ⓧ Ⓨ Ⓩ ⓐ ⓑ ⓒ ⓓ ⓔ ⓕ ⓖ ⓗ ⓘ ⓙ ⓚ ⓛ ⓜ ⓝ ⓞ ⓟ ...
|
||||
Box Drawing
|
||||
─ ━ │ ┃ ┄ ┅ ┆ ┇ ┈ ┉ ┊ ┋ ┌ ┍ ┎ ┏ ┐ ┑ ┒ ┓ └ ┕ ┖ ┗ ┘ ┙ ┚ ┛ ├ ┝ ┞ ┟ ┠ ┡ ┢ ┣ ┤ ┥ ┦ ┧ ┨ ┩ ┪ ┫ ┬ ┭ ┮ ┯ ┰ ┱ ┲ ┳ ┴ ┵ ┶ ┷ ┸ ┹ ┺ ┻ ┼ ┽ ┾ ┿ ╀ ╁ ╂ ╃ ╄ ╅ ╆ ╇ ╈ ╉ ╊ ╋ ╌ ╍ ╎ ╏ ═ ║ ╒ ╓ ╔ ╕ ╖ ╗ ╘ ╙ ╚ ╛ ╜ ╝ ╞ ╟ ╠ ╡ ╢ ╣ ╤ ╥ ╦ ╧ ╨ ╩ ╪ ╫ ╬ ╭ ╮ ╯ ╰ ╱ ╲ ╳ ╴ ╵ ╶ ╷ ╸ ╹ ╺ ╻ ╼ ╽ ╾ ╿
|
||||
Block Elements
|
||||
▀ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▉ ▊ ▋ ▌ ▍ ▎ ▏ ▐ ░ ▒ ▓ ▔ ▕
|
||||
Geometric Shapes
|
||||
■ □ ▢ ▣ ▤ ▥ ▦ ▧ ▨ ▩ ▪ ▫ ▬ ▭ ▮ ▯ ▰ ▱ ▲ △ ▴ ▵ ▶ ▷ ▸ ▹ ► ▻ ▼ ▽ ▾ ▿ ◀ ◁ ◂ ◃ ◄ ◅ ◆ ◇ ◈ ◉ ◊ ○ ◌ ◍ ◎ ● ◐ ◑ ◒ ◓ ◔ ◕ ◖ ◗ ◘ ◙ ◚ ◛ ◜ ◝ ◞ ◟ ◠ ◡ ◢ ◣ ◤ ◥ ◦ ◧ ◨ ◩ ◪ ◫ ◬ ◭ ◮ ◯
|
||||
Miscellaneous Symbols
|
||||
☀ ☁ ☂ ☃ ☄ ★ ☆ ☇ ☈ ☉ ☊ ☋ ☌ ☍ ☎ ☏ ☐ ☑ ☒ ☓ ☚ ☛ ☜ ☝ ☞ ☟ ☠ ☡ ☢ ☣ ☤ ☥ ☦ ☧ ☨ ☩ ☪ ☫ ☬ ☭ ☮ ☯ ☰ ☱ ☲ ☳ ☴ ☵ ☶ ☷ ☸ ☹ ☺ ☻ ☼ ☽ ☾ ☿ ♀ ♁ ♂ ♃ ♄ ♅ ♆ ♇ ♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓ ♔ ♕ ♖ ♗ ♘ ♙ ♚ ♛ ♜ ♝ ♞ ♟ ♠ ♡ ♢ ♣ ♤ ♥ ♦ ♧ ♨ ♩ ♪ ♫ ♬ ♭ ♮ ♯
|
||||
Dingbats
|
||||
✁ ✂ ✃ ✄ ✆ ✇ ✈ ✉ ✌ ✍ ✎ ✏ ✐ ✑ ✒ ✓ ✔ ✕ ✖ ✗ ✘ ✙ ✚ ✛ ✜ ✝ ✞ ✟ ✠ ✡ ✢ ✣ ✤ ✥ ✦ ✧ ✩ ✪ ✫ ✬ ✭ ✮ ✯ ✰ ✱ ✲ ✳ ✴ ✵ ✶ ✷ ✸ ✹ ✺ ✻ ✼ ✽ ✾ ✿ ❀ ❁ ❂ ❃ ❄ ❅ ❆ ❇ ❈ ❉ ❊ ❋ ❍ ❏ ❐ ❑ ❒ ❖ ❘ ❙ ❚ ❛ ❜ ❝ ❞ ❡ ❢ ❣ ❤ ❥ ❦ ❧ ❶ ❷ ❸ ❹ ❺ ❻ ❼ ❽ ❾ ❿ ➀ ➁ ➂ ➃ ➄ ➅ ➆ ➇ ➈ ➉ ➊ ➋ ➌ ➍ ➎ ➏ ➐ ➑ ➒ ➓ ➔ ➘ ➙ ➚ ➛ ➜ ➝ ...
|
||||
CJK Symbols and Punctuation
|
||||
、 。 〃 〄 々 〆 〇 〈 〉 《 》 「 」 『 』 【 】 〒 〓 〔 〕 〖 〗 〘 〙 〚 〛 〜 〝 〞 〟 〠 〡 〢 〣 〤 〥 〦 〧 〨 〩 〪 〫 〬 〭 〮 〯 〰 〱 〲 〳 〴 〵 〶 〷 〿
|
||||
Hiragana
|
||||
ぁ あ ぃ い ぅ う ぇ え ぉ お か が き ぎ く ぐ け げ こ ご さ ざ し じ す ず せ ぜ そ ぞ た だ ち ぢ っ つ づ て で と ど な に ぬ ね の は ば ぱ ひ び ぴ ふ ぶ ぷ へ べ ぺ ほ ぼ ぽ ま み む め も ゃ や ゅ ゆ ょ よ ら り る れ ろ ゎ わ ゐ ゑ を ん ゔ ゙ ゚ ゛ ゜ ゝ ゞ
|
||||
Katakana
|
||||
ァ ア ィ イ ゥ ウ ェ エ ォ オ カ ガ キ ギ ク グ ケ ゲ コ ゴ サ ザ シ ジ ス ズ セ ゼ ソ ゾ タ ダ チ ヂ ッ ツ ヅ テ デ ト ド ナ ニ ヌ ネ ノ ハ バ パ ヒ ビ ピ フ ブ プ ヘ ベ ペ ホ ボ ポ マ ミ ム メ モ ャ ヤ ュ ユ ョ ヨ ラ リ ル レ ロ ヮ ワ ヰ ヱ ヲ ン ヴ ヵ ヶ ヷ ヸ ヹ ヺ ・ ー ヽ ヾ
|
||||
Bopomofo
|
||||
ㄅ ㄆ ㄇ ㄈ ㄉ ㄊ ㄋ ㄌ ㄍ ㄎ ㄏ ㄐ ㄑ ㄒ ㄓ ㄔ ㄕ ㄖ ㄗ ㄘ ㄙ ㄚ ㄛ ㄜ ㄝ ㄞ ㄟ ㄠ ㄡ ㄢ ㄣ ㄤ ㄥ ㄦ ㄧ ㄨ ㄩ ㄪ ㄫ ㄬ
|
||||
Hangul Compatibility Jamo
|
||||
ㄱ ㄲ ㄳ ㄴ ㄵ ㄶ ㄷ ㄸ ㄹ ㄺ ㄻ ㄼ ㄽ ㄾ ㄿ ㅀ ㅁ ㅂ ㅃ ㅄ ㅅ ㅆ ㅇ ㅈ ㅉ ㅊ ㅋ ㅌ ㅍ ㅎ ㅏ ㅐ ㅑ ㅒ ㅓ ㅔ ㅕ ㅖ ㅗ ㅘ ㅙ ㅚ ㅛ ㅜ ㅝ ㅞ ㅟ ㅠ ㅡ ㅢ ㅣ ㅤ ㅥ ㅦ ㅧ ㅨ ㅩ ㅪ ㅫ ㅬ ㅭ ㅮ ㅯ ㅰ ㅱ ㅲ ㅳ ㅴ ㅵ ㅶ ㅷ ㅸ ㅹ ㅺ ㅻ ㅼ ㅽ ㅾ ㅿ ㆀ ㆁ ㆂ ㆃ ㆄ ㆅ ㆆ ㆇ ㆈ ㆉ ㆊ ㆋ ㆌ ㆍ ㆎ
|
||||
Kanbun
|
||||
㆐ ㆑ ㆒ ㆓ ㆔ ㆕ ㆖ ㆗ ㆘ ㆙ ㆚ ㆛ ㆜ ㆝ ㆞ ㆟
|
||||
Enclosed CJK Letters and Months
|
||||
㈀ ㈁ ㈂ ㈃ ㈄ ㈅ ㈆ ㈇ ㈈ ㈉ ㈊ ㈋ ㈌ ㈍ ㈎ ㈏ ㈐ ㈑ ㈒ ㈓ ㈔ ㈕ ㈖ ㈗ ㈘ ㈙ ㈚ ㈛ ㈜ ㈠ ㈡ ㈢ ㈣ ㈤ ㈥ ㈦ ㈧ ㈨ ㈩ ㈪ ㈫ ㈬ ㈭ ㈮ ㈯ ㈰ ㈱ ㈲ ㈳ ㈴ ㈵ ㈶ ㈷ ㈸ ㈹ ㈺ ㈻ ㈼ ㈽ ㈾ ㈿ ㉀ ㉁ ㉂ ㉃ ㉠ ㉡ ㉢ ㉣ ㉤ ㉥ ㉦ ㉧ ㉨ ㉩ ㉪ ㉫ ㉬ ㉭ ㉮ ㉯ ㉰ ㉱ ㉲ ㉳ ㉴ ㉵ ㉶ ㉷ ㉸ ㉹ ㉺ ㉻ ㉿ ㊀ ㊁ ㊂ ㊃ ㊄ ㊅ ㊆ ㊇ ㊈ ㊉ ㊊ ㊋ ㊌ ㊍ ㊎ ㊏ ㊐ ㊑ ㊒ ㊓ ㊔ ㊕ ㊖ ㊗ ㊘ ㊙ ㊚ ㊛ ㊜ ㊝ ㊞ ㊟ ㊠ ㊡ ...
|
||||
CJK Compatibility
|
||||
㌀ ㌁ ㌂ ㌃ ㌄ ㌅ ㌆ ㌇ ㌈ ㌉ ㌊ ㌋ ㌌ ㌍ ㌎ ㌏ ㌐ ㌑ ㌒ ㌓ ㌔ ㌕ ㌖ ㌗ ㌘ ㌙ ㌚ ㌛ ㌜ ㌝ ㌞ ㌟ ㌠ ㌡ ㌢ ㌣ ㌤ ㌥ ㌦ ㌧ ㌨ ㌩ ㌪ ㌫ ㌬ ㌭ ㌮ ㌯ ㌰ ㌱ ㌲ ㌳ ㌴ ㌵ ㌶ ㌷ ㌸ ㌹ ㌺ ㌻ ㌼ ㌽ ㌾ ㌿ ㍀ ㍁ ㍂ ㍃ ㍄ ㍅ ㍆ ㍇ ㍈ ㍉ ㍊ ㍋ ㍌ ㍍ ㍎ ㍏ ㍐ ㍑ ㍒ ㍓ ㍔ ㍕ ㍖ ㍗ ㍘ ㍙ ㍚ ㍛ ㍜ ㍝ ㍞ ㍟ ㍠ ㍡ ㍢ ㍣ ㍤ ㍥ ㍦ ㍧ ㍨ ㍩ ㍪ ㍫ ㍬ ㍭ ㍮ ㍯ ㍰ ㍱ ㍲ ㍳ ㍴ ㍵ ㍶ ㍻ ㍼ ㍽ ㍾ ㍿ ㎀ ㎁ ㎂ ㎃ ...
|
||||
CJK Unified Ideographs
|
||||
一 丁 丂 七 丄 丅 丆 万 丈 三 上 下 丌 不 与 丏 丐 丑 丒 专 且 丕 世 丗 丘 丙 业 丛 东 丝 丞 丟 丠 両 丢 丣 两 严 並 丧 丨 丩 个 丫 丬 中 丮 丯 丰 丱 串 丳 临 丵 丶 丷 丸 丹 为 主 丼 丽 举 丿 乀 乁 乂 乃 乄 久 乆 乇 么 义 乊 之 乌 乍 乎 乏 乐 乑 乒 乓 乔 乕 乖 乗 乘 乙 乚 乛 乜 九 乞 也 习 乡 乢 乣 乤 乥 书 乧 乨 乩 乪 乫 乬 乭 乮 乯 买 乱 乲 乳 乴 乵 乶 乷 乸 乹 乺 乻 乼 乽 乾 乿 ...
|
||||
Hangul Syllables
|
||||
가 각 갂 갃 간 갅 갆 갇 갈 갉 갊 갋 갌 갍 갎 갏 감 갑 값 갓 갔 강 갖 갗 갘 같 갚 갛 개 객 갞 갟 갠 갡 갢 갣 갤 갥 갦 갧 갨 갩 갪 갫 갬 갭 갮 갯 갰 갱 갲 갳 갴 갵 갶 갷 갸 갹 갺 갻 갼 갽 갾 갿 걀 걁 걂 걃 걄 걅 걆 걇 걈 걉 걊 걋 걌 걍 걎 걏 걐 걑 걒 걓 걔 걕 걖 걗 걘 걙 걚 걛 걜 걝 걞 걟 걠 걡 걢 걣 걤 걥 걦 걧 걨 걩 걪 걫 걬 걭 걮 걯 거 걱 걲 걳 건 걵 걶 걷 걸 걹 걺 걻 걼 걽 걾 걿 ...
|
||||
Private Use
|
||||
...
|
||||
CJK Compatibility Ideographs
|
||||
豈 更 車 賈 滑 串 句 龜 龜 契 金 喇 奈 懶 癩 羅 蘿 螺 裸 邏 樂 洛 烙 珞 落 酪 駱 亂 卵 欄 爛 蘭 鸞 嵐 濫 藍 襤 拉 臘 蠟 廊 朗 浪 狼 郎 來 冷 勞 擄 櫓 爐 盧 老 蘆 虜 路 露 魯 鷺 碌 祿 綠 菉 錄 鹿 論 壟 弄 籠 聾 牢 磊 賂 雷 壘 屢 樓 淚 漏 累 縷 陋 勒 肋 凜 凌 稜 綾 菱 陵 讀 拏 樂 諾 丹 寧 怒 率 異 北 磻 便 復 不 泌 數 索 參 塞 省 葉 說 殺 辰 沈 拾 若 掠 略 亮 兩 凉 梁 糧 良 諒 量 勵 ...
|
||||
Alphabetic Presentation Forms
|
||||
ff fi fl ffi ffl ſt st ﬓ ﬔ ﬕ ﬖ ﬗ ﬞ ײַ ﬠ ﬡ ﬢ ﬣ ﬤ ﬥ ﬦ ﬧ ﬨ ﬩ שׁ שׂ שּׁ שּׂ אַ אָ אּ בּ גּ דּ הּ וּ זּ טּ יּ ךּ כּ לּ מּ נּ סּ ףּ פּ צּ קּ רּ שּ תּ וֹ בֿ כֿ פֿ ﭏ
|
||||
Arabic Presentation Forms-A
|
||||
ﭐ ﭑ ﭒ ﭓ ﭔ ﭕ ﭖ ﭗ ﭘ ﭙ ﭚ ﭛ ﭜ ﭝ ﭞ ﭟ ﭠ ﭡ ﭢ ﭣ ﭤ ﭥ ﭦ ﭧ ﭨ ﭩ ﭪ ﭫ ﭬ ﭭ ﭮ ﭯ ﭰ ﭱ ﭲ ﭳ ﭴ ﭵ ﭶ ﭷ ﭸ ﭹ ﭺ ﭻ ﭼ ﭽ ﭾ ﭿ ﮀ ﮁ ﮂ ﮃ ﮄ ﮅ ﮆ ﮇ ﮈ ﮉ ﮊ ﮋ ﮌ ﮍ ﮎ ﮏ ﮐ ﮑ ﮒ ﮓ ﮔ ﮕ ﮖ ﮗ ﮘ ﮙ ﮚ ﮛ ﮜ ﮝ ﮞ ﮟ ﮠ ﮡ ﮢ ﮣ ﮤ ﮥ ﮦ ﮧ ﮨ ﮩ ﮪ ﮫ ﮬ ﮭ ﮮ ﮯ ﮰ ﮱ ﯓ ﯔ ﯕ ﯖ ﯗ ﯘ ﯙ ﯚ ﯛ ﯜ ﯝ ﯞ ﯟ ﯠ ﯡ ﯢ ﯣ ﯤ ﯥ ﯦ ﯧ ﯨ ﯩ ﯪ ﯫ ﯬ ﯭ ﯮ ﯯ ﯰ ...
|
||||
Combining Half Marks
|
||||
︠ ︡ ︢ ︣
|
||||
CJK Compatibility Forms
|
||||
︰ ︱ ︲ ︳ ︴ ︵ ︶ ︷ ︸ ︹ ︺ ︻ ︼ ︽ ︾ ︿ ﹀ ﹁ ﹂ ﹃ ﹄ ﹉ ﹊ ﹋ ﹌ ﹍ ﹎ ﹏
|
||||
Small Form Variants
|
||||
﹐ ﹑ ﹒ ﹔ ﹕ ﹖ ﹗ ﹘ ﹙ ﹚ ﹛ ﹜ ﹝ ﹞ ﹟ ﹠ ﹡ ﹢ ﹣ ﹤ ﹥ ﹦ ﹨ ﹩ ﹪ ﹫
|
||||
Arabic Presentation Forms-B
|
||||
ﹰ ﹱ ﹲ ﹴ ﹶ ﹷ ﹸ ﹹ ﹺ ﹻ ﹼ ﹽ ﹾ ﹿ ﺀ ﺁ ﺂ ﺃ ﺄ ﺅ ﺆ ﺇ ﺈ ﺉ ﺊ ﺋ ﺌ ﺍ ﺎ ﺏ ﺐ ﺑ ﺒ ﺓ ﺔ ﺕ ﺖ ﺗ ﺘ ﺙ ﺚ ﺛ ﺜ ﺝ ﺞ ﺟ ﺠ ﺡ ﺢ ﺣ ﺤ ﺥ ﺦ ﺧ ﺨ ﺩ ﺪ ﺫ ﺬ ﺭ ﺮ ﺯ ﺰ ﺱ ﺲ ﺳ ﺴ ﺵ ﺶ ﺷ ﺸ ﺹ ﺺ ﺻ ﺼ ﺽ ﺾ ﺿ ﻀ ﻁ ﻂ ﻃ ﻄ ﻅ ﻆ ﻇ ﻈ ﻉ ﻊ ﻋ ﻌ ﻍ ﻎ ﻏ ﻐ ﻑ ﻒ ﻓ ﻔ ﻕ ﻖ ﻗ ﻘ ﻙ ﻚ ﻛ ﻜ ﻝ ﻞ ﻟ ﻠ ﻡ ﻢ ﻣ ﻤ ﻥ ﻦ ﻧ ﻨ ﻩ ﻪ ﻫ ﻬ ﻭ ﻮ ﻯ ﻰ ﻱ ...
|
||||
Halfwidth and Fullwidth Forms
|
||||
! " # $ % & ' ( ) * + , - . / 0 1 2 3 4 5 6 7 8 9 : ; < = > ? @ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \ ] ^ _ ` a b c d e f g h i j k l m n o p q r s t u v w x y z { | } ~ 。 「 」 、 ・ ヲ ァ ィ ゥ ェ ォ ャ ュ ョ ッ ー ア イ ウ エ オ カ キ ク ケ コ サ シ ス セ ソ タ チ ツ ...
|
||||
Specials
|
||||
|
||||
Specials
|
||||
<20>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.kunzisoft.keepass.tests.stream
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.database.BinaryAttachment
|
||||
import com.kunzisoft.keepass.stream.readAllBytes
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import java.io.DataInputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.lang.Exception
|
||||
import java.security.MessageDigest
|
||||
|
||||
class BinaryAttachmentTest {
|
||||
|
||||
private val context: Context by lazy {
|
||||
InstrumentationRegistry.getInstrumentation().context
|
||||
}
|
||||
|
||||
private val cacheDirectory = UriUtil.getBinaryDir(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
private val fileA = File(cacheDirectory, TEST_FILE_CACHE_A)
|
||||
private val fileB = File(cacheDirectory, TEST_FILE_CACHE_B)
|
||||
private val fileC = File(cacheDirectory, TEST_FILE_CACHE_C)
|
||||
|
||||
private val loadedKey = Database.LoadedKey.generateNewCipherKey()
|
||||
|
||||
private fun saveBinary(asset: String, binaryAttachment: BinaryAttachment) {
|
||||
context.assets.open(asset).use { assetInputStream ->
|
||||
binaryAttachment.getOutputDataStream(loadedKey).use { binaryOutputStream ->
|
||||
assetInputStream.readAllBytes(DEFAULT_BUFFER_SIZE) { buffer ->
|
||||
binaryOutputStream.write(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSaveTextInCache() {
|
||||
val binaryA = BinaryAttachment(fileA)
|
||||
val binaryB = BinaryAttachment(fileB)
|
||||
saveBinary(TEST_TEXT_ASSET, binaryA)
|
||||
saveBinary(TEST_TEXT_ASSET, binaryB)
|
||||
assertEquals("Save text binary length failed.", binaryA.length, binaryB.length)
|
||||
assertEquals("Save text binary MD5 failed.", binaryA.md5(), binaryB.md5())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSaveImageInCache() {
|
||||
val binaryA = BinaryAttachment(fileA)
|
||||
val binaryB = BinaryAttachment(fileB)
|
||||
saveBinary(TEST_IMAGE_ASSET, binaryA)
|
||||
saveBinary(TEST_IMAGE_ASSET, binaryB)
|
||||
assertEquals("Save image binary length failed.", binaryA.length, binaryB.length)
|
||||
assertEquals("Save image binary failed.", binaryA.md5(), binaryB.md5())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCompressText() {
|
||||
val binaryA = BinaryAttachment(fileA)
|
||||
val binaryB = BinaryAttachment(fileB)
|
||||
val binaryC = BinaryAttachment(fileC)
|
||||
saveBinary(TEST_TEXT_ASSET, binaryA)
|
||||
saveBinary(TEST_TEXT_ASSET, binaryB)
|
||||
saveBinary(TEST_TEXT_ASSET, binaryC)
|
||||
binaryA.compress(loadedKey)
|
||||
binaryB.compress(loadedKey)
|
||||
assertEquals("Compress text length failed.", binaryA.length, binaryB.length)
|
||||
assertEquals("Compress text MD5 failed.", binaryA.md5(), binaryB.md5())
|
||||
binaryB.decompress(loadedKey)
|
||||
assertEquals("Decompress text length failed.", binaryB.length, binaryC.length)
|
||||
assertEquals("Decompress text MD5 failed.", binaryB.md5(), binaryC.md5())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCompressImage() {
|
||||
val binaryA = BinaryAttachment(fileA)
|
||||
var binaryB = BinaryAttachment(fileB)
|
||||
val binaryC = BinaryAttachment(fileC)
|
||||
saveBinary(TEST_IMAGE_ASSET, binaryA)
|
||||
saveBinary(TEST_IMAGE_ASSET, binaryB)
|
||||
saveBinary(TEST_IMAGE_ASSET, binaryC)
|
||||
binaryA.compress(loadedKey)
|
||||
binaryB.compress(loadedKey)
|
||||
assertEquals("Compress image length failed.", binaryA.length, binaryA.length)
|
||||
assertEquals("Compress image failed.", binaryA.md5(), binaryA.md5())
|
||||
binaryB = BinaryAttachment(fileB, true)
|
||||
binaryB.decompress(loadedKey)
|
||||
assertEquals("Decompress image length failed.", binaryB.length, binaryC.length)
|
||||
assertEquals("Decompress image failed.", binaryB.md5(), binaryC.md5())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadText() {
|
||||
val binaryA = BinaryAttachment(fileA)
|
||||
saveBinary(TEST_TEXT_ASSET, binaryA)
|
||||
assert(streamAreEquals(context.assets.open(TEST_TEXT_ASSET),
|
||||
binaryA.getInputDataStream(loadedKey)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadImage() {
|
||||
val binaryA = BinaryAttachment(fileA)
|
||||
saveBinary(TEST_IMAGE_ASSET, binaryA)
|
||||
assert(streamAreEquals(context.assets.open(TEST_IMAGE_ASSET),
|
||||
binaryA.getInputDataStream(loadedKey)))
|
||||
}
|
||||
|
||||
private fun streamAreEquals(inputStreamA: InputStream,
|
||||
inputStreamB: InputStream): Boolean {
|
||||
val bufferA = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
val bufferB = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
val dataInputStreamB = DataInputStream(inputStreamB)
|
||||
try {
|
||||
var len: Int
|
||||
while (inputStreamA.read(bufferA).also { len = it } > 0) {
|
||||
dataInputStreamB.readFully(bufferB, 0, len)
|
||||
for (i in 0 until len) {
|
||||
if (bufferA[i] != bufferB[i])
|
||||
return false
|
||||
}
|
||||
}
|
||||
return inputStreamB.read() < 0 // is the end of the second file also.
|
||||
} catch (e: Exception) {
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
inputStreamA.close()
|
||||
inputStreamB.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun BinaryAttachment.md5(): String {
|
||||
val md = MessageDigest.getInstance("MD5")
|
||||
return this.getInputDataStream(loadedKey).use { fis ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
generateSequence {
|
||||
when (val bytesRead = fis.read(buffer)) {
|
||||
-1 -> null
|
||||
else -> bytesRead
|
||||
}
|
||||
}.forEach { bytesRead -> md.update(buffer, 0, bytesRead) }
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TEST_FILE_CACHE_A = "testA"
|
||||
private const val TEST_FILE_CACHE_B = "testB"
|
||||
private const val TEST_FILE_CACHE_C = "testC"
|
||||
private const val TEST_IMAGE_ASSET = "test_image.png"
|
||||
private const val TEST_TEXT_ASSET = "test_text.txt"
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,15 @@
|
||||
android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission
|
||||
android:name="android.permission.VIBRATE"/>
|
||||
<!-- Write permission until Android 10 -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<!-- Open apps from links -->
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"/>
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
@@ -124,9 +129,12 @@
|
||||
<activity
|
||||
android:name="com.kunzisoft.keepass.activities.EntryActivity"
|
||||
android:configChanges="keyboardHidden" />
|
||||
<activity
|
||||
android:name="com.kunzisoft.keepass.activities.ImageViewerActivity"
|
||||
android:configChanges="keyboardHidden" />
|
||||
<activity
|
||||
android:name="com.kunzisoft.keepass.activities.EntryEditActivity"
|
||||
android:windowSoftInputMode="adjustPan|stateAlwaysHidden" />
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<!-- About and Settings -->
|
||||
<activity
|
||||
android:name="com.kunzisoft.keepass.activities.AboutActivity"
|
||||
@@ -170,15 +178,19 @@
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService"
|
||||
android:name="com.kunzisoft.keepass.services.DatabaseTaskNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".notifications.AttachmentFileNotificationService"
|
||||
android:name="com.kunzisoft.keepass.services.AttachmentFileNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name="com.kunzisoft.keepass.notifications.ClipboardEntryNotificationService"
|
||||
android:name="com.kunzisoft.keepass.services.ClipboardEntryNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name="com.kunzisoft.keepass.services.AdvancedUnlockNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
<!-- Receiver for Autofill -->
|
||||
@@ -204,7 +216,7 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name="com.kunzisoft.keepass.notifications.KeyboardEntryNotificationService"
|
||||
android:name="com.kunzisoft.keepass.services.KeyboardEntryNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
|
||||
1061
app/src/main/java/com/igreenwood/loupe/Loupe.kt
Normal file
1061
app/src/main/java/com/igreenwood/loupe/Loupe.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ import android.content.Intent
|
||||
import android.content.IntentSender
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.inputmethod.InlineSuggestionsRequest
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -33,6 +34,7 @@ import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
|
||||
import com.kunzisoft.keepass.activities.helpers.SpecialMode
|
||||
import com.kunzisoft.keepass.autofill.AutofillHelper
|
||||
import com.kunzisoft.keepass.autofill.AutofillHelper.EXTRA_INLINE_SUGGESTIONS_REQUEST
|
||||
import com.kunzisoft.keepass.autofill.KeeAutofillService
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.search.SearchHelper
|
||||
@@ -40,7 +42,6 @@ import com.kunzisoft.keepass.model.RegisterInfo
|
||||
import com.kunzisoft.keepass.model.SearchInfo
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.utils.LOCK_ACTION
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
class AutofillLauncherActivity : AppCompatActivity() {
|
||||
@@ -84,9 +85,9 @@ class AutofillLauncherActivity : AppCompatActivity() {
|
||||
|
||||
private fun launchSelection(searchInfo: SearchInfo) {
|
||||
// Pass extra for Autofill (EXTRA_ASSIST_STRUCTURE)
|
||||
val assistStructure = AutofillHelper.retrieveAssistStructure(intent)
|
||||
val autofillComponent = AutofillHelper.retrieveAutofillComponent(intent)
|
||||
|
||||
if (assistStructure == null) {
|
||||
if (autofillComponent == null) {
|
||||
setResult(Activity.RESULT_CANCELED)
|
||||
finish()
|
||||
} else if (!KeeAutofillService.autofillAllowedFor(searchInfo.applicationId,
|
||||
@@ -105,21 +106,21 @@ class AutofillLauncherActivity : AppCompatActivity() {
|
||||
searchInfo,
|
||||
{ items ->
|
||||
// Items found
|
||||
AutofillHelper.buildResponse(this, items)
|
||||
AutofillHelper.buildResponseAndSetResult(this, items)
|
||||
finish()
|
||||
},
|
||||
{
|
||||
// Show the database UI to select the entry
|
||||
GroupActivity.launchForAutofillResult(this,
|
||||
readOnly,
|
||||
assistStructure,
|
||||
autofillComponent,
|
||||
searchInfo,
|
||||
false)
|
||||
},
|
||||
{
|
||||
// If database not open
|
||||
FileDatabaseSelectActivity.launchForAutofillResult(this,
|
||||
assistStructure,
|
||||
autofillComponent,
|
||||
searchInfo)
|
||||
}
|
||||
)
|
||||
@@ -196,7 +197,8 @@ class AutofillLauncherActivity : AppCompatActivity() {
|
||||
private const val KEY_REGISTER_INFO = "KEY_REGISTER_INFO"
|
||||
|
||||
fun getAuthIntentSenderForSelection(context: Context,
|
||||
searchInfo: SearchInfo? = null): IntentSender {
|
||||
searchInfo: SearchInfo? = null,
|
||||
inlineSuggestionsRequest: InlineSuggestionsRequest? = null): IntentSender {
|
||||
return PendingIntent.getActivity(context, 0,
|
||||
// Doesn't work with Parcelable (don't know why?)
|
||||
Intent(context, AutofillLauncherActivity::class.java).apply {
|
||||
@@ -205,6 +207,11 @@ class AutofillLauncherActivity : AppCompatActivity() {
|
||||
putExtra(KEY_SEARCH_DOMAIN, it.webDomain)
|
||||
putExtra(KEY_SEARCH_SCHEME, it.webScheme)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
inlineSuggestionsRequest?.let {
|
||||
putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it)
|
||||
}
|
||||
}
|
||||
},
|
||||
PendingIntent.FLAG_CANCEL_CURRENT).intentSender
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import com.google.android.material.appbar.CollapsingToolbarLayout
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
|
||||
import com.kunzisoft.keepass.activities.helpers.SpecialMode
|
||||
import com.kunzisoft.keepass.activities.lock.LockingActivity
|
||||
import com.kunzisoft.keepass.activities.lock.resetAppTimeoutWhenViewFocusedOrChanged
|
||||
import com.kunzisoft.keepass.database.element.Attachment
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.Entry
|
||||
@@ -49,20 +51,19 @@ import com.kunzisoft.keepass.icons.assignDatabaseIcon
|
||||
import com.kunzisoft.keepass.magikeyboard.MagikIME
|
||||
import com.kunzisoft.keepass.model.EntryAttachmentState
|
||||
import com.kunzisoft.keepass.model.StreamDirection
|
||||
import com.kunzisoft.keepass.notifications.AttachmentFileNotificationService
|
||||
import com.kunzisoft.keepass.notifications.ClipboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_ENTRY_HISTORY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RESTORE_ENTRY_HISTORY
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||
import com.kunzisoft.keepass.services.AttachmentFileNotificationService
|
||||
import com.kunzisoft.keepass.services.ClipboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_ENTRY_HISTORY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RELOAD_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RESTORE_ENTRY_HISTORY
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.tasks.AttachmentFileBinderManager
|
||||
import com.kunzisoft.keepass.timeout.ClipboardHelper
|
||||
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
||||
import com.kunzisoft.keepass.utils.MenuUtil
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import com.kunzisoft.keepass.utils.createDocument
|
||||
import com.kunzisoft.keepass.utils.onCreateDocumentResult
|
||||
import com.kunzisoft.keepass.utils.*
|
||||
import com.kunzisoft.keepass.view.EntryContentsView
|
||||
import com.kunzisoft.keepass.view.showActionError
|
||||
import com.kunzisoft.keepass.view.showActionErrorIfNeeded
|
||||
import java.util.*
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
@@ -125,6 +126,7 @@ class EntryActivity : LockingActivity() {
|
||||
historyView = findViewById(R.id.history_container)
|
||||
entryContentsView = findViewById(R.id.entry_contents)
|
||||
entryContentsView?.applyFontVisibilityToFields(PreferencesUtil.fieldFontIsInVisibility(this))
|
||||
entryContentsView?.setAttachmentCipherKey(mDatabase?.loadedCipherKey)
|
||||
entryProgress = findViewById(R.id.entry_progress)
|
||||
lockView = findViewById(R.id.lock_button)
|
||||
|
||||
@@ -133,7 +135,7 @@ class EntryActivity : LockingActivity() {
|
||||
}
|
||||
|
||||
// Focus view to reinitialize timeout
|
||||
resetAppTimeoutWhenViewFocusedOrChanged(coordinatorLayout)
|
||||
coordinatorLayout?.resetAppTimeoutWhenViewFocusedOrChanged(this)
|
||||
|
||||
// Init the clipboard helper
|
||||
clipboardHelper = ClipboardHelper(this)
|
||||
@@ -150,8 +152,13 @@ class EntryActivity : LockingActivity() {
|
||||
if (result.isSuccess)
|
||||
finish()
|
||||
}
|
||||
ACTION_DATABASE_RELOAD_TASK -> {
|
||||
// Close the current activity
|
||||
this.showActionErrorIfNeeded(result)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
coordinatorLayout?.showActionError(result)
|
||||
coordinatorLayout?.showActionErrorIfNeeded(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +205,7 @@ class EntryActivity : LockingActivity() {
|
||||
// Refresh Menu
|
||||
invalidateOptionsMenu()
|
||||
|
||||
val entryInfo = entry.getEntryInfo(Database.getInstance())
|
||||
|
||||
val entryInfo = entry.getEntryInfo(mDatabase)
|
||||
// Manage entry copy to start notification if allowed
|
||||
if (mFirstLaunchOfActivity) {
|
||||
// Manage entry to launch copying notification if allowed
|
||||
@@ -215,7 +221,9 @@ class EntryActivity : LockingActivity() {
|
||||
registerProgressTask()
|
||||
onActionTaskListener = object : AttachmentFileNotificationService.ActionTaskListener {
|
||||
override fun onAttachmentAction(fileUri: Uri, entryAttachmentState: EntryAttachmentState) {
|
||||
entryContentsView?.putAttachment(entryAttachmentState)
|
||||
if (entryAttachmentState.streamDirection != StreamDirection.UPLOAD) {
|
||||
entryContentsView?.putAttachment(entryAttachmentState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,23 +239,21 @@ class EntryActivity : LockingActivity() {
|
||||
|
||||
private fun fillEntryDataInContentsView(entry: Entry) {
|
||||
|
||||
val database = Database.getInstance()
|
||||
database.startManageEntry(entry)
|
||||
val entryInfo = entry.getEntryInfo(mDatabase)
|
||||
|
||||
// Assign title icon
|
||||
titleIconView?.assignDatabaseIcon(database.drawFactory, entry.icon, iconColor)
|
||||
titleIconView?.assignDatabaseIcon(mDatabase!!.drawFactory, entryInfo.icon, iconColor)
|
||||
|
||||
// Assign title text
|
||||
val entryTitle = entry.title
|
||||
val entryTitle = entryInfo.title
|
||||
collapsingToolbarLayout?.title = entryTitle
|
||||
toolbar?.title = entryTitle
|
||||
|
||||
// Assign basic fields
|
||||
entryContentsView?.assignUserName(entry.username) {
|
||||
database.startManageEntry(entry)
|
||||
clipboardHelper?.timeoutCopyToClipboard(entry.username,
|
||||
entryContentsView?.assignUserName(entryInfo.username) {
|
||||
clipboardHelper?.timeoutCopyToClipboard(entryInfo.username,
|
||||
getString(R.string.copy_field,
|
||||
getString(R.string.entry_user_name)))
|
||||
database.stopManageEntry(entry)
|
||||
}
|
||||
|
||||
val isFirstTimeAskAllowCopyPasswordAndProtectedFields =
|
||||
@@ -277,11 +283,9 @@ class EntryActivity : LockingActivity() {
|
||||
|
||||
val onPasswordCopyClickListener: View.OnClickListener? = if (allowCopyPasswordAndProtectedFields) {
|
||||
View.OnClickListener {
|
||||
database.startManageEntry(entry)
|
||||
clipboardHelper?.timeoutCopyToClipboard(entry.password,
|
||||
clipboardHelper?.timeoutCopyToClipboard(entryInfo.password,
|
||||
getString(R.string.copy_field,
|
||||
getString(R.string.entry_password)))
|
||||
database.stopManageEntry(entry)
|
||||
}
|
||||
} else {
|
||||
// If dialog not already shown
|
||||
@@ -291,44 +295,46 @@ class EntryActivity : LockingActivity() {
|
||||
null
|
||||
}
|
||||
}
|
||||
entryContentsView?.assignPassword(entry.password,
|
||||
entryContentsView?.assignPassword(entryInfo.password,
|
||||
allowCopyPasswordAndProtectedFields,
|
||||
onPasswordCopyClickListener)
|
||||
|
||||
//Assign OTP field
|
||||
entryContentsView?.assignOtp(entry.getOtpElement(), entryProgress,
|
||||
View.OnClickListener {
|
||||
entry.getOtpElement()?.let { otpElement ->
|
||||
clipboardHelper?.timeoutCopyToClipboard(
|
||||
otpElement.token,
|
||||
getString(R.string.copy_field, getString(R.string.entry_otp))
|
||||
)
|
||||
}
|
||||
})
|
||||
entry.getOtpElement()?.let { otpElement ->
|
||||
entryContentsView?.assignOtp(otpElement, entryProgress) {
|
||||
clipboardHelper?.timeoutCopyToClipboard(
|
||||
otpElement.token,
|
||||
getString(R.string.copy_field, getString(R.string.entry_otp))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
entryContentsView?.assignURL(entry.url)
|
||||
entryContentsView?.assignNotes(entry.notes)
|
||||
entryContentsView?.assignURL(entryInfo.url)
|
||||
entryContentsView?.assignNotes(entryInfo.notes)
|
||||
|
||||
// Assign custom fields
|
||||
if (mDatabase?.allowEntryCustomFields() == true) {
|
||||
entryContentsView?.clearExtraFields()
|
||||
entry.getExtraFields().forEach { field ->
|
||||
entryInfo.customFields.forEach { field ->
|
||||
val label = field.name
|
||||
val value = field.protectedValue
|
||||
val allowCopyProtectedField = !value.isProtected || allowCopyPasswordAndProtectedFields
|
||||
if (allowCopyProtectedField) {
|
||||
entryContentsView?.addExtraField(label, value, allowCopyProtectedField) {
|
||||
clipboardHelper?.timeoutCopyToClipboard(
|
||||
value.toString(),
|
||||
getString(R.string.copy_field, label)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// If dialog not already shown
|
||||
if (isFirstTimeAskAllowCopyPasswordAndProtectedFields) {
|
||||
entryContentsView?.addExtraField(label, value, allowCopyProtectedField, showWarningClipboardDialogOnClickListener)
|
||||
// OTP field is already managed in dedicated view
|
||||
if (label != OtpEntryFields.OTP_TOKEN_FIELD) {
|
||||
val value = field.protectedValue
|
||||
val allowCopyProtectedField = !value.isProtected || allowCopyPasswordAndProtectedFields
|
||||
if (allowCopyProtectedField) {
|
||||
entryContentsView?.addExtraField(label, value, allowCopyProtectedField) {
|
||||
clipboardHelper?.timeoutCopyToClipboard(
|
||||
value.toString(),
|
||||
getString(R.string.copy_field, label)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
entryContentsView?.addExtraField(label, value, allowCopyProtectedField, null)
|
||||
// If dialog not already shown
|
||||
if (isFirstTimeAskAllowCopyPasswordAndProtectedFields) {
|
||||
entryContentsView?.addExtraField(label, value, allowCopyProtectedField, showWarningClipboardDialogOnClickListener)
|
||||
} else {
|
||||
entryContentsView?.addExtraField(label, value, allowCopyProtectedField, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,24 +342,16 @@ class EntryActivity : LockingActivity() {
|
||||
entryContentsView?.setHiddenProtectedValue(!mShowPassword)
|
||||
|
||||
// Manage attachments
|
||||
mDatabase?.binaryPool?.let { binaryPool ->
|
||||
entryContentsView?.assignAttachments(entry.getAttachments(binaryPool).toSet(), StreamDirection.DOWNLOAD) { attachmentItem ->
|
||||
createDocument(this, attachmentItem.name)?.let { requestCode ->
|
||||
mAttachmentsToDownload[requestCode] = attachmentItem
|
||||
}
|
||||
entryContentsView?.assignAttachments(entryInfo.attachments.toSet(), StreamDirection.DOWNLOAD) { attachmentItem ->
|
||||
createDocument(this, attachmentItem.name)?.let { requestCode ->
|
||||
mAttachmentsToDownload[requestCode] = attachmentItem
|
||||
}
|
||||
}
|
||||
|
||||
// Assign dates
|
||||
entryContentsView?.assignCreationDate(entry.creationTime)
|
||||
entryContentsView?.assignModificationDate(entry.lastModificationTime)
|
||||
entryContentsView?.assignLastAccessDate(entry.lastAccessTime)
|
||||
entryContentsView?.setExpires(entry.isCurrentlyExpires)
|
||||
if (entry.expires) {
|
||||
entryContentsView?.assignExpiresDate(entry.expiryTime)
|
||||
} else {
|
||||
entryContentsView?.assignExpiresDate(getString(R.string.never))
|
||||
}
|
||||
entryContentsView?.assignCreationDate(entryInfo.creationTime)
|
||||
entryContentsView?.assignModificationDate(entryInfo.lastModificationTime)
|
||||
entryContentsView?.setExpires(entryInfo.expires, entryInfo.expiryTime)
|
||||
|
||||
// Manage history
|
||||
historyView?.visibility = if (mIsHistory) View.VISIBLE else View.GONE
|
||||
@@ -368,8 +366,6 @@ class EntryActivity : LockingActivity() {
|
||||
|
||||
// Assign special data
|
||||
entryContentsView?.assignUUID(entry.nodeId.id)
|
||||
|
||||
database.stopManageEntry(entry)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
@@ -407,6 +403,9 @@ class EntryActivity : LockingActivity() {
|
||||
menu.findItem(R.id.menu_save_database)?.isVisible = false
|
||||
menu.findItem(R.id.menu_edit)?.isVisible = false
|
||||
}
|
||||
if (mSpecialMode != SpecialMode.DEFAULT) {
|
||||
menu.findItem(R.id.menu_reload_database)?.isVisible = false
|
||||
}
|
||||
|
||||
val gotoUrl = menu.findItem(R.id.menu_goto_url)
|
||||
gotoUrl?.apply {
|
||||
@@ -500,6 +499,9 @@ class EntryActivity : LockingActivity() {
|
||||
R.id.menu_save_database -> {
|
||||
mProgressDatabaseTaskProvider?.startDatabaseSave(!mReadOnly)
|
||||
}
|
||||
R.id.menu_reload_database -> {
|
||||
mProgressDatabaseTaskProvider?.startDatabaseReload(false)
|
||||
}
|
||||
android.R.id.home -> finish() // close this activity and return to preview activity (if there is any)
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
|
||||
@@ -21,7 +21,6 @@ package com.kunzisoft.keepass.activities
|
||||
import android.app.Activity
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.app.assist.AssistStructure
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@@ -37,7 +36,6 @@ import android.widget.DatePicker
|
||||
import android.widget.TimePicker
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.NestedScrollView
|
||||
@@ -48,6 +46,8 @@ import com.kunzisoft.keepass.activities.dialogs.FileTooBigDialogFragment.Compani
|
||||
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
|
||||
import com.kunzisoft.keepass.activities.helpers.SelectFileHelper
|
||||
import com.kunzisoft.keepass.activities.lock.LockingActivity
|
||||
import com.kunzisoft.keepass.activities.lock.resetAppTimeoutWhenViewFocusedOrChanged
|
||||
import com.kunzisoft.keepass.autofill.AutofillComponent
|
||||
import com.kunzisoft.keepass.autofill.AutofillHelper
|
||||
import com.kunzisoft.keepass.database.element.*
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImage
|
||||
@@ -56,21 +56,22 @@ import com.kunzisoft.keepass.database.element.node.Node
|
||||
import com.kunzisoft.keepass.database.element.node.NodeId
|
||||
import com.kunzisoft.keepass.education.EntryEditActivityEducation
|
||||
import com.kunzisoft.keepass.model.*
|
||||
import com.kunzisoft.keepass.notifications.AttachmentFileNotificationService
|
||||
import com.kunzisoft.keepass.notifications.ClipboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.notifications.KeyboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.otp.OtpElement
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||
import com.kunzisoft.keepass.services.AttachmentFileNotificationService
|
||||
import com.kunzisoft.keepass.services.ClipboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RELOAD_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.services.KeyboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.tasks.AttachmentFileBinderManager
|
||||
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
||||
import com.kunzisoft.keepass.utils.MenuUtil
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import com.kunzisoft.keepass.view.ToolbarAction
|
||||
import com.kunzisoft.keepass.view.asError
|
||||
import com.kunzisoft.keepass.view.showActionError
|
||||
import com.kunzisoft.keepass.view.showActionErrorIfNeeded
|
||||
import com.kunzisoft.keepass.view.updateLockPaddingLeft
|
||||
import org.joda.time.DateTime
|
||||
import java.util.*
|
||||
@@ -97,7 +98,7 @@ class EntryEditActivity : LockingActivity(),
|
||||
private var coordinatorLayout: CoordinatorLayout? = null
|
||||
private var scrollView: NestedScrollView? = null
|
||||
private var entryEditFragment: EntryEditFragment? = null
|
||||
private var entryEditAddToolBar: Toolbar? = null
|
||||
private var entryEditAddToolBar: ToolbarAction? = null
|
||||
private var validateButton: View? = null
|
||||
private var lockView: View? = null
|
||||
|
||||
@@ -105,7 +106,7 @@ class EntryEditActivity : LockingActivity(),
|
||||
private var mSelectFileHelper: SelectFileHelper? = null
|
||||
private var mAttachmentFileBinderManager: AttachmentFileBinderManager? = null
|
||||
private var mAllowMultipleAttachments: Boolean = false
|
||||
private var mTempAttachments = ArrayList<Attachment>()
|
||||
private var mTempAttachments = ArrayList<EntryAttachmentState>()
|
||||
|
||||
// Education
|
||||
private var entryEditActivityEducation: EntryEditActivityEducation? = null
|
||||
@@ -117,11 +118,12 @@ class EntryEditActivity : LockingActivity(),
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_entry_edit)
|
||||
|
||||
val toolbar = findViewById<Toolbar>(R.id.toolbar)
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp)
|
||||
// Bottom Bar
|
||||
entryEditAddToolBar = findViewById(R.id.entry_edit_bottom_bar)
|
||||
setSupportActionBar(entryEditAddToolBar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayShowHomeEnabled(true)
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
|
||||
coordinatorLayout = findViewById(R.id.entry_edit_coordinator_layout)
|
||||
|
||||
@@ -134,7 +136,7 @@ class EntryEditActivity : LockingActivity(),
|
||||
}
|
||||
|
||||
// Focus view to reinitialize timeout
|
||||
resetAppTimeoutWhenViewFocusedOrChanged(coordinatorLayout)
|
||||
coordinatorLayout?.resetAppTimeoutWhenViewFocusedOrChanged(this)
|
||||
|
||||
stopService(Intent(this, ClipboardEntryNotificationService::class.java))
|
||||
stopService(Intent(this, KeyboardEntryNotificationService::class.java))
|
||||
@@ -196,14 +198,14 @@ class EntryEditActivity : LockingActivity(),
|
||||
// Build fragment to manage entry modification
|
||||
entryEditFragment = supportFragmentManager.findFragmentByTag(ENTRY_EDIT_FRAGMENT_TAG) as? EntryEditFragment?
|
||||
if (entryEditFragment == null) {
|
||||
entryEditFragment = EntryEditFragment.getInstance(tempEntryInfo)
|
||||
entryEditFragment = EntryEditFragment.getInstance(tempEntryInfo, mDatabase?.loadedCipherKey)
|
||||
}
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.entry_edit_contents, entryEditFragment!!, ENTRY_EDIT_FRAGMENT_TAG)
|
||||
.commit()
|
||||
entryEditFragment?.apply {
|
||||
drawFactory = mDatabase?.drawFactory
|
||||
setOnDateClickListener = View.OnClickListener {
|
||||
setOnDateClickListener = {
|
||||
expiryTime.date.let { expiresDate ->
|
||||
val dateTime = DateTime(expiresDate)
|
||||
val defaultYear = dateTime.year
|
||||
@@ -234,51 +236,6 @@ class EntryEditActivity : LockingActivity(),
|
||||
mTempAttachments = savedInstanceState.getParcelableArrayList(TEMP_ATTACHMENTS) ?: mTempAttachments
|
||||
}
|
||||
|
||||
// Assign title
|
||||
title = if (mIsNew) getString(R.string.add_entry) else getString(R.string.edit_entry)
|
||||
|
||||
// Bottom Bar
|
||||
entryEditAddToolBar = findViewById(R.id.entry_edit_bottom_bar)
|
||||
entryEditAddToolBar?.apply {
|
||||
menuInflater.inflate(R.menu.entry_edit, menu)
|
||||
|
||||
menu.findItem(R.id.menu_add_field).apply {
|
||||
val allowCustomField = mDatabase?.allowEntryCustomFields() == true
|
||||
isEnabled = allowCustomField
|
||||
isVisible = allowCustomField
|
||||
}
|
||||
|
||||
// Attachment not compatible below KitKat
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
menu.findItem(R.id.menu_add_attachment).isVisible = false
|
||||
}
|
||||
|
||||
menu.findItem(R.id.menu_add_otp).apply {
|
||||
val allowOTP = mDatabase?.allowOTP == true
|
||||
isEnabled = allowOTP
|
||||
// OTP not compatible below KitKat
|
||||
isVisible = allowOTP && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT
|
||||
}
|
||||
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.menu_add_field -> {
|
||||
addNewCustomField()
|
||||
true
|
||||
}
|
||||
R.id.menu_add_attachment -> {
|
||||
addNewAttachment(item)
|
||||
true
|
||||
}
|
||||
R.id.menu_add_otp -> {
|
||||
setupOTP()
|
||||
true
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// To retrieve attachment
|
||||
mSelectFileHelper = SelectFileHelper(this)
|
||||
mAttachmentFileBinderManager = AttachmentFileBinderManager(this)
|
||||
@@ -334,8 +291,13 @@ class EntryEditActivity : LockingActivity(),
|
||||
Log.e(TAG, "Unable to retrieve entry after database action", e)
|
||||
}
|
||||
}
|
||||
ACTION_DATABASE_RELOAD_TASK -> {
|
||||
// Close the current activity
|
||||
this.showActionErrorIfNeeded(result)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
coordinatorLayout?.showActionError(result)
|
||||
coordinatorLayout?.showActionErrorIfNeeded(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +322,7 @@ class EntryEditActivity : LockingActivity(),
|
||||
// Build Autofill response with the entry selected
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
mDatabase?.let { database ->
|
||||
AutofillHelper.buildResponse(this@EntryEditActivity,
|
||||
AutofillHelper.buildResponseAndSetResult(this@EntryEditActivity,
|
||||
entry.getEntryInfo(database))
|
||||
}
|
||||
}
|
||||
@@ -392,29 +354,27 @@ class EntryEditActivity : LockingActivity(),
|
||||
when (entryAttachmentState.downloadState) {
|
||||
AttachmentState.START -> {
|
||||
entryEditFragment?.apply {
|
||||
// When only one attachment is allowed
|
||||
if (!mAllowMultipleAttachments) {
|
||||
clearAttachments()
|
||||
}
|
||||
putAttachment(entryAttachmentState)
|
||||
// Scroll to the attachment position
|
||||
getAttachmentViewPosition(entryAttachmentState) {
|
||||
scrollView?.smoothScrollTo(0, it.toInt())
|
||||
}
|
||||
}
|
||||
} // Add in temp list
|
||||
mTempAttachments.add(entryAttachmentState)
|
||||
}
|
||||
AttachmentState.IN_PROGRESS -> {
|
||||
entryEditFragment?.putAttachment(entryAttachmentState)
|
||||
}
|
||||
AttachmentState.COMPLETE -> {
|
||||
entryEditFragment?.apply {
|
||||
putAttachment(entryAttachmentState)
|
||||
// Scroll to the attachment position
|
||||
getAttachmentViewPosition(entryAttachmentState) {
|
||||
entryEditFragment?.putAttachment(entryAttachmentState) {
|
||||
entryEditFragment?.getAttachmentViewPosition(entryAttachmentState) {
|
||||
scrollView?.smoothScrollTo(0, it.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
AttachmentState.CANCELED -> {
|
||||
entryEditFragment?.removeAttachment(entryAttachmentState)
|
||||
}
|
||||
AttachmentState.ERROR -> {
|
||||
entryEditFragment?.removeAttachment(entryAttachmentState)
|
||||
coordinatorLayout?.let {
|
||||
@@ -478,8 +438,12 @@ class EntryEditActivity : LockingActivity(),
|
||||
}
|
||||
|
||||
override fun onEditCustomFieldApproved(oldField: Field, newField: Field) {
|
||||
verifyNameField(newField) {
|
||||
if (oldField.name.equals(newField.name, true)) {
|
||||
entryEditFragment?.replaceExtraField(oldField, newField)
|
||||
} else {
|
||||
verifyNameField(newField) {
|
||||
entryEditFragment?.replaceExtraField(oldField, newField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,10 +470,12 @@ class EntryEditActivity : LockingActivity(),
|
||||
|
||||
private fun startUploadAttachment(attachmentToUploadUri: Uri?, attachment: Attachment?) {
|
||||
if (attachmentToUploadUri != null && attachment != null) {
|
||||
// When only one attachment is allowed
|
||||
if (!mAllowMultipleAttachments) {
|
||||
entryEditFragment?.clearAttachments()
|
||||
}
|
||||
// Start uploading in service
|
||||
mAttachmentFileBinderManager?.startUploadAttachment(attachmentToUploadUri, attachment)
|
||||
// Add in temp list
|
||||
mTempAttachments.add(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +528,7 @@ class EntryEditActivity : LockingActivity(),
|
||||
* Saves the new entry or update an existing entry in the database
|
||||
*/
|
||||
private fun saveEntry() {
|
||||
mAttachmentFileBinderManager?.stopUploadAllAttachments()
|
||||
// Get the temp entry
|
||||
entryEditFragment?.getEntryInfo()?.let { newEntryInfo ->
|
||||
|
||||
@@ -573,14 +540,34 @@ class EntryEditActivity : LockingActivity(),
|
||||
Entry(mEntry!!)
|
||||
}?.let { newEntry ->
|
||||
|
||||
// Do not save entry in upload progression
|
||||
mTempAttachments.forEach { attachmentState ->
|
||||
if (attachmentState.streamDirection == StreamDirection.UPLOAD) {
|
||||
when (attachmentState.downloadState) {
|
||||
AttachmentState.START,
|
||||
AttachmentState.IN_PROGRESS,
|
||||
AttachmentState.CANCELED,
|
||||
AttachmentState.ERROR -> {
|
||||
// Remove attachment not finished from info
|
||||
newEntryInfo.attachments = newEntryInfo.attachments.toMutableList().apply {
|
||||
remove(attachmentState.attachment)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build info
|
||||
newEntry.setEntryInfo(mDatabase, newEntryInfo)
|
||||
|
||||
// Delete temp attachment if not used
|
||||
mTempAttachments.forEach {
|
||||
mTempAttachments.forEach { tempAttachmentState ->
|
||||
val tempAttachment = tempAttachmentState.attachment
|
||||
mDatabase?.binaryPool?.let { binaryPool ->
|
||||
if (!newEntry.getAttachments(binaryPool).contains(it)) {
|
||||
mDatabase?.removeAttachmentIfNotUsed(it)
|
||||
if (!newEntry.getAttachments(binaryPool).contains(tempAttachment)) {
|
||||
mDatabase?.removeAttachmentIfNotUsed(tempAttachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,18 +596,30 @@ class EntryEditActivity : LockingActivity(),
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
|
||||
val inflater = menuInflater
|
||||
inflater.inflate(R.menu.database, menu)
|
||||
// Save database not needed here
|
||||
menu.findItem(R.id.menu_save_database)?.isVisible = false
|
||||
MenuUtil.contributionMenuInflater(inflater, menu)
|
||||
|
||||
menuInflater.inflate(R.menu.entry_edit, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
|
||||
|
||||
menu?.findItem(R.id.menu_add_field)?.apply {
|
||||
val allowCustomField = mDatabase?.allowEntryCustomFields() == true
|
||||
isEnabled = allowCustomField
|
||||
isVisible = allowCustomField
|
||||
}
|
||||
|
||||
// Attachment not compatible below KitKat
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
menu?.findItem(R.id.menu_add_attachment)?.isVisible = false
|
||||
}
|
||||
|
||||
menu?.findItem(R.id.menu_add_otp)?.apply {
|
||||
val allowOTP = mDatabase?.allowOTP == true
|
||||
isEnabled = allowOTP
|
||||
// OTP not compatible below KitKat
|
||||
isVisible = allowOTP && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT
|
||||
}
|
||||
|
||||
entryEditActivityEducation?.let {
|
||||
Handler(Looper.getMainLooper()).post { performedNextEducation(it) }
|
||||
}
|
||||
@@ -672,11 +671,16 @@ class EntryEditActivity : LockingActivity(),
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.menu_save_database -> {
|
||||
mProgressDatabaseTaskProvider?.startDatabaseSave(!mReadOnly)
|
||||
R.id.menu_add_field -> {
|
||||
addNewCustomField()
|
||||
return true
|
||||
}
|
||||
R.id.menu_contribute -> {
|
||||
MenuUtil.onContributionItemSelected(this)
|
||||
R.id.menu_add_attachment -> {
|
||||
addNewAttachment(item)
|
||||
return true
|
||||
}
|
||||
R.id.menu_add_otp -> {
|
||||
setupOTP()
|
||||
return true
|
||||
}
|
||||
android.R.id.home -> {
|
||||
@@ -786,6 +790,7 @@ class EntryEditActivity : LockingActivity(),
|
||||
.setMessage(R.string.discard_changes)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.discard) { _, _ ->
|
||||
mAttachmentFileBinderManager?.stopUploadAllAttachments()
|
||||
backPressedAlreadyApproved = true
|
||||
approved.invoke()
|
||||
}.create().show()
|
||||
@@ -908,7 +913,7 @@ class EntryEditActivity : LockingActivity(),
|
||||
*/
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
fun launchForAutofillResult(activity: Activity,
|
||||
assistStructure: AssistStructure,
|
||||
autofillComponent: AutofillComponent,
|
||||
group: Group,
|
||||
searchInfo: SearchInfo? = null) {
|
||||
if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) {
|
||||
@@ -916,7 +921,7 @@ class EntryEditActivity : LockingActivity(),
|
||||
intent.putExtra(KEY_PARENT, group.nodeId)
|
||||
AutofillHelper.startActivityForAutofillResult(activity,
|
||||
intent,
|
||||
assistStructure,
|
||||
autofillComponent,
|
||||
searchInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,8 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
@@ -37,9 +35,11 @@ import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.dialogs.GeneratePasswordDialogFragment
|
||||
import com.kunzisoft.keepass.activities.lock.resetAppTimeoutWhenViewFocusedOrChanged
|
||||
import com.kunzisoft.keepass.activities.stylish.StylishFragment
|
||||
import com.kunzisoft.keepass.adapters.EntryAttachmentsItemsAdapter
|
||||
import com.kunzisoft.keepass.database.element.Attachment
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.DateInstant
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImage
|
||||
import com.kunzisoft.keepass.education.EntryEditActivityEducation
|
||||
@@ -48,6 +48,7 @@ import com.kunzisoft.keepass.icons.assignDatabaseIcon
|
||||
import com.kunzisoft.keepass.model.*
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.view.ExpirationView
|
||||
import com.kunzisoft.keepass.view.applyFontVisibility
|
||||
import com.kunzisoft.keepass.view.collapse
|
||||
import com.kunzisoft.keepass.view.expand
|
||||
@@ -62,8 +63,7 @@ class EntryEditFragment: StylishFragment() {
|
||||
private lateinit var entryPasswordLayoutView: TextInputLayout
|
||||
private lateinit var entryPasswordView: EditText
|
||||
private lateinit var entryPasswordGeneratorView: View
|
||||
private lateinit var entryExpiresCheckBox: CompoundButton
|
||||
private lateinit var entryExpiresTextView: TextView
|
||||
private lateinit var entryExpirationView: ExpirationView
|
||||
private lateinit var entryNotesView: EditText
|
||||
private lateinit var extraFieldsContainerView: View
|
||||
private lateinit var extraFieldsListView: ViewGroup
|
||||
@@ -74,10 +74,9 @@ class EntryEditFragment: StylishFragment() {
|
||||
|
||||
private var fontInVisibility: Boolean = false
|
||||
private var iconColor: Int = 0
|
||||
private var expiresInstant: DateInstant = DateInstant.IN_ONE_MONTH
|
||||
|
||||
var drawFactory: IconDrawableFactory? = null
|
||||
var setOnDateClickListener: View.OnClickListener? = null
|
||||
var setOnDateClickListener: (() -> Unit)? = null
|
||||
var setOnPasswordGeneratorClickListener: View.OnClickListener? = null
|
||||
var setOnIconViewClickListener: View.OnClickListener? = null
|
||||
var setOnEditCustomField: ((Field) -> Unit)? = null
|
||||
@@ -85,6 +84,7 @@ class EntryEditFragment: StylishFragment() {
|
||||
|
||||
// Elements to modify the current entry
|
||||
private var mEntryInfo = EntryInfo()
|
||||
private var mBinaryCipherKey: Database.LoadedKey? = null
|
||||
private var mLastFocusedEditField: FocusedEditField? = null
|
||||
private var mExtraViewToRequestFocus: EditText? = null
|
||||
|
||||
@@ -111,12 +111,8 @@ class EntryEditFragment: StylishFragment() {
|
||||
entryPasswordGeneratorView.setOnClickListener {
|
||||
setOnPasswordGeneratorClickListener?.onClick(it)
|
||||
}
|
||||
entryExpiresCheckBox = rootView.findViewById(R.id.entry_edit_expires_checkbox)
|
||||
entryExpiresTextView = rootView.findViewById(R.id.entry_edit_expires_text)
|
||||
entryExpiresTextView.setOnClickListener {
|
||||
if (entryExpiresCheckBox.isChecked)
|
||||
setOnDateClickListener?.onClick(it)
|
||||
}
|
||||
entryExpirationView = rootView.findViewById(R.id.entry_edit_expiration)
|
||||
entryExpirationView.setOnDateClickListener = setOnDateClickListener
|
||||
|
||||
entryNotesView = rootView.findViewById(R.id.entry_edit_notes)
|
||||
|
||||
@@ -126,6 +122,7 @@ class EntryEditFragment: StylishFragment() {
|
||||
attachmentsContainerView = rootView.findViewById(R.id.entry_attachments_container)
|
||||
attachmentsListView = rootView.findViewById(R.id.entry_attachments_list)
|
||||
attachmentsAdapter = EntryAttachmentsItemsAdapter(requireContext())
|
||||
attachmentsAdapter.binaryCipherKey = arguments?.getSerializable(KEY_BINARY_CIPHER_KEY) as? Database.LoadedKey?
|
||||
attachmentsAdapter.onListSizeChangedListener = { previousSize, newSize ->
|
||||
if (previousSize > 0 && newSize == 0) {
|
||||
attachmentsContainerView.collapse(true)
|
||||
@@ -139,15 +136,13 @@ class EntryEditFragment: StylishFragment() {
|
||||
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
}
|
||||
|
||||
entryExpiresCheckBox.setOnCheckedChangeListener { _, _ ->
|
||||
assignExpiresDateText()
|
||||
}
|
||||
|
||||
// Retrieve the textColor to tint the icon
|
||||
val taIconColor = contextThemed?.theme?.obtainStyledAttributes(intArrayOf(android.R.attr.textColor))
|
||||
iconColor = taIconColor?.getColor(0, Color.WHITE) ?: Color.WHITE
|
||||
taIconColor?.recycle()
|
||||
|
||||
rootView?.resetAppTimeoutWhenViewFocusedOrChanged(requireContext())
|
||||
|
||||
// Retrieve the new entry after an orientation change
|
||||
if (arguments?.containsKey(KEY_TEMP_ENTRY_INFO) == true)
|
||||
mEntryInfo = arguments?.getParcelable(KEY_TEMP_ENTRY_INFO) ?: mEntryInfo
|
||||
@@ -175,7 +170,7 @@ class EntryEditFragment: StylishFragment() {
|
||||
setOnEditCustomField = null
|
||||
}
|
||||
|
||||
fun getEntryInfo(): EntryInfo? {
|
||||
fun getEntryInfo(): EntryInfo {
|
||||
populateEntryWithViews()
|
||||
return mEntryInfo
|
||||
}
|
||||
@@ -280,41 +275,20 @@ class EntryEditFragment: StylishFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun assignExpiresDateText() {
|
||||
entryExpiresTextView.text = if (entryExpiresCheckBox.isChecked) {
|
||||
entryExpiresTextView.setOnClickListener(setOnDateClickListener)
|
||||
expiresInstant.getDateTimeString(resources)
|
||||
} else {
|
||||
entryExpiresTextView.setOnClickListener(null)
|
||||
resources.getString(R.string.never)
|
||||
}
|
||||
if (fontInVisibility)
|
||||
entryExpiresTextView.applyFontVisibility()
|
||||
}
|
||||
|
||||
var expires: Boolean
|
||||
get() {
|
||||
return entryExpiresCheckBox.isChecked
|
||||
return entryExpirationView.expires
|
||||
}
|
||||
set(value) {
|
||||
if (!value) {
|
||||
expiresInstant = DateInstant.IN_ONE_MONTH
|
||||
}
|
||||
entryExpiresCheckBox.isChecked = value
|
||||
assignExpiresDateText()
|
||||
entryExpirationView.expires = value
|
||||
}
|
||||
|
||||
var expiryTime: DateInstant
|
||||
get() {
|
||||
return if (expires)
|
||||
expiresInstant
|
||||
else
|
||||
DateInstant.NEVER_EXPIRE
|
||||
return entryExpirationView.expiryTime
|
||||
}
|
||||
set(value) {
|
||||
if (expires)
|
||||
expiresInstant = value
|
||||
assignExpiresDateText()
|
||||
entryExpirationView.expiryTime = value
|
||||
}
|
||||
|
||||
var notes: String
|
||||
@@ -499,9 +473,13 @@ class EntryEditFragment: StylishFragment() {
|
||||
return attachmentsAdapter.contains(attachment)
|
||||
}
|
||||
|
||||
fun putAttachment(attachment: EntryAttachmentState) {
|
||||
fun putAttachment(attachment: EntryAttachmentState,
|
||||
onPreviewLoaded: (()-> Unit)? = null) {
|
||||
attachmentsContainerView.visibility = View.VISIBLE
|
||||
attachmentsAdapter.putItem(attachment)
|
||||
attachmentsAdapter.onBinaryPreviewLoaded = {
|
||||
onPreviewLoaded?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAttachment(attachment: EntryAttachmentState) {
|
||||
@@ -525,6 +503,7 @@ class EntryEditFragment: StylishFragment() {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
populateEntryWithViews()
|
||||
outState.putParcelable(KEY_TEMP_ENTRY_INFO, mEntryInfo)
|
||||
outState.putSerializable(KEY_BINARY_CIPHER_KEY, mBinaryCipherKey)
|
||||
outState.putParcelable(KEY_LAST_FOCUSED_FIELD, mLastFocusedEditField)
|
||||
|
||||
super.onSaveInstanceState(outState)
|
||||
@@ -532,12 +511,15 @@ class EntryEditFragment: StylishFragment() {
|
||||
|
||||
companion object {
|
||||
const val KEY_TEMP_ENTRY_INFO = "KEY_TEMP_ENTRY_INFO"
|
||||
const val KEY_BINARY_CIPHER_KEY = "KEY_BINARY_CIPHER_KEY"
|
||||
const val KEY_LAST_FOCUSED_FIELD = "KEY_LAST_FOCUSED_FIELD"
|
||||
|
||||
fun getInstance(entryInfo: EntryInfo?): EntryEditFragment {
|
||||
fun getInstance(entryInfo: EntryInfo?,
|
||||
loadedKey: Database.LoadedKey?): EntryEditFragment {
|
||||
return EntryEditFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(KEY_TEMP_ENTRY_INFO, entryInfo)
|
||||
putSerializable(KEY_BINARY_CIPHER_KEY, loadedKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
package com.kunzisoft.keepass.activities
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.assist.AssistStructure
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@@ -36,7 +35,6 @@ import androidx.activity.viewModels
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
@@ -49,16 +47,18 @@ import com.kunzisoft.keepass.activities.helpers.SpecialMode
|
||||
import com.kunzisoft.keepass.activities.selection.SpecialModeActivity
|
||||
import com.kunzisoft.keepass.adapters.FileDatabaseHistoryAdapter
|
||||
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
|
||||
import com.kunzisoft.keepass.autofill.AutofillComponent
|
||||
import com.kunzisoft.keepass.autofill.AutofillHelper
|
||||
import com.kunzisoft.keepass.database.action.ProgressDatabaseTaskProvider
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.education.FileDatabaseSelectActivityEducation
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
import com.kunzisoft.keepass.model.RegisterInfo
|
||||
import com.kunzisoft.keepass.model.SearchInfo
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_LOAD_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.DATABASE_URI_KEY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.KEY_FILE_URI_KEY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_LOAD_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.DATABASE_URI_KEY
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.utils.*
|
||||
import com.kunzisoft.keepass.view.asError
|
||||
@@ -162,7 +162,7 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
}
|
||||
|
||||
// Observe list of databases
|
||||
databaseFilesViewModel.databaseFilesLoaded.observe(this, Observer { databaseFiles ->
|
||||
databaseFilesViewModel.databaseFilesLoaded.observe(this) { databaseFiles ->
|
||||
when (databaseFiles.databaseFileAction) {
|
||||
DatabaseFilesViewModel.DatabaseFileAction.NONE -> {
|
||||
mAdapterDatabaseHistory?.replaceAllDatabaseFileHistoryList(databaseFiles.databaseFileList)
|
||||
@@ -186,13 +186,13 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
}
|
||||
}
|
||||
databaseFilesViewModel.consumeAction()
|
||||
})
|
||||
}
|
||||
|
||||
// Observe default database
|
||||
databaseFilesViewModel.defaultDatabase.observe(this, Observer {
|
||||
databaseFilesViewModel.defaultDatabase.observe(this) {
|
||||
// Retrieve settings for default database
|
||||
mAdapterDatabaseHistory?.setDefaultDatabase(it)
|
||||
})
|
||||
}
|
||||
|
||||
// Attach the dialog thread to this activity
|
||||
mProgressDatabaseTaskProvider = ProgressDatabaseTaskProvider(this).apply {
|
||||
@@ -200,8 +200,8 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
when (actionTask) {
|
||||
ACTION_DATABASE_CREATE_TASK -> {
|
||||
result.data?.getParcelable<Uri?>(DATABASE_URI_KEY)?.let { databaseUri ->
|
||||
val keyFileUri = result.data?.getParcelable<Uri?>(KEY_FILE_URI_KEY)
|
||||
databaseFilesViewModel.addDatabaseFile(databaseUri, keyFileUri)
|
||||
val mainCredential = result.data?.getParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY) ?: MainCredential()
|
||||
databaseFilesViewModel.addDatabaseFile(databaseUri, mainCredential.keyFileUri)
|
||||
}
|
||||
}
|
||||
ACTION_DATABASE_LOAD_TASK -> {
|
||||
@@ -237,10 +237,10 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
|
||||
private fun fileNoFoundAction(e: FileNotFoundException) {
|
||||
val error = getString(R.string.file_not_found_content)
|
||||
Log.e(TAG, error, e)
|
||||
coordinatorLayout?.let {
|
||||
Snackbar.make(it, error, Snackbar.LENGTH_LONG).asError().show()
|
||||
}
|
||||
Log.e(TAG, error, e)
|
||||
}
|
||||
|
||||
private fun launchPasswordActivity(databaseUri: Uri, keyFile: Uri?) {
|
||||
@@ -331,9 +331,7 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
outState.putParcelable(EXTRA_DATABASE_URI, mDatabaseFileUri)
|
||||
}
|
||||
|
||||
override fun onAssignKeyDialogPositiveClick(
|
||||
masterPasswordChecked: Boolean, masterPassword: String?,
|
||||
keyFileChecked: Boolean, keyFile: Uri?) {
|
||||
override fun onAssignKeyDialogPositiveClick(mainCredential: MainCredential) {
|
||||
|
||||
try {
|
||||
mDatabaseFileUri?.let { databaseUri ->
|
||||
@@ -341,10 +339,7 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
// Create the new database
|
||||
mProgressDatabaseTaskProvider?.startDatabaseCreate(
|
||||
databaseUri,
|
||||
masterPasswordChecked,
|
||||
masterPassword,
|
||||
keyFileChecked,
|
||||
keyFile
|
||||
mainCredential
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -354,11 +349,7 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAssignKeyDialogNegativeClick(
|
||||
masterPasswordChecked: Boolean, masterPassword: String?,
|
||||
keyFileChecked: Boolean, keyFile: Uri?) {
|
||||
|
||||
}
|
||||
override fun onAssignKeyDialogNegativeClick(mainCredential: MainCredential) {}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
@@ -435,8 +426,8 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> UriUtil.gotoUrl(this, R.string.file_manager_explanation_url)
|
||||
}
|
||||
|
||||
return MenuUtil.onDefaultMenuOptionsItemSelected(this, item) && super.onOptionsItemSelected(item)
|
||||
MenuUtil.onDefaultMenuOptionsItemSelected(this, item)
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -502,11 +493,11 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
fun launchForAutofillResult(activity: Activity,
|
||||
assistStructure: AssistStructure,
|
||||
autofillComponent: AutofillComponent,
|
||||
searchInfo: SearchInfo? = null) {
|
||||
AutofillHelper.startActivityForAutofillResult(activity,
|
||||
Intent(activity, FileDatabaseSelectActivity::class.java),
|
||||
assistStructure,
|
||||
autofillComponent,
|
||||
searchInfo)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
package com.kunzisoft.keepass.activities
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.SearchManager
|
||||
import android.app.assist.AssistStructure
|
||||
import android.app.TimePickerDialog
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -34,9 +35,7 @@ import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import android.widget.*
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.SearchView
|
||||
@@ -50,38 +49,41 @@ import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
|
||||
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
|
||||
import com.kunzisoft.keepass.activities.helpers.SpecialMode
|
||||
import com.kunzisoft.keepass.activities.lock.LockingActivity
|
||||
import com.kunzisoft.keepass.activities.lock.resetAppTimeoutWhenViewFocusedOrChanged
|
||||
import com.kunzisoft.keepass.adapters.SearchEntryCursorAdapter
|
||||
import com.kunzisoft.keepass.autofill.AutofillComponent
|
||||
import com.kunzisoft.keepass.autofill.AutofillHelper
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.Entry
|
||||
import com.kunzisoft.keepass.database.element.Group
|
||||
import com.kunzisoft.keepass.database.element.SortNodeEnum
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImage
|
||||
import com.kunzisoft.keepass.database.element.*
|
||||
import com.kunzisoft.keepass.database.element.node.Node
|
||||
import com.kunzisoft.keepass.database.element.node.NodeId
|
||||
import com.kunzisoft.keepass.database.element.node.Type
|
||||
import com.kunzisoft.keepass.database.search.SearchHelper
|
||||
import com.kunzisoft.keepass.education.GroupActivityEducation
|
||||
import com.kunzisoft.keepass.icons.assignDatabaseIcon
|
||||
import com.kunzisoft.keepass.model.GroupInfo
|
||||
import com.kunzisoft.keepass.model.RegisterInfo
|
||||
import com.kunzisoft.keepass.model.SearchInfo
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_COPY_NODES_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_GROUP_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_NODES_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_MOVE_NODES_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_GROUP_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.NEW_NODES_KEY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.OLD_NODES_KEY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.getListNodesFromBundle
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_COPY_NODES_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_GROUP_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_NODES_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_MOVE_NODES_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RELOAD_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_GROUP_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.NEW_NODES_KEY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.OLD_NODES_KEY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getListNodesFromBundle
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
||||
import com.kunzisoft.keepass.utils.MenuUtil
|
||||
import com.kunzisoft.keepass.view.*
|
||||
import org.joda.time.DateTime
|
||||
|
||||
class GroupActivity : LockingActivity(),
|
||||
GroupEditDialogFragment.EditGroupListener,
|
||||
IconPickerDialogFragment.IconPickerListener,
|
||||
DatePickerDialog.OnDateSetListener,
|
||||
TimePickerDialog.OnTimeSetListener,
|
||||
ListNodesFragment.NodeClickListener,
|
||||
ListNodesFragment.NodesActionMenuListener,
|
||||
DeleteNodesDialogFragment.DeleteNodeListener,
|
||||
@@ -153,7 +155,7 @@ class GroupActivity : LockingActivity(),
|
||||
taTextColor.recycle()
|
||||
|
||||
// Focus view to reinitialize timeout
|
||||
resetAppTimeoutWhenViewFocusedOrChanged(rootContainerView)
|
||||
rootContainerView?.resetAppTimeoutWhenViewFocusedOrChanged(this)
|
||||
|
||||
// Retrieve elements after an orientation change
|
||||
if (savedInstanceState != null) {
|
||||
@@ -227,10 +229,10 @@ class GroupActivity : LockingActivity(),
|
||||
currentGroup, searchInfo)
|
||||
onLaunchActivitySpecialMode()
|
||||
},
|
||||
{ searchInfo, assistStructure ->
|
||||
{ searchInfo, autofillComponent ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
EntryEditActivity.launchForAutofillResult(this@GroupActivity,
|
||||
assistStructure,
|
||||
autofillComponent,
|
||||
currentGroup, searchInfo)
|
||||
onLaunchActivitySpecialMode()
|
||||
} else {
|
||||
@@ -316,7 +318,12 @@ class GroupActivity : LockingActivity(),
|
||||
if (result.isSuccess) {
|
||||
|
||||
// Rebuild all the list to avoid bug when delete node from sort
|
||||
mListNodesFragment?.rebuildList()
|
||||
try {
|
||||
mListNodesFragment?.rebuildList()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to rebuild the list after deletion")
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
// Add trash in views list if it doesn't exists
|
||||
if (database.isRecycleBinEnabled) {
|
||||
@@ -336,9 +343,20 @@ class GroupActivity : LockingActivity(),
|
||||
}
|
||||
}
|
||||
}
|
||||
ACTION_DATABASE_RELOAD_TASK -> {
|
||||
// Reload the current activity
|
||||
if (result.isSuccess) {
|
||||
startActivity(intent)
|
||||
finish()
|
||||
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
} else {
|
||||
this.showActionErrorIfNeeded(result)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coordinatorLayout?.showActionError(result)
|
||||
coordinatorLayout?.showActionErrorIfNeeded(result)
|
||||
|
||||
finishNodeAction()
|
||||
|
||||
@@ -659,7 +677,7 @@ class GroupActivity : LockingActivity(),
|
||||
// Build response with the entry selected
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && mDatabase != null) {
|
||||
mDatabase?.let { database ->
|
||||
AutofillHelper.buildResponse(this,
|
||||
AutofillHelper.buildResponseAndSetResult(this,
|
||||
entry.getEntryInfo(database))
|
||||
}
|
||||
}
|
||||
@@ -687,6 +705,39 @@ class GroupActivity : LockingActivity(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDateSet(datePicker: DatePicker?, year: Int, month: Int, day: Int) {
|
||||
// To fix android 4.4 issue
|
||||
// https://stackoverflow.com/questions/12436073/datepicker-ondatechangedlistener-called-twice
|
||||
if (datePicker?.isShown == true) {
|
||||
val groupEditFragment = supportFragmentManager.findFragmentByTag(GroupEditDialogFragment.TAG_CREATE_GROUP) as? GroupEditDialogFragment
|
||||
groupEditFragment?.getExpiryTime()?.date?.let { expiresDate ->
|
||||
groupEditFragment.setExpiryTime(DateInstant(DateTime(expiresDate)
|
||||
.withYear(year)
|
||||
.withMonthOfYear(month + 1)
|
||||
.withDayOfMonth(day)
|
||||
.toDate()))
|
||||
// Launch the time picker
|
||||
val dateTime = DateTime(expiresDate)
|
||||
val defaultHour = dateTime.hourOfDay
|
||||
val defaultMinute = dateTime.minuteOfHour
|
||||
TimePickerFragment.getInstance(defaultHour, defaultMinute)
|
||||
.show(supportFragmentManager, "TimePickerFragment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeSet(view: TimePicker?, hours: Int, minutes: Int) {
|
||||
val groupEditFragment = supportFragmentManager.findFragmentByTag(GroupEditDialogFragment.TAG_CREATE_GROUP) as? GroupEditDialogFragment
|
||||
groupEditFragment?.getExpiryTime()?.date?.let { expiresDate ->
|
||||
// Save the date
|
||||
groupEditFragment.setExpiryTime(
|
||||
DateInstant(DateTime(expiresDate)
|
||||
.withHourOfDay(hours)
|
||||
.withMinuteOfHour(minutes)
|
||||
.toDate()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishNodeAction() {
|
||||
actionNodeMode?.finish()
|
||||
}
|
||||
@@ -732,7 +783,7 @@ class GroupActivity : LockingActivity(),
|
||||
when (node.type) {
|
||||
Type.GROUP -> {
|
||||
mOldGroupToUpdate = node as Group
|
||||
GroupEditDialogFragment.build(mOldGroupToUpdate!!)
|
||||
GroupEditDialogFragment.build(mOldGroupToUpdate!!.getGroupInfo())
|
||||
.show(supportFragmentManager,
|
||||
GroupEditDialogFragment.TAG_CREATE_GROUP)
|
||||
}
|
||||
@@ -872,6 +923,8 @@ class GroupActivity : LockingActivity(),
|
||||
}
|
||||
if (mSpecialMode == SpecialMode.DEFAULT) {
|
||||
MenuUtil.defaultMenuInflater(inflater, menu)
|
||||
} else {
|
||||
menu.findItem(R.id.menu_reload_database)?.isVisible = false
|
||||
}
|
||||
|
||||
// Menu for recycle bin
|
||||
@@ -997,6 +1050,10 @@ class GroupActivity : LockingActivity(),
|
||||
mProgressDatabaseTaskProvider?.startDatabaseSave(!mReadOnly)
|
||||
return true
|
||||
}
|
||||
R.id.menu_reload_database -> {
|
||||
mProgressDatabaseTaskProvider?.startDatabaseReload(false)
|
||||
return true
|
||||
}
|
||||
R.id.menu_empty_recycle_bin -> {
|
||||
mCurrentGroup?.getChildren()?.let { listChildren ->
|
||||
// Automatically delete all elements
|
||||
@@ -1012,19 +1069,17 @@ class GroupActivity : LockingActivity(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun approveEditGroup(action: GroupEditDialogFragment.EditGroupDialogAction?,
|
||||
name: String?,
|
||||
icon: IconImage?) {
|
||||
override fun approveEditGroup(action: GroupEditDialogFragment.EditGroupDialogAction,
|
||||
groupInfo: GroupInfo) {
|
||||
|
||||
if (name != null && name.isNotEmpty() && icon != null) {
|
||||
if (groupInfo.title.isNotEmpty()) {
|
||||
when (action) {
|
||||
GroupEditDialogFragment.EditGroupDialogAction.CREATION -> {
|
||||
// If group creation
|
||||
mCurrentGroup?.let { currentGroup ->
|
||||
// Build the group
|
||||
mDatabase?.createGroup()?.let { newGroup ->
|
||||
newGroup.title = name
|
||||
newGroup.icon = icon
|
||||
newGroup.setGroupInfo(groupInfo)
|
||||
// Not really needed here because added in runnable but safe
|
||||
newGroup.parent = currentGroup
|
||||
|
||||
@@ -1044,9 +1099,7 @@ class GroupActivity : LockingActivity(),
|
||||
// WARNING remove parent and children to keep memory
|
||||
removeParent()
|
||||
removeChildren()
|
||||
|
||||
title = name
|
||||
this.icon = icon // TODO custom icon #96
|
||||
this.setGroupInfo(groupInfo)
|
||||
}
|
||||
}
|
||||
// If group updated save it in the database
|
||||
@@ -1062,9 +1115,8 @@ class GroupActivity : LockingActivity(),
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelEditGroup(action: GroupEditDialogFragment.EditGroupDialogAction?,
|
||||
name: String?,
|
||||
icon: IconImage?) {
|
||||
override fun cancelEditGroup(action: GroupEditDialogFragment.EditGroupDialogAction,
|
||||
groupInfo: GroupInfo) {
|
||||
// Do nothing here
|
||||
}
|
||||
|
||||
@@ -1124,7 +1176,16 @@ class GroupActivity : LockingActivity(),
|
||||
private fun rebuildListNodes() {
|
||||
mListNodesFragment = supportFragmentManager.findFragmentByTag(LIST_NODES_FRAGMENT_TAG) as ListNodesFragment?
|
||||
// to refresh fragment
|
||||
mListNodesFragment?.rebuildList()
|
||||
try {
|
||||
mListNodesFragment?.rebuildList()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
coordinatorLayout?.let { coordinatorLayout ->
|
||||
Snackbar.make(coordinatorLayout,
|
||||
R.string.error_rebuild_list,
|
||||
Snackbar.LENGTH_LONG).asError().show()
|
||||
}
|
||||
}
|
||||
mCurrentGroup = mListNodesFragment?.mainGroup
|
||||
// Remove search in intent
|
||||
deletePreviousSearchGroup()
|
||||
@@ -1295,14 +1356,14 @@ class GroupActivity : LockingActivity(),
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
fun launchForAutofillResult(activity: Activity,
|
||||
readOnly: Boolean,
|
||||
assistStructure: AssistStructure,
|
||||
autofillComponent: AutofillComponent,
|
||||
searchInfo: SearchInfo? = null,
|
||||
autoSearch: Boolean = false) {
|
||||
checkTimeAndBuildIntent(activity, null, readOnly) { intent ->
|
||||
intent.putExtra(AUTO_SEARCH_KEY, autoSearch)
|
||||
AutofillHelper.startActivityForAutofillResult(activity,
|
||||
intent,
|
||||
assistStructure,
|
||||
autofillComponent,
|
||||
searchInfo)
|
||||
}
|
||||
}
|
||||
@@ -1419,21 +1480,21 @@ class GroupActivity : LockingActivity(),
|
||||
}
|
||||
)
|
||||
},
|
||||
{ searchInfo, assistStructure ->
|
||||
{ searchInfo, autofillComponent ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
SearchHelper.checkAutoSearchInfo(activity,
|
||||
Database.getInstance(),
|
||||
searchInfo,
|
||||
{ items ->
|
||||
// Response is build
|
||||
AutofillHelper.buildResponse(activity, items)
|
||||
AutofillHelper.buildResponseAndSetResult(activity, items)
|
||||
onValidateSpecialMode()
|
||||
},
|
||||
{
|
||||
// Here no search info found, disable auto search
|
||||
GroupActivity.launchForAutofillResult(activity,
|
||||
readOnly,
|
||||
assistStructure,
|
||||
autofillComponent,
|
||||
searchInfo,
|
||||
false)
|
||||
onLaunchActivitySpecialMode()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2021 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import android.util.Log
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import com.igreenwood.loupe.Loupe
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.lock.LockingActivity
|
||||
import com.kunzisoft.keepass.database.element.Attachment
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import kotlinx.android.synthetic.main.activity_image_viewer.*
|
||||
|
||||
class ImageViewerActivity : LockingActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_image_viewer)
|
||||
|
||||
val toolbar = findViewById<Toolbar>(R.id.toolbar)
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayShowHomeEnabled(true)
|
||||
|
||||
val imageView: ImageView = findViewById(R.id.image_viewer_image)
|
||||
val progressView: View = findViewById(R.id.image_viewer_progress)
|
||||
|
||||
try {
|
||||
progressView.visibility = View.VISIBLE
|
||||
intent.getParcelableExtra<Attachment>(IMAGE_ATTACHMENT_TAG)?.let { attachment ->
|
||||
|
||||
supportActionBar?.title = attachment.name
|
||||
supportActionBar?.subtitle = Formatter.formatFileSize(this, attachment.binaryAttachment.length)
|
||||
|
||||
Attachment.loadBitmap(attachment, Database.getInstance().loadedCipherKey) { bitmapLoaded ->
|
||||
if (bitmapLoaded == null) {
|
||||
finish()
|
||||
} else {
|
||||
progressView.visibility = View.GONE
|
||||
imageView.setImageBitmap(bitmapLoaded)
|
||||
}
|
||||
}
|
||||
} ?: finish()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to view the binary", e)
|
||||
finish()
|
||||
}
|
||||
|
||||
Loupe.create(imageView, image_viewer_container) {
|
||||
onViewTranslateListener = object : Loupe.OnViewTranslateListener {
|
||||
|
||||
override fun onStart(view: ImageView) {
|
||||
// called when the view starts moving
|
||||
}
|
||||
|
||||
override fun onViewTranslate(view: ImageView, amount: Float) {
|
||||
// called whenever the view position changed
|
||||
}
|
||||
|
||||
override fun onRestore(view: ImageView) {
|
||||
// called when the view drag gesture ended
|
||||
}
|
||||
|
||||
override fun onDismiss(view: ImageView) {
|
||||
// called when the view drag gesture ended
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> finish()
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = ImageViewerActivity::class.simpleName
|
||||
|
||||
private const val IMAGE_ATTACHMENT_TAG = "IMAGE_ATTACHMENT_TAG"
|
||||
|
||||
fun getInstance(context: Context, imageAttachment: Attachment) {
|
||||
context.startActivity(Intent(context, ImageViewerActivity::class.java).apply {
|
||||
putExtra(IMAGE_ATTACHMENT_TAG, imageAttachment)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,30 +22,24 @@ package com.kunzisoft.keepass.activities
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.adapters.NodeAdapter
|
||||
import com.kunzisoft.keepass.database.element.SortNodeEnum
|
||||
import com.kunzisoft.keepass.database.element.Group
|
||||
import com.kunzisoft.keepass.database.element.node.Node
|
||||
import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment
|
||||
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.activities.stylish.StylishFragment
|
||||
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
|
||||
import com.kunzisoft.keepass.activities.helpers.SpecialMode
|
||||
import com.kunzisoft.keepass.activities.stylish.StylishFragment
|
||||
import com.kunzisoft.keepass.adapters.NodeAdapter
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.Group
|
||||
import com.kunzisoft.keepass.database.element.SortNodeEnum
|
||||
import com.kunzisoft.keepass.database.element.node.Node
|
||||
import com.kunzisoft.keepass.database.element.node.Type
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import java.util.*
|
||||
|
||||
class ListNodesFragment : StylishFragment(), SortDialogFragment.SortSelectionListener {
|
||||
@@ -197,7 +191,12 @@ class ListNodesFragment : StylishFragment(), SortDialogFragment.SortSelectionLis
|
||||
}
|
||||
|
||||
// Refresh data
|
||||
rebuildList()
|
||||
try {
|
||||
rebuildList()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to rebuild the list during resume")
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
if (isASearchResult && mAdapter!= null && mAdapter!!.isEmpty) {
|
||||
// To show the " no search entry found "
|
||||
@@ -209,10 +208,12 @@ class ListNodesFragment : StylishFragment(), SortDialogFragment.SortSelectionLis
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IllegalArgumentException::class)
|
||||
fun rebuildList() {
|
||||
// Add elements to the list
|
||||
mainGroup?.let { mainGroup ->
|
||||
mAdapter?.apply {
|
||||
// Thrown an exception when sort cannot be performed
|
||||
rebuildList(mainGroup)
|
||||
// To visually change the elements
|
||||
if (PreferencesUtil.APPEARANCE_CHANGED) {
|
||||
@@ -231,8 +232,13 @@ class ListNodesFragment : StylishFragment(), SortDialogFragment.SortSelectionLis
|
||||
}
|
||||
|
||||
// Tell the adapter to refresh it's list
|
||||
mAdapter?.notifyChangeSort(sortNodeEnum, sortNodeParameters)
|
||||
rebuildList()
|
||||
try {
|
||||
mAdapter?.notifyChangeSort(sortNodeEnum, sortNodeParameters)
|
||||
rebuildList()
|
||||
} catch (e:Exception) {
|
||||
Log.e(TAG, "Unable to rebuild the list with the sort")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
package com.kunzisoft.keepass.activities
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.assist.AssistStructure
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
@@ -37,9 +36,8 @@ import android.widget.*
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.fragment.app.commit
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.dialogs.DuplicateUuidDialog
|
||||
@@ -50,33 +48,33 @@ import com.kunzisoft.keepass.activities.helpers.SpecialMode
|
||||
import com.kunzisoft.keepass.activities.lock.LockingActivity
|
||||
import com.kunzisoft.keepass.activities.selection.SpecialModeActivity
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseEntity
|
||||
import com.kunzisoft.keepass.autofill.AutofillComponent
|
||||
import com.kunzisoft.keepass.autofill.AutofillHelper
|
||||
import com.kunzisoft.keepass.biometric.AdvancedUnlockedManager
|
||||
import com.kunzisoft.keepass.biometric.BiometricUnlockDatabaseHelper
|
||||
import com.kunzisoft.keepass.biometric.AdvancedUnlockFragment
|
||||
import com.kunzisoft.keepass.database.action.ProgressDatabaseTaskProvider
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException
|
||||
import com.kunzisoft.keepass.database.exception.FileNotFoundDatabaseException
|
||||
import com.kunzisoft.keepass.education.PasswordActivityEducation
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
import com.kunzisoft.keepass.model.RegisterInfo
|
||||
import com.kunzisoft.keepass.model.SearchInfo
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_LOAD_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.CIPHER_ENTITY_KEY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.DATABASE_URI_KEY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.KEY_FILE_URI_KEY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.MASTER_PASSWORD_KEY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.READ_ONLY_KEY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_LOAD_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.CIPHER_ENTITY_KEY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.DATABASE_URI_KEY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.MAIN_CREDENTIAL_KEY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.READ_ONLY_KEY
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.utils.BACK_PREVIOUS_KEYBOARD_ACTION
|
||||
import com.kunzisoft.keepass.utils.MenuUtil
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import com.kunzisoft.keepass.view.AdvancedUnlockInfoView
|
||||
import com.kunzisoft.keepass.view.KeyFileSelectionView
|
||||
import com.kunzisoft.keepass.view.asError
|
||||
import com.kunzisoft.keepass.viewmodels.DatabaseFileViewModel
|
||||
import kotlinx.android.synthetic.main.activity_password.*
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
open class PasswordActivity : SpecialModeActivity() {
|
||||
open class PasswordActivity : SpecialModeActivity(), AdvancedUnlockFragment.BuilderListener {
|
||||
|
||||
// Views
|
||||
private var toolbar: Toolbar? = null
|
||||
@@ -86,12 +84,12 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
private var confirmButtonView: Button? = null
|
||||
private var checkboxPasswordView: CompoundButton? = null
|
||||
private var checkboxKeyFileView: CompoundButton? = null
|
||||
private var advancedUnlockInfoView: AdvancedUnlockInfoView? = null
|
||||
private var advancedUnlockFragment: AdvancedUnlockFragment? = null
|
||||
private var infoContainerView: ViewGroup? = null
|
||||
private var enableButtonOnCheckedChangeListener: CompoundButton.OnCheckedChangeListener? = null
|
||||
|
||||
private val databaseFileViewModel: DatabaseFileViewModel by viewModels()
|
||||
|
||||
private var mDefaultDatabase: Boolean = false
|
||||
private var mDatabaseFileUri: Uri? = null
|
||||
private var mDatabaseKeyFileUri: Uri? = null
|
||||
|
||||
@@ -113,7 +111,6 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
|
||||
private var mProgressDatabaseTaskProvider: ProgressDatabaseTaskProvider? = null
|
||||
|
||||
private var advancedUnlockedManager: AdvancedUnlockedManager? = null
|
||||
private var mAllowAutoOpenBiometricPrompt: Boolean = true
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -133,7 +130,6 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
keyFileSelectionView = findViewById(R.id.keyfile_selection)
|
||||
checkboxPasswordView = findViewById(R.id.password_checkbox)
|
||||
checkboxKeyFileView = findViewById(R.id.keyfile_checkox)
|
||||
advancedUnlockInfoView = findViewById(R.id.biometric_info)
|
||||
infoContainerView = findViewById(R.id.activity_password_info_container)
|
||||
|
||||
mPermissionAsked = savedInstanceState?.getBoolean(KEY_PERMISSION_ASKED) ?: mPermissionAsked
|
||||
@@ -160,10 +156,6 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
}
|
||||
})
|
||||
|
||||
enableButtonOnCheckedChangeListener = CompoundButton.OnCheckedChangeListener { _, _ ->
|
||||
enableOrNotTheConfirmationButton()
|
||||
}
|
||||
|
||||
// If is a view intent
|
||||
getUriFromIntent(intent)
|
||||
if (savedInstanceState?.containsKey(KEY_KEYFILE) == true) {
|
||||
@@ -173,8 +165,31 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
mAllowAutoOpenBiometricPrompt = savedInstanceState.getBoolean(ALLOW_AUTO_OPEN_BIOMETRIC_PROMPT)
|
||||
}
|
||||
|
||||
// Init Biometric elements
|
||||
advancedUnlockFragment = supportFragmentManager
|
||||
.findFragmentByTag(UNLOCK_FRAGMENT_TAG) as? AdvancedUnlockFragment?
|
||||
if (advancedUnlockFragment == null) {
|
||||
advancedUnlockFragment = AdvancedUnlockFragment()
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.fragment_advanced_unlock_container_view,
|
||||
advancedUnlockFragment!!,
|
||||
UNLOCK_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen password checkbox to init advanced unlock and confirmation button
|
||||
checkboxPasswordView?.setOnCheckedChangeListener { _, _ ->
|
||||
advancedUnlockFragment?.checkUnlockAvailability()
|
||||
enableOrNotTheConfirmationButton()
|
||||
}
|
||||
|
||||
// Observe if default database
|
||||
databaseFileViewModel.isDefaultDatabase.observe(this) { isDefaultDatabase ->
|
||||
mDefaultDatabase = isDefaultDatabase
|
||||
}
|
||||
|
||||
// Observe database file change
|
||||
databaseFileViewModel.databaseFileLoaded.observe(this, Observer { databaseFile ->
|
||||
databaseFileViewModel.databaseFileLoaded.observe(this) { databaseFile ->
|
||||
// Force read only if the file does not exists
|
||||
mForceReadOnly = databaseFile?.let {
|
||||
!it.databaseFileExists
|
||||
@@ -194,19 +209,14 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
filenameView?.text = databaseFile?.databaseAlias ?: ""
|
||||
|
||||
onDatabaseFileLoaded(databaseFile?.databaseUri, keyFileUri)
|
||||
})
|
||||
}
|
||||
|
||||
mProgressDatabaseTaskProvider = ProgressDatabaseTaskProvider(this).apply {
|
||||
onActionFinish = { actionTask, result ->
|
||||
when (actionTask) {
|
||||
ACTION_DATABASE_LOAD_TASK -> {
|
||||
// Recheck biometric if error
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (PreferencesUtil.isBiometricUnlockEnable(this@PasswordActivity)) {
|
||||
// Stay with the same mode and init it
|
||||
advancedUnlockedManager?.initBiometricMode()
|
||||
}
|
||||
}
|
||||
// Recheck advanced unlock if error
|
||||
advancedUnlockFragment?.initAdvancedUnlockMode()
|
||||
|
||||
if (result.isSuccess) {
|
||||
mDatabaseKeyFileUri = null
|
||||
@@ -220,32 +230,37 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
if (resultException != null) {
|
||||
resultError = resultException.getLocalizedMessage(resources)
|
||||
|
||||
// Relaunch loading if we need to fix UUID
|
||||
if (resultException is DuplicateUuidDatabaseException) {
|
||||
showLoadDatabaseDuplicateUuidMessage {
|
||||
when (resultException) {
|
||||
is DuplicateUuidDatabaseException -> {
|
||||
// Relaunch loading if we need to fix UUID
|
||||
showLoadDatabaseDuplicateUuidMessage {
|
||||
|
||||
var databaseUri: Uri? = null
|
||||
var masterPassword: String? = null
|
||||
var keyFileUri: Uri? = null
|
||||
var readOnly = true
|
||||
var cipherEntity: CipherDatabaseEntity? = null
|
||||
var databaseUri: Uri? = null
|
||||
var mainCredential: MainCredential = MainCredential()
|
||||
var readOnly = true
|
||||
var cipherEntity: CipherDatabaseEntity? = null
|
||||
|
||||
result.data?.let { resultData ->
|
||||
databaseUri = resultData.getParcelable(DATABASE_URI_KEY)
|
||||
masterPassword = resultData.getString(MASTER_PASSWORD_KEY)
|
||||
keyFileUri = resultData.getParcelable(KEY_FILE_URI_KEY)
|
||||
readOnly = resultData.getBoolean(READ_ONLY_KEY)
|
||||
cipherEntity = resultData.getParcelable(CIPHER_ENTITY_KEY)
|
||||
result.data?.let { resultData ->
|
||||
databaseUri = resultData.getParcelable(DATABASE_URI_KEY)
|
||||
mainCredential = resultData.getParcelable(MAIN_CREDENTIAL_KEY) ?: mainCredential
|
||||
readOnly = resultData.getBoolean(READ_ONLY_KEY)
|
||||
cipherEntity = resultData.getParcelable(CIPHER_ENTITY_KEY)
|
||||
}
|
||||
|
||||
databaseUri?.let { databaseFileUri ->
|
||||
showProgressDialogAndLoadDatabase(
|
||||
databaseFileUri,
|
||||
mainCredential,
|
||||
readOnly,
|
||||
cipherEntity,
|
||||
true)
|
||||
}
|
||||
}
|
||||
|
||||
databaseUri?.let { databaseFileUri ->
|
||||
showProgressDialogAndLoadDatabase(
|
||||
databaseFileUri,
|
||||
masterPassword,
|
||||
keyFileUri,
|
||||
readOnly,
|
||||
cipherEntity,
|
||||
true)
|
||||
}
|
||||
is FileNotFoundDatabaseException -> {
|
||||
// Remove this default database inaccessible
|
||||
if (mDefaultDatabase) {
|
||||
databaseFileViewModel.removeDefaultDatabase()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,6 +292,9 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
mDatabaseFileUri = intent?.getParcelableExtra(KEY_FILENAME)
|
||||
mDatabaseKeyFileUri = intent?.getParcelableExtra(KEY_KEYFILE)
|
||||
}
|
||||
mDatabaseFileUri?.let {
|
||||
databaseFileViewModel.checkIfIsDefaultDatabase(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
@@ -303,6 +321,33 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun retrieveCredentialForEncryption(): String {
|
||||
return passwordView?.text?.toString() ?: ""
|
||||
}
|
||||
|
||||
override fun conditionToStoreCredential(): Boolean {
|
||||
return checkboxPasswordView?.isChecked == true
|
||||
}
|
||||
|
||||
override fun onCredentialEncrypted(databaseUri: Uri,
|
||||
encryptedCredential: String,
|
||||
ivSpec: String) {
|
||||
// Load the database if password is registered with biometric
|
||||
verifyCheckboxesAndLoadDatabase(
|
||||
CipherDatabaseEntity(
|
||||
databaseUri.toString(),
|
||||
encryptedCredential,
|
||||
ivSpec)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCredentialDecrypted(databaseUri: Uri,
|
||||
decryptedCredential: String) {
|
||||
// Load the database if password is retrieve from biometric
|
||||
// Retrieve from biometric
|
||||
verifyKeyFileCheckboxAndLoadDatabase(decryptedCredential)
|
||||
}
|
||||
|
||||
private val onEditorActionListener = object : TextView.OnEditorActionListener {
|
||||
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
|
||||
if (actionId == IME_ACTION_DONE) {
|
||||
@@ -369,48 +414,9 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
verifyCheckboxesAndLoadDatabase(password, keyFileUri)
|
||||
} else {
|
||||
// Init Biometric elements
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (PreferencesUtil.isBiometricUnlockEnable(this)) {
|
||||
if (advancedUnlockedManager == null
|
||||
&& databaseFileUri != null) {
|
||||
advancedUnlockedManager = AdvancedUnlockedManager(this,
|
||||
databaseFileUri,
|
||||
advancedUnlockInfoView,
|
||||
checkboxPasswordView,
|
||||
enableButtonOnCheckedChangeListener,
|
||||
passwordView,
|
||||
{ passwordEncrypted, ivSpec ->
|
||||
// Load the database if password is registered with biometric
|
||||
if (passwordEncrypted != null && ivSpec != null) {
|
||||
verifyCheckboxesAndLoadDatabase(
|
||||
CipherDatabaseEntity(
|
||||
databaseFileUri.toString(),
|
||||
passwordEncrypted,
|
||||
ivSpec)
|
||||
)
|
||||
}
|
||||
},
|
||||
{ passwordDecrypted ->
|
||||
// Load the database if password is retrieve from biometric
|
||||
passwordDecrypted?.let {
|
||||
// Retrieve from biometric
|
||||
verifyKeyFileCheckboxAndLoadDatabase(it)
|
||||
}
|
||||
})
|
||||
}
|
||||
advancedUnlockedManager?.isBiometricPromptAutoOpenEnable =
|
||||
mAllowAutoOpenBiometricPrompt && mProgressDatabaseTaskProvider?.isBinded() != true
|
||||
advancedUnlockedManager?.checkBiometricAvailability()
|
||||
} else {
|
||||
advancedUnlockInfoView?.visibility = View.GONE
|
||||
advancedUnlockedManager?.destroy()
|
||||
advancedUnlockedManager = null
|
||||
}
|
||||
}
|
||||
if (advancedUnlockedManager == null) {
|
||||
checkboxPasswordView?.setOnCheckedChangeListener(enableButtonOnCheckedChangeListener)
|
||||
}
|
||||
checkboxKeyFileView?.setOnCheckedChangeListener(enableButtonOnCheckedChangeListener)
|
||||
advancedUnlockFragment?.loadDatabase(databaseFileUri,
|
||||
mAllowAutoOpenBiometricPrompt
|
||||
&& mProgressDatabaseTaskProvider?.isBinded() != true)
|
||||
}
|
||||
|
||||
enableOrNotTheConfirmationButton()
|
||||
@@ -462,11 +468,6 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
override fun onPause() {
|
||||
mProgressDatabaseTaskProvider?.unregisterProgressTask()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
advancedUnlockedManager?.destroy()
|
||||
advancedUnlockedManager = null
|
||||
}
|
||||
|
||||
// Reinit locking activity UI variable
|
||||
LockingActivity.LOCKING_ACTIVITY_UI_VISIBLE_DURING_LOCK = null
|
||||
mAllowAutoOpenBiometricPrompt = true
|
||||
@@ -530,8 +531,7 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
// Show the progress dialog and load the database
|
||||
showProgressDialogAndLoadDatabase(
|
||||
databaseUri,
|
||||
password,
|
||||
keyFileUri,
|
||||
MainCredential(password, keyFileUri),
|
||||
readOnly,
|
||||
cipherDatabaseEntity,
|
||||
false)
|
||||
@@ -540,15 +540,13 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
}
|
||||
|
||||
private fun showProgressDialogAndLoadDatabase(databaseUri: Uri,
|
||||
password: String?,
|
||||
keyFile: Uri?,
|
||||
mainCredential: MainCredential,
|
||||
readOnly: Boolean,
|
||||
cipherDatabaseEntity: CipherDatabaseEntity?,
|
||||
fixDuplicateUUID: Boolean) {
|
||||
mProgressDatabaseTaskProvider?.startDatabaseLoad(
|
||||
databaseUri,
|
||||
password,
|
||||
keyFile,
|
||||
mainCredential,
|
||||
readOnly,
|
||||
cipherDatabaseEntity,
|
||||
fixDuplicateUUID
|
||||
@@ -575,11 +573,6 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
MenuUtil.defaultMenuInflater(inflater, menu)
|
||||
}
|
||||
|
||||
if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// biometric menu
|
||||
advancedUnlockedManager?.inflateOptionsMenu(inflater, menu)
|
||||
}
|
||||
|
||||
super.onCreateOptionsMenu(menu)
|
||||
|
||||
launchEducation(menu)
|
||||
@@ -589,13 +582,13 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
|
||||
// Check permission
|
||||
private fun checkPermission() {
|
||||
val writePermission = android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
val permissions = arrayOf(writePermission)
|
||||
if (Build.VERSION.SDK_INT >= 23
|
||||
if (Build.VERSION.SDK_INT in 23..28
|
||||
&& !readOnly
|
||||
&& !mPermissionAsked) {
|
||||
mPermissionAsked = true
|
||||
// Check self permission to show or not the dialog
|
||||
val writePermission = android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
val permissions = arrayOf(writePermission)
|
||||
if (toolbar != null
|
||||
&& ActivityCompat.checkSelfPermission(this, writePermission) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this, permissions, WRITE_EXTERNAL_STORAGE_REQUEST)
|
||||
@@ -655,21 +648,14 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
performedNextEducation(passwordActivityEducation, menu)
|
||||
})
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||
&& !readOnlyEducationPerformed) {
|
||||
val biometricCanAuthenticate = BiometricUnlockDatabaseHelper.canAuthenticate(this)
|
||||
PreferencesUtil.isBiometricUnlockEnable(applicationContext)
|
||||
&& (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED || biometricCanAuthenticate == BiometricManager.BIOMETRIC_SUCCESS)
|
||||
&& advancedUnlockInfoView != null && advancedUnlockInfoView?.visibility == View.VISIBLE
|
||||
&& advancedUnlockInfoView?.unlockIconImageView != null
|
||||
&& passwordActivityEducation.checkAndPerformedBiometricEducation(advancedUnlockInfoView?.unlockIconImageView!!,
|
||||
{
|
||||
performedNextEducation(passwordActivityEducation, menu)
|
||||
},
|
||||
{
|
||||
performedNextEducation(passwordActivityEducation, menu)
|
||||
})
|
||||
}
|
||||
advancedUnlockFragment?.performEducation(passwordActivityEducation,
|
||||
readOnlyEducationPerformed,
|
||||
{
|
||||
performedNextEducation(passwordActivityEducation, menu)
|
||||
},
|
||||
{
|
||||
performedNextEducation(passwordActivityEducation, menu)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,10 +677,7 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
readOnly = !readOnly
|
||||
changeOpenFileReadIcon(item)
|
||||
}
|
||||
R.id.menu_biometric_remove_key -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
advancedUnlockedManager?.deleteEntryKey()
|
||||
}
|
||||
else -> return MenuUtil.onDefaultMenuOptionsItemSelected(this, item)
|
||||
else -> MenuUtil.onDefaultMenuOptionsItemSelected(this, item)
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
@@ -708,6 +691,9 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
|
||||
mAllowAutoOpenBiometricPrompt = false
|
||||
|
||||
// To get device credential unlock result
|
||||
advancedUnlockFragment?.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
// To get entry in result
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
AutofillHelper.onActivityResultSetResultAndFinish(this, requestCode, resultCode, data)
|
||||
@@ -728,7 +714,7 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
when (resultCode) {
|
||||
LockingActivity.RESULT_EXIT_LOCK -> {
|
||||
clearCredentialsViews()
|
||||
Database.getInstance().closeAndClear(UriUtil.getBinaryDir(this))
|
||||
Database.getInstance().clearAndClose(UriUtil.getBinaryDir(this))
|
||||
}
|
||||
Activity.RESULT_CANCELED -> {
|
||||
clearCredentialsViews()
|
||||
@@ -741,6 +727,8 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
|
||||
private val TAG = PasswordActivity::class.java.name
|
||||
|
||||
private const val UNLOCK_FRAGMENT_TAG = "UNLOCK_FRAGMENT_TAG"
|
||||
|
||||
private const val KEY_FILENAME = "fileName"
|
||||
private const val KEY_KEYFILE = "keyFile"
|
||||
private const val VIEW_INTENT = "android.intent.action.VIEW"
|
||||
@@ -844,13 +832,13 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
fun launchForAutofillResult(activity: Activity,
|
||||
databaseFile: Uri,
|
||||
keyFile: Uri?,
|
||||
assistStructure: AssistStructure,
|
||||
autofillComponent: AutofillComponent,
|
||||
searchInfo: SearchInfo?) {
|
||||
buildAndLaunchIntent(activity, databaseFile, keyFile) { intent ->
|
||||
AutofillHelper.startActivityForAutofillResult(
|
||||
activity,
|
||||
intent,
|
||||
assistStructure,
|
||||
autofillComponent,
|
||||
searchInfo)
|
||||
}
|
||||
}
|
||||
@@ -908,11 +896,11 @@ open class PasswordActivity : SpecialModeActivity() {
|
||||
searchInfo)
|
||||
onLaunchActivitySpecialMode()
|
||||
},
|
||||
{ searchInfo, assistStructure -> // Autofill Selection Action
|
||||
{ searchInfo, autofillComponent -> // Autofill Selection Action
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
PasswordActivity.launchForAutofillResult(activity,
|
||||
databaseUri, keyFile,
|
||||
assistStructure,
|
||||
autofillComponent,
|
||||
searchInfo)
|
||||
onLaunchActivitySpecialMode()
|
||||
} else {
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.helpers.SelectFileHelper
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import com.kunzisoft.keepass.view.KeyFileSelectionView
|
||||
|
||||
@@ -76,10 +77,8 @@ class AssignMasterKeyDialogFragment : DialogFragment() {
|
||||
}
|
||||
|
||||
interface AssignPasswordDialogListener {
|
||||
fun onAssignKeyDialogPositiveClick(masterPasswordChecked: Boolean, masterPassword: String?,
|
||||
keyFileChecked: Boolean, keyFile: Uri?)
|
||||
fun onAssignKeyDialogNegativeClick(masterPasswordChecked: Boolean, masterPassword: String?,
|
||||
keyFileChecked: Boolean, keyFile: Uri?)
|
||||
fun onAssignKeyDialogPositiveClick(mainCredential: MainCredential)
|
||||
fun onAssignKeyDialogNegativeClick(mainCredential: MainCredential)
|
||||
}
|
||||
|
||||
override fun onAttach(activity: Context) {
|
||||
@@ -161,17 +160,13 @@ class AssignMasterKeyDialogFragment : DialogFragment() {
|
||||
}
|
||||
}
|
||||
if (!error) {
|
||||
mListener?.onAssignKeyDialogPositiveClick(
|
||||
passwordCheckBox!!.isChecked, mMasterPassword,
|
||||
keyFileCheckBox!!.isChecked, mKeyFile)
|
||||
mListener?.onAssignKeyDialogPositiveClick(retrieveMainCredential())
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
val negativeButton = dialog1.getButton(DialogInterface.BUTTON_NEGATIVE)
|
||||
negativeButton.setOnClickListener {
|
||||
mListener?.onAssignKeyDialogNegativeClick(
|
||||
passwordCheckBox!!.isChecked, mMasterPassword,
|
||||
keyFileCheckBox!!.isChecked, mKeyFile)
|
||||
mListener?.onAssignKeyDialogNegativeClick(retrieveMainCredential())
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -183,6 +178,12 @@ class AssignMasterKeyDialogFragment : DialogFragment() {
|
||||
return super.onCreateDialog(savedInstanceState)
|
||||
}
|
||||
|
||||
private fun retrieveMainCredential(): MainCredential {
|
||||
val masterPassword = if (passwordCheckBox!!.isChecked) mMasterPassword else null
|
||||
val keyFile = if (keyFileCheckBox!!.isChecked) mKeyFile else null
|
||||
return MainCredential(masterPassword, keyFile)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
@@ -242,9 +243,7 @@ class AssignMasterKeyDialogFragment : DialogFragment() {
|
||||
builder.setMessage(R.string.warning_empty_password)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
if (!verifyKeyFile()) {
|
||||
mListener?.onAssignKeyDialogPositiveClick(
|
||||
passwordCheckBox!!.isChecked, mMasterPassword,
|
||||
keyFileCheckBox!!.isChecked, mKeyFile)
|
||||
mListener?.onAssignKeyDialogPositiveClick(retrieveMainCredential())
|
||||
this@AssignMasterKeyDialogFragment.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -259,9 +258,7 @@ class AssignMasterKeyDialogFragment : DialogFragment() {
|
||||
val builder = AlertDialog.Builder(it)
|
||||
builder.setMessage(R.string.warning_no_encryption_key)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
mListener?.onAssignKeyDialogPositiveClick(
|
||||
passwordCheckBox!!.isChecked, mMasterPassword,
|
||||
keyFileCheckBox!!.isChecked, mKeyFile)
|
||||
mListener?.onAssignKeyDialogPositiveClick(retrieveMainCredential())
|
||||
this@AssignMasterKeyDialogFragment.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2020 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.activities.dialogs
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.model.SnapFileDatabaseInfo
|
||||
|
||||
|
||||
class DatabaseChangedDialogFragment : DialogFragment() {
|
||||
|
||||
var actionDatabaseListener: ActionDatabaseChangedListener? = null
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
actionDatabaseListener = null
|
||||
this.dismiss()
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
activity?.let { activity ->
|
||||
|
||||
val oldSnapFileDatabaseInfo: SnapFileDatabaseInfo? = arguments?.getParcelable(OLD_FILE_DATABASE_INFO)
|
||||
val newSnapFileDatabaseInfo: SnapFileDatabaseInfo? = arguments?.getParcelable(NEW_FILE_DATABASE_INFO)
|
||||
|
||||
if (oldSnapFileDatabaseInfo != null && newSnapFileDatabaseInfo != null) {
|
||||
// Use the Builder class for convenient dialog construction
|
||||
val builder = AlertDialog.Builder(activity)
|
||||
|
||||
val stringBuilder = SpannableStringBuilder()
|
||||
if (newSnapFileDatabaseInfo.exists) {
|
||||
stringBuilder.append(getString(R.string.warning_database_info_changed))
|
||||
stringBuilder.append("\n\n" +oldSnapFileDatabaseInfo.toString(activity)
|
||||
+ "\n→\n" +
|
||||
newSnapFileDatabaseInfo.toString(activity) + "\n\n")
|
||||
stringBuilder.append(getString(R.string.warning_database_info_changed_options))
|
||||
} else {
|
||||
stringBuilder.append(getString(R.string.warning_database_revoked))
|
||||
}
|
||||
builder.setMessage(stringBuilder)
|
||||
builder.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
actionDatabaseListener?.validateDatabaseChanged()
|
||||
}
|
||||
return builder.create()
|
||||
}
|
||||
}
|
||||
return super.onCreateDialog(savedInstanceState)
|
||||
}
|
||||
|
||||
interface ActionDatabaseChangedListener {
|
||||
fun validateDatabaseChanged()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val DATABASE_CHANGED_DIALOG_TAG = "databaseChangedDialogFragment"
|
||||
private const val OLD_FILE_DATABASE_INFO = "OLD_FILE_DATABASE_INFO"
|
||||
private const val NEW_FILE_DATABASE_INFO = "NEW_FILE_DATABASE_INFO"
|
||||
|
||||
fun getInstance(oldSnapFileDatabaseInfo: SnapFileDatabaseInfo,
|
||||
newSnapFileDatabaseInfo: SnapFileDatabaseInfo)
|
||||
: DatabaseChangedDialogFragment {
|
||||
val fragment = DatabaseChangedDialogFragment()
|
||||
fragment.arguments = Bundle().apply {
|
||||
putParcelable(OLD_FILE_DATABASE_INFO, oldSnapFileDatabaseInfo)
|
||||
putParcelable(NEW_FILE_DATABASE_INFO, newSnapFileDatabaseInfo)
|
||||
}
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,9 @@ import androidx.fragment.app.DialogFragment
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.node.Node
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.getBundleFromListNodes
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.getListNodesFromBundle
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getBundleFromListNodes
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getListNodesFromBundle
|
||||
|
||||
open class DeleteNodesDialogFragment : DialogFragment() {
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ package com.kunzisoft.keepass.activities.dialogs
|
||||
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.database.element.node.Node
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.getBundleFromListNodes
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getBundleFromListNodes
|
||||
|
||||
class EmptyRecycleBinDialogFragment : DeleteNodesDialogFragment() {
|
||||
|
||||
|
||||
@@ -23,34 +23,39 @@ import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.dialogs.GroupEditDialogFragment.EditGroupDialogAction.CREATION
|
||||
import com.kunzisoft.keepass.activities.dialogs.GroupEditDialogFragment.EditGroupDialogAction.UPDATE
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.Group
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImage
|
||||
import com.kunzisoft.keepass.database.element.DateInstant
|
||||
import com.kunzisoft.keepass.icons.assignDatabaseIcon
|
||||
import com.kunzisoft.keepass.model.GroupInfo
|
||||
import com.kunzisoft.keepass.view.ExpirationView
|
||||
import org.joda.time.DateTime
|
||||
|
||||
class GroupEditDialogFragment : DialogFragment(), IconPickerDialogFragment.IconPickerListener {
|
||||
|
||||
private var mDatabase: Database? = null
|
||||
|
||||
private var editGroupListener: EditGroupListener? = null
|
||||
private var mEditGroupListener: EditGroupListener? = null
|
||||
|
||||
private var editGroupDialogAction: EditGroupDialogAction? = null
|
||||
private var nameGroup: String? = null
|
||||
private var iconGroup: IconImage? = null
|
||||
private var mEditGroupDialogAction = EditGroupDialogAction.NONE
|
||||
private var mGroupInfo = GroupInfo()
|
||||
|
||||
private var nameTextLayoutView: TextInputLayout? = null
|
||||
private var nameTextView: TextView? = null
|
||||
private var iconButtonView: ImageView? = null
|
||||
private lateinit var iconButtonView: ImageView
|
||||
private var iconColor: Int = 0
|
||||
private lateinit var nameTextLayoutView: TextInputLayout
|
||||
private lateinit var nameTextView: TextView
|
||||
private lateinit var notesTextLayoutView: TextInputLayout
|
||||
private lateinit var notesTextView: TextView
|
||||
private lateinit var expirationView: ExpirationView
|
||||
|
||||
enum class EditGroupDialogAction {
|
||||
CREATION, UPDATE, NONE;
|
||||
@@ -67,7 +72,7 @@ class GroupEditDialogFragment : DialogFragment(), IconPickerDialogFragment.IconP
|
||||
// Verify that the host activity implements the callback interface
|
||||
try {
|
||||
// Instantiate the NoticeDialogListener so we can send events to the host
|
||||
editGroupListener = context as EditGroupListener
|
||||
mEditGroupListener = context as EditGroupListener
|
||||
} catch (e: ClassCastException) {
|
||||
// The activity doesn't implement the interface, throw exception
|
||||
throw ClassCastException(context.toString()
|
||||
@@ -76,16 +81,19 @@ class GroupEditDialogFragment : DialogFragment(), IconPickerDialogFragment.IconP
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
editGroupListener = null
|
||||
mEditGroupListener = null
|
||||
super.onDetach()
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
activity?.let { activity ->
|
||||
val root = activity.layoutInflater.inflate(R.layout.fragment_group_edit, null)
|
||||
nameTextLayoutView = root?.findViewById(R.id.group_edit_name_container)
|
||||
nameTextView = root?.findViewById(R.id.group_edit_name)
|
||||
iconButtonView = root?.findViewById(R.id.group_edit_icon_button)
|
||||
iconButtonView = root.findViewById(R.id.group_edit_icon_button)
|
||||
nameTextLayoutView = root.findViewById(R.id.group_edit_name_container)
|
||||
nameTextView = root.findViewById(R.id.group_edit_name)
|
||||
notesTextLayoutView = root.findViewById(R.id.group_edit_note_container)
|
||||
notesTextView = root.findViewById(R.id.group_edit_note)
|
||||
expirationView = root.findViewById(R.id.group_edit_expiration)
|
||||
|
||||
// Retrieve the textColor to tint the icon
|
||||
val ta = activity.theme.obtainStyledAttributes(intArrayOf(android.R.attr.textColor))
|
||||
@@ -94,46 +102,48 @@ class GroupEditDialogFragment : DialogFragment(), IconPickerDialogFragment.IconP
|
||||
|
||||
// Init elements
|
||||
mDatabase = Database.getInstance()
|
||||
editGroupDialogAction = EditGroupDialogAction.NONE
|
||||
nameGroup = ""
|
||||
iconGroup = mDatabase?.iconFactory?.folderIcon
|
||||
|
||||
if (savedInstanceState != null
|
||||
&& savedInstanceState.containsKey(KEY_ACTION_ID)
|
||||
&& savedInstanceState.containsKey(KEY_NAME)
|
||||
&& savedInstanceState.containsKey(KEY_ICON)) {
|
||||
editGroupDialogAction = EditGroupDialogAction.getActionFromOrdinal(savedInstanceState.getInt(KEY_ACTION_ID))
|
||||
nameGroup = savedInstanceState.getString(KEY_NAME)
|
||||
iconGroup = savedInstanceState.getParcelable(KEY_ICON)
|
||||
|
||||
&& savedInstanceState.containsKey(KEY_GROUP_INFO)) {
|
||||
mEditGroupDialogAction = EditGroupDialogAction.getActionFromOrdinal(savedInstanceState.getInt(KEY_ACTION_ID))
|
||||
mGroupInfo = savedInstanceState.getParcelable(KEY_GROUP_INFO) ?: mGroupInfo
|
||||
} else {
|
||||
arguments?.apply {
|
||||
if (containsKey(KEY_ACTION_ID))
|
||||
editGroupDialogAction = EditGroupDialogAction.getActionFromOrdinal(getInt(KEY_ACTION_ID))
|
||||
|
||||
if (containsKey(KEY_NAME) && containsKey(KEY_ICON)) {
|
||||
nameGroup = getString(KEY_NAME)
|
||||
iconGroup = getParcelable(KEY_ICON)
|
||||
mEditGroupDialogAction = EditGroupDialogAction.getActionFromOrdinal(getInt(KEY_ACTION_ID))
|
||||
if (mEditGroupDialogAction === CREATION)
|
||||
mGroupInfo.notes = ""
|
||||
if (containsKey(KEY_GROUP_INFO)) {
|
||||
mGroupInfo = getParcelable(KEY_GROUP_INFO) ?: mGroupInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// populate the name
|
||||
nameTextView?.text = nameGroup
|
||||
// populate the icon
|
||||
assignIconView()
|
||||
// populate info in views
|
||||
populateInfoToViews()
|
||||
expirationView.setOnDateClickListener = {
|
||||
expirationView.expiryTime.date.let { expiresDate ->
|
||||
val dateTime = DateTime(expiresDate)
|
||||
val defaultYear = dateTime.year
|
||||
val defaultMonth = dateTime.monthOfYear-1
|
||||
val defaultDay = dateTime.dayOfMonth
|
||||
DatePickerFragment.getInstance(defaultYear, defaultMonth, defaultDay)
|
||||
.show(parentFragmentManager, "DatePickerFragment")
|
||||
}
|
||||
}
|
||||
|
||||
val builder = AlertDialog.Builder(activity)
|
||||
builder.setView(root)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
editGroupListener?.cancelEditGroup(
|
||||
editGroupDialogAction,
|
||||
nameTextView?.text?.toString(),
|
||||
iconGroup)
|
||||
retrieveGroupInfoFromViews()
|
||||
mEditGroupListener?.cancelEditGroup(
|
||||
mEditGroupDialogAction,
|
||||
mGroupInfo)
|
||||
}
|
||||
|
||||
iconButtonView?.setOnClickListener { _ ->
|
||||
iconButtonView.setOnClickListener { _ ->
|
||||
IconPickerDialogFragment().show(parentFragmentManager, "IconPickerDialogFragment")
|
||||
}
|
||||
|
||||
@@ -150,55 +160,87 @@ class GroupEditDialogFragment : DialogFragment(), IconPickerDialogFragment.IconP
|
||||
if (d != null) {
|
||||
val positiveButton = d.getButton(Dialog.BUTTON_POSITIVE) as Button
|
||||
positiveButton.setOnClickListener {
|
||||
retrieveGroupInfoFromViews()
|
||||
if (isValid()) {
|
||||
editGroupListener?.approveEditGroup(
|
||||
editGroupDialogAction,
|
||||
nameTextView?.text?.toString(),
|
||||
iconGroup)
|
||||
mEditGroupListener?.approveEditGroup(
|
||||
mEditGroupDialogAction,
|
||||
mGroupInfo)
|
||||
d.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getExpiryTime(): DateInstant {
|
||||
retrieveGroupInfoFromViews()
|
||||
return mGroupInfo.expiryTime
|
||||
}
|
||||
|
||||
fun setExpiryTime(expiryTime: DateInstant) {
|
||||
mGroupInfo.expiryTime = expiryTime
|
||||
populateInfoToViews()
|
||||
}
|
||||
|
||||
private fun populateInfoToViews() {
|
||||
assignIconView()
|
||||
nameTextView.text = mGroupInfo.title
|
||||
notesTextLayoutView.visibility = if (mGroupInfo.notes == null) View.GONE else View.VISIBLE
|
||||
mGroupInfo.notes?.let {
|
||||
notesTextView.text = it
|
||||
}
|
||||
expirationView.expires = mGroupInfo.expires
|
||||
expirationView.expiryTime = mGroupInfo.expiryTime
|
||||
}
|
||||
|
||||
private fun retrieveGroupInfoFromViews() {
|
||||
mGroupInfo.title = nameTextView.text.toString()
|
||||
// Only if there
|
||||
val newNotes = notesTextView.text.toString()
|
||||
if (newNotes.isNotEmpty()) {
|
||||
mGroupInfo.notes = newNotes
|
||||
}
|
||||
mGroupInfo.expires = expirationView.expires
|
||||
mGroupInfo.expiryTime = expirationView.expiryTime
|
||||
}
|
||||
|
||||
private fun assignIconView() {
|
||||
if (mDatabase?.drawFactory != null && iconGroup != null) {
|
||||
iconButtonView?.assignDatabaseIcon(mDatabase?.drawFactory!!, iconGroup!!, iconColor)
|
||||
if (mDatabase?.drawFactory != null) {
|
||||
iconButtonView.assignDatabaseIcon(mDatabase?.drawFactory!!, mGroupInfo.icon, iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
override fun iconPicked(bundle: Bundle) {
|
||||
iconGroup = IconPickerDialogFragment.getIconStandardFromBundle(bundle)
|
||||
mGroupInfo.icon = IconPickerDialogFragment.getIconStandardFromBundle(bundle) ?: mGroupInfo.icon
|
||||
assignIconView()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putInt(KEY_ACTION_ID, editGroupDialogAction!!.ordinal)
|
||||
outState.putString(KEY_NAME, nameGroup)
|
||||
outState.putParcelable(KEY_ICON, iconGroup)
|
||||
retrieveGroupInfoFromViews()
|
||||
outState.putInt(KEY_ACTION_ID, mEditGroupDialogAction.ordinal)
|
||||
outState.putParcelable(KEY_GROUP_INFO, mGroupInfo)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun isValid(): Boolean {
|
||||
if (nameTextView?.text?.toString()?.isNotEmpty() != true) {
|
||||
nameTextLayoutView?.error = getString(R.string.error_no_name)
|
||||
if (nameTextView.text.toString().isEmpty()) {
|
||||
nameTextLayoutView.error = getString(R.string.error_no_name)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
interface EditGroupListener {
|
||||
fun approveEditGroup(action: EditGroupDialogAction?, name: String?, icon: IconImage?)
|
||||
fun cancelEditGroup(action: EditGroupDialogAction?, name: String?, icon: IconImage?)
|
||||
fun approveEditGroup(action: EditGroupDialogAction,
|
||||
groupInfo: GroupInfo)
|
||||
fun cancelEditGroup(action: EditGroupDialogAction,
|
||||
groupInfo: GroupInfo)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TAG_CREATE_GROUP = "TAG_CREATE_GROUP"
|
||||
|
||||
const val KEY_NAME = "KEY_NAME"
|
||||
const val KEY_ICON = "KEY_ICON"
|
||||
const val KEY_ACTION_ID = "KEY_ACTION_ID"
|
||||
const val KEY_GROUP_INFO = "KEY_GROUP_INFO"
|
||||
|
||||
fun build(): GroupEditDialogFragment {
|
||||
val bundle = Bundle()
|
||||
@@ -208,11 +250,10 @@ class GroupEditDialogFragment : DialogFragment(), IconPickerDialogFragment.IconP
|
||||
return fragment
|
||||
}
|
||||
|
||||
fun build(group: Group): GroupEditDialogFragment {
|
||||
fun build(groupInfo: GroupInfo): GroupEditDialogFragment {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_NAME, group.title)
|
||||
bundle.putParcelable(KEY_ICON, group.icon)
|
||||
bundle.putInt(KEY_ACTION_ID, UPDATE.ordinal)
|
||||
bundle.putParcelable(KEY_GROUP_INFO, groupInfo)
|
||||
val fragment = GroupEditDialogFragment()
|
||||
fragment.arguments = bundle
|
||||
return fragment
|
||||
|
||||
@@ -26,6 +26,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
|
||||
class PasswordEncodingDialogFragment : DialogFragment() {
|
||||
|
||||
@@ -49,10 +50,7 @@ class PasswordEncodingDialogFragment : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
|
||||
val databaseUri: Uri? = savedInstanceState?.getParcelable(DATABASE_URI_KEY)
|
||||
val masterPasswordChecked: Boolean = savedInstanceState?.getBoolean(MASTER_PASSWORD_CHECKED_KEY) ?: false
|
||||
val masterPassword: String? = savedInstanceState?.getString(MASTER_PASSWORD_KEY)
|
||||
val keyFileChecked: Boolean = savedInstanceState?.getBoolean(KEY_FILE_CHECKED_KEY) ?: false
|
||||
val keyFile: Uri? = savedInstanceState?.getParcelable(KEY_FILE_URI_KEY)
|
||||
val mainCredential: MainCredential = savedInstanceState?.getParcelable(MAIN_CREDENTIAL) ?: MainCredential()
|
||||
|
||||
activity?.let { activity ->
|
||||
val builder = AlertDialog.Builder(activity)
|
||||
@@ -60,10 +58,7 @@ class PasswordEncodingDialogFragment : DialogFragment() {
|
||||
builder.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
mListener?.onPasswordEncodingValidateListener(
|
||||
databaseUri,
|
||||
masterPasswordChecked,
|
||||
masterPassword,
|
||||
keyFileChecked,
|
||||
keyFile
|
||||
mainCredential
|
||||
)
|
||||
}
|
||||
builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() }
|
||||
@@ -75,32 +70,20 @@ class PasswordEncodingDialogFragment : DialogFragment() {
|
||||
|
||||
interface Listener {
|
||||
fun onPasswordEncodingValidateListener(databaseUri: Uri?,
|
||||
masterPasswordChecked: Boolean,
|
||||
masterPassword: String?,
|
||||
keyFileChecked: Boolean,
|
||||
keyFile: Uri?)
|
||||
mainCredential: MainCredential)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DATABASE_URI_KEY = "DATABASE_URI_KEY"
|
||||
private const val MASTER_PASSWORD_CHECKED_KEY = "MASTER_PASSWORD_CHECKED_KEY"
|
||||
private const val MASTER_PASSWORD_KEY = "MASTER_PASSWORD_KEY"
|
||||
private const val KEY_FILE_CHECKED_KEY = "KEY_FILE_CHECKED_KEY"
|
||||
private const val KEY_FILE_URI_KEY = "KEY_FILE_URI_KEY"
|
||||
private const val MAIN_CREDENTIAL = "MAIN_CREDENTIAL"
|
||||
|
||||
fun getInstance(databaseUri: Uri,
|
||||
masterPasswordChecked: Boolean,
|
||||
masterPassword: String?,
|
||||
keyFileChecked: Boolean,
|
||||
keyFile: Uri?): SortDialogFragment {
|
||||
mainCredential: MainCredential): SortDialogFragment {
|
||||
val fragment = SortDialogFragment()
|
||||
fragment.arguments = Bundle().apply {
|
||||
putParcelable(DATABASE_URI_KEY, databaseUri)
|
||||
putBoolean(MASTER_PASSWORD_CHECKED_KEY, masterPasswordChecked)
|
||||
putString(MASTER_PASSWORD_KEY, masterPassword)
|
||||
putBoolean(KEY_FILE_CHECKED_KEY, keyFileChecked)
|
||||
putParcelable(KEY_FILE_URI_KEY, keyFile)
|
||||
putParcelable(MAIN_CREDENTIAL, mainCredential)
|
||||
}
|
||||
return fragment
|
||||
}
|
||||
|
||||
@@ -29,10 +29,7 @@ import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.Spinner
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
@@ -57,6 +54,7 @@ class SetOTPDialogFragment : DialogFragment() {
|
||||
|
||||
private var mOtpElement: OtpElement = OtpElement()
|
||||
|
||||
private var otpTypeMessage: TextView? = null
|
||||
private var otpTypeSpinner: Spinner? = null
|
||||
private var otpTokenTypeSpinner: Spinner? = null
|
||||
private var otpSecretContainer: TextInputLayout? = null
|
||||
@@ -74,6 +72,8 @@ class SetOTPDialogFragment : DialogFragment() {
|
||||
private var totpTokenTypeAdapter: ArrayAdapter<OtpTokenType>? = null
|
||||
private var hotpTokenTypeAdapter: ArrayAdapter<OtpTokenType>? = null
|
||||
private var otpAlgorithmAdapter: ArrayAdapter<TokenCalculator.HashAlgorithm>? = null
|
||||
private var mHotpTokenTypeArray: Array<OtpTokenType>? = null
|
||||
private var mTotpTokenTypeArray: Array<OtpTokenType>? = null
|
||||
|
||||
private var mManualEvent = false
|
||||
private var mOnFocusChangeListener = View.OnFocusChangeListener { _, isFocus ->
|
||||
@@ -134,6 +134,7 @@ class SetOTPDialogFragment : DialogFragment() {
|
||||
|
||||
activity?.let { activity ->
|
||||
val root = activity.layoutInflater.inflate(R.layout.fragment_set_otp, null) as ViewGroup?
|
||||
otpTypeMessage = root?.findViewById(R.id.setup_otp_type_message)
|
||||
otpTypeSpinner = root?.findViewById(R.id.setup_otp_type)
|
||||
otpTokenTypeSpinner = root?.findViewById(R.id.setup_otp_token_type)
|
||||
otpSecretContainer = root?.findViewById(R.id.setup_otp_secret_label)
|
||||
@@ -183,23 +184,23 @@ class SetOTPDialogFragment : DialogFragment() {
|
||||
|
||||
// HOTP / TOTP Type selection
|
||||
val otpTypeArray = OtpType.values()
|
||||
otpTypeAdapter = ArrayAdapter<OtpType>(activity,
|
||||
otpTypeAdapter = ArrayAdapter(activity,
|
||||
android.R.layout.simple_spinner_item, otpTypeArray).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
otpTypeSpinner?.adapter = otpTypeAdapter
|
||||
|
||||
// Otp Token type selection
|
||||
val hotpTokenTypeArray = OtpTokenType.getHotpTokenTypeValues()
|
||||
mHotpTokenTypeArray = OtpTokenType.getHotpTokenTypeValues()
|
||||
hotpTokenTypeAdapter = ArrayAdapter(activity,
|
||||
android.R.layout.simple_spinner_item, hotpTokenTypeArray).apply {
|
||||
android.R.layout.simple_spinner_item, mHotpTokenTypeArray!!).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
// Proprietary only on closed and full version
|
||||
val totpTokenTypeArray = OtpTokenType.getTotpTokenTypeValues(
|
||||
mTotpTokenTypeArray = OtpTokenType.getTotpTokenTypeValues(
|
||||
BuildConfig.CLOSED_STORE && BuildConfig.FULL_VERSION)
|
||||
totpTokenTypeAdapter = ArrayAdapter(activity,
|
||||
android.R.layout.simple_spinner_item, totpTokenTypeArray).apply {
|
||||
android.R.layout.simple_spinner_item, mTotpTokenTypeArray!!).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
otpTokenTypeAdapter = hotpTokenTypeAdapter
|
||||
@@ -207,7 +208,7 @@ class SetOTPDialogFragment : DialogFragment() {
|
||||
|
||||
// OTP Algorithm
|
||||
val otpAlgorithmArray = TokenCalculator.HashAlgorithm.values()
|
||||
otpAlgorithmAdapter = ArrayAdapter<TokenCalculator.HashAlgorithm>(activity,
|
||||
otpAlgorithmAdapter = ArrayAdapter(activity,
|
||||
android.R.layout.simple_spinner_item, otpAlgorithmArray).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
@@ -372,24 +373,40 @@ class SetOTPDialogFragment : DialogFragment() {
|
||||
}
|
||||
|
||||
private fun upgradeTokenType() {
|
||||
val tokenType = mOtpElement.tokenType
|
||||
when (mOtpElement.type) {
|
||||
OtpType.HOTP -> {
|
||||
otpPeriodContainer?.visibility = View.GONE
|
||||
otpCounterContainer?.visibility = View.VISIBLE
|
||||
otpTokenTypeSpinner?.adapter = hotpTokenTypeAdapter
|
||||
otpTokenTypeSpinner?.setSelection(OtpTokenType
|
||||
.getHotpTokenTypeValues().indexOf(mOtpElement.tokenType))
|
||||
mHotpTokenTypeArray?.let { otpTokenTypeArray ->
|
||||
defineOtpTokenTypeSpinner(otpTokenTypeArray, tokenType, OtpTokenType.RFC4226)
|
||||
}
|
||||
}
|
||||
OtpType.TOTP -> {
|
||||
otpPeriodContainer?.visibility = View.VISIBLE
|
||||
otpCounterContainer?.visibility = View.GONE
|
||||
otpTokenTypeSpinner?.adapter = totpTokenTypeAdapter
|
||||
otpTokenTypeSpinner?.setSelection(OtpTokenType
|
||||
.getTotpTokenTypeValues().indexOf(mOtpElement.tokenType))
|
||||
mTotpTokenTypeArray?.let { otpTokenTypeArray ->
|
||||
defineOtpTokenTypeSpinner(otpTokenTypeArray, tokenType, OtpTokenType.RFC6238)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun defineOtpTokenTypeSpinner(otpTokenTypeArray: Array<OtpTokenType>,
|
||||
tokenType: OtpTokenType,
|
||||
defaultTokenType: OtpTokenType) {
|
||||
val formTokenType = if (otpTokenTypeArray.contains(tokenType)) {
|
||||
otpTypeMessage?.visibility = View.GONE
|
||||
tokenType
|
||||
} else {
|
||||
otpTypeMessage?.visibility = View.VISIBLE
|
||||
defaultTokenType
|
||||
}
|
||||
otpTokenTypeSpinner?.setSelection(otpTokenTypeArray.indexOf(formTokenType))
|
||||
}
|
||||
|
||||
private fun upgradeParameters() {
|
||||
otpAlgorithmSpinner?.setSelection(TokenCalculator.HashAlgorithm.values()
|
||||
.indexOf(mOtpElement.algorithm))
|
||||
|
||||
@@ -90,12 +90,12 @@ class UnavailableFeatureDialogFragment : DialogFragment() {
|
||||
}
|
||||
}
|
||||
if (apiName.isEmpty()) {
|
||||
val mapper = arrayOf("ANDROID BASE", "ANDROID BASE 1.1", "CUPCAKE", "DONUT", "ECLAIR", "ECLAIR_0_1", "ECLAIR_MR1", "FROYO", "GINGERBREAD", "GINGERBREAD_MR1", "HONEYCOMB", "HONEYCOMB_MR1", "HONEYCOMB_MR2", "ICE_CREAM_SANDWICH", "ICE_CREAM_SANDWICH_MR1", "JELLY_BEAN", "JELLY_BEAN", "JELLY_BEAN", "KITKAT", "KITKAT", "LOLLIPOOP", "LOLLIPOOP_MR1", "MARSHMALLOW", "NOUGAT", "NOUGAT", "OREO", "OREO")
|
||||
val mapper = arrayOf("ANDROID BASE", "ANDROID BASE 1.1", "CUPCAKE", "DONUT", "ECLAIR", "ECLAIR_0_1", "ECLAIR_MR1", "FROYO", "GINGERBREAD", "GINGERBREAD_MR1", "HONEYCOMB", "HONEYCOMB_MR1", "HONEYCOMB_MR2", "ICE_CREAM_SANDWICH", "ICE_CREAM_SANDWICH_MR1", "JELLY_BEAN", "JELLY_BEAN", "JELLY_BEAN", "KITKAT", "KITKAT", "LOLLIPOOP", "LOLLIPOOP_MR1", "MARSHMALLOW", "NOUGAT", "NOUGAT", "OREO", "OREO", "PIE", "", "")
|
||||
val index = apiNumber - 1
|
||||
apiName = if (index < mapper.size) mapper[index] else "UNKNOWN_VERSION"
|
||||
}
|
||||
if (version.isEmpty()) {
|
||||
val versions = arrayOf("1.0", "1.1", "1.5", "1.6", "2.0", "2.0.1", "2.1", "2.2.X", "2.3", "2.3.3", "3.0", "3.1", "3.2.0", "4.0.1", "4.0.3", "4.1.0", "4.2.0", "4.3.0", "4.4", "4.4", "5.0", "5.1", "6.0", "7.0", "7.1", "8.0.0", "8.1.0")
|
||||
val versions = arrayOf("1.0", "1.1", "1.5", "1.6", "2.0", "2.0.1", "2.1", "2.2.X", "2.3", "2.3.3", "3.0", "3.1", "3.2.0", "4.0.1", "4.0.3", "4.1.0", "4.2.0", "4.3.0", "4.4", "4.4", "5.0", "5.1", "6.0", "7.0", "7.1", "8.0.0", "8.1.0", "9", "10", "11")
|
||||
val index = apiNumber - 1
|
||||
version = if (index < versions.size) versions[index] else "UNKNOWN_VERSION"
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.activities.helpers
|
||||
|
||||
import android.app.assist.AssistStructure
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.kunzisoft.keepass.autofill.AutofillComponent
|
||||
import com.kunzisoft.keepass.autofill.AutofillHelper
|
||||
import com.kunzisoft.keepass.model.RegisterInfo
|
||||
import com.kunzisoft.keepass.model.SearchInfo
|
||||
@@ -106,7 +106,7 @@ object EntrySelectionHelper {
|
||||
|
||||
fun retrieveSpecialModeFromIntent(intent: Intent): SpecialMode {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (AutofillHelper.retrieveAssistStructure(intent) != null)
|
||||
if (AutofillHelper.retrieveAutofillComponent(intent) != null)
|
||||
return SpecialMode.SELECTION
|
||||
}
|
||||
return intent.getSerializableExtra(KEY_SPECIAL_MODE) as SpecialMode?
|
||||
@@ -119,7 +119,7 @@ object EntrySelectionHelper {
|
||||
|
||||
fun retrieveTypeModeFromIntent(intent: Intent): TypeMode {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (AutofillHelper.retrieveAssistStructure(intent) != null)
|
||||
if (AutofillHelper.retrieveAutofillComponent(intent) != null)
|
||||
return TypeMode.AUTOFILL
|
||||
}
|
||||
return intent.getSerializableExtra(KEY_TYPE_MODE) as TypeMode? ?: TypeMode.DEFAULT
|
||||
@@ -136,7 +136,7 @@ object EntrySelectionHelper {
|
||||
saveAction: (searchInfo: SearchInfo) -> Unit,
|
||||
keyboardSelectionAction: (searchInfo: SearchInfo?) -> Unit,
|
||||
autofillSelectionAction: (searchInfo: SearchInfo?,
|
||||
assistStructure: AssistStructure) -> Unit,
|
||||
autofillComponent: AutofillComponent) -> Unit,
|
||||
autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) {
|
||||
|
||||
when (retrieveSpecialModeFromIntent(intent)) {
|
||||
@@ -167,14 +167,14 @@ object EntrySelectionHelper {
|
||||
}
|
||||
SpecialMode.SELECTION -> {
|
||||
val searchInfo: SearchInfo? = retrieveSearchInfoFromIntent(intent)
|
||||
var assistStructureInit = false
|
||||
var autofillComponentInit = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
AutofillHelper.retrieveAssistStructure(intent)?.let { assistStructure ->
|
||||
autofillSelectionAction.invoke(searchInfo, assistStructure)
|
||||
assistStructureInit = true
|
||||
AutofillHelper.retrieveAutofillComponent(intent)?.let { autofillComponent ->
|
||||
autofillSelectionAction.invoke(searchInfo, autofillComponent)
|
||||
autofillComponentInit = true
|
||||
}
|
||||
}
|
||||
if (!assistStructureInit) {
|
||||
if (!autofillComponentInit) {
|
||||
if (intent.getSerializableExtra(KEY_SPECIAL_MODE) != null) {
|
||||
when (retrieveTypeModeFromIntent(intent)) {
|
||||
TypeMode.DEFAULT -> {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
package com.kunzisoft.keepass.activities.lock
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
@@ -59,6 +60,9 @@ abstract class LockingActivity : SpecialModeActivity() {
|
||||
private set
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
mProgressDatabaseTaskProvider = ProgressDatabaseTaskProvider(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState != null
|
||||
@@ -83,8 +87,6 @@ abstract class LockingActivity : SpecialModeActivity() {
|
||||
}
|
||||
|
||||
mExitLock = false
|
||||
|
||||
mProgressDatabaseTaskProvider = ProgressDatabaseTaskProvider(this)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
@@ -163,35 +165,6 @@ abstract class LockingActivity : SpecialModeActivity() {
|
||||
sendBroadcast(Intent(LOCK_ACTION))
|
||||
}
|
||||
|
||||
/**
|
||||
* To reset the app timeout when a view is focused or changed
|
||||
*/
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
protected fun resetAppTimeoutWhenViewFocusedOrChanged(vararg views: View?) {
|
||||
views.forEach {
|
||||
it?.setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
// Log.d(TAG, "View touched, try to reset app timeout")
|
||||
TimeoutHelper.checkTimeAndLockIfTimeoutOrResetTimeout(this)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
it?.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
// Log.d(TAG, "View focused, try to reset app timeout")
|
||||
TimeoutHelper.checkTimeAndLockIfTimeoutOrResetTimeout(this)
|
||||
}
|
||||
}
|
||||
if (it is ViewGroup) {
|
||||
for (i in 0..it.childCount) {
|
||||
resetAppTimeoutWhenViewFocusedOrChanged(it.getChildAt(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (mTimeoutEnable) {
|
||||
TimeoutHelper.checkTimeAndLockIfTimeoutOrResetTimeout(this) {
|
||||
@@ -204,7 +177,7 @@ abstract class LockingActivity : SpecialModeActivity() {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "LockingActivity"
|
||||
const val TAG = "LockingActivity"
|
||||
|
||||
const val RESULT_EXIT_LOCK = 1450
|
||||
|
||||
@@ -215,3 +188,28 @@ abstract class LockingActivity : SpecialModeActivity() {
|
||||
var LOCKING_ACTIVITY_UI_VISIBLE_DURING_LOCK: Boolean? = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* To reset the app timeout when a view is focused or changed
|
||||
*/
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
fun View.resetAppTimeoutWhenViewFocusedOrChanged(context: Context) {
|
||||
setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
//Log.d(LockingActivity.TAG, "View touched, try to reset app timeout")
|
||||
TimeoutHelper.checkTimeAndLockIfTimeoutOrResetTimeout(context)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
setOnFocusChangeListener { _, _ ->
|
||||
//Log.d(LockingActivity.TAG, "View focused, try to reset app timeout")
|
||||
TimeoutHelper.checkTimeAndLockIfTimeoutOrResetTimeout(context)
|
||||
}
|
||||
if (this is ViewGroup) {
|
||||
for (i in 0..childCount) {
|
||||
getChildAt(i)?.resetAppTimeoutWhenViewFocusedOrChanged(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import com.kunzisoft.keepass.view.SpecialModeView
|
||||
abstract class SpecialModeActivity : StylishActivity() {
|
||||
|
||||
protected var mSpecialMode: SpecialMode = SpecialMode.DEFAULT
|
||||
protected var mTypeMode: TypeMode = TypeMode.DEFAULT
|
||||
private var mTypeMode: TypeMode = TypeMode.DEFAULT
|
||||
|
||||
private var mSpecialModeView: SpecialModeView? = null
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ object Stylish {
|
||||
context.getString(R.string.list_style_name_blue) -> R.style.KeepassDXStyle_Blue
|
||||
context.getString(R.string.list_style_name_red) -> R.style.KeepassDXStyle_Red
|
||||
context.getString(R.string.list_style_name_purple) -> R.style.KeepassDXStyle_Purple
|
||||
context.getString(R.string.list_style_name_purple_dark) -> R.style.KeepassDXStyle_Purple_Dark
|
||||
else -> R.style.KeepassDXStyle_Light
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,9 @@ abstract class AnimatedItemsAdapter<Item, T: RecyclerView.ViewHolder>(val contex
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
itemsList.clear()
|
||||
notifyDataSetChanged()
|
||||
if (itemsList.size > 0) {
|
||||
itemsList.clear()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,16 +31,22 @@ import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.ImageViewerActivity
|
||||
import com.kunzisoft.keepass.database.element.Attachment
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm
|
||||
import com.kunzisoft.keepass.model.AttachmentState
|
||||
import com.kunzisoft.keepass.model.EntryAttachmentState
|
||||
import com.kunzisoft.keepass.model.StreamDirection
|
||||
import com.kunzisoft.keepass.view.expand
|
||||
|
||||
|
||||
class EntryAttachmentsItemsAdapter(context: Context)
|
||||
: AnimatedItemsAdapter<EntryAttachmentState, EntryAttachmentsItemsAdapter.EntryBinariesViewHolder>(context) {
|
||||
|
||||
var binaryCipherKey: Database.LoadedKey? = null
|
||||
var onItemClickListener: ((item: EntryAttachmentState)->Unit)? = null
|
||||
var onBinaryPreviewLoaded: ((item: EntryAttachmentState) -> Unit)? = null
|
||||
|
||||
private var mTitleColor: Int
|
||||
|
||||
@@ -62,6 +68,37 @@ class EntryAttachmentsItemsAdapter(context: Context)
|
||||
val entryAttachmentState = itemsList[position]
|
||||
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
holder.binaryFileThumbnail.apply {
|
||||
// Perform image loading only if upload is finished
|
||||
if (entryAttachmentState.downloadState != AttachmentState.START
|
||||
&& entryAttachmentState.downloadState != AttachmentState.IN_PROGRESS) {
|
||||
// Show the bitmap image if loaded
|
||||
if (entryAttachmentState.previewState == AttachmentState.NULL) {
|
||||
entryAttachmentState.previewState = AttachmentState.IN_PROGRESS
|
||||
// Load the bitmap image
|
||||
Attachment.loadBitmap(entryAttachmentState.attachment, binaryCipherKey) { imageLoaded ->
|
||||
if (imageLoaded == null) {
|
||||
entryAttachmentState.previewState = AttachmentState.ERROR
|
||||
visibility = View.GONE
|
||||
onBinaryPreviewLoaded?.invoke(entryAttachmentState)
|
||||
} else {
|
||||
entryAttachmentState.previewState = AttachmentState.COMPLETE
|
||||
setImageBitmap(imageLoaded)
|
||||
if (visibility != View.VISIBLE) {
|
||||
expand(true, resources.getDimensionPixelSize(R.dimen.item_file_info_height)) {
|
||||
onBinaryPreviewLoaded?.invoke(entryAttachmentState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visibility = View.GONE
|
||||
}
|
||||
this.setOnClickListener {
|
||||
ImageViewerActivity.getInstance(context, entryAttachmentState.attachment)
|
||||
}
|
||||
}
|
||||
holder.binaryFileBroken.apply {
|
||||
setColorFilter(Color.RED)
|
||||
visibility = if (entryAttachmentState.attachment.binaryAttachment.isCorrupted) {
|
||||
@@ -77,7 +114,7 @@ class EntryAttachmentsItemsAdapter(context: Context)
|
||||
holder.binaryFileTitle.setTextColor(mTitleColor)
|
||||
}
|
||||
holder.binaryFileSize.text = Formatter.formatFileSize(context,
|
||||
entryAttachmentState.attachment.binaryAttachment.length())
|
||||
entryAttachmentState.attachment.binaryAttachment.length)
|
||||
holder.binaryFileCompression.apply {
|
||||
if (entryAttachmentState.attachment.binaryAttachment.isCompressed) {
|
||||
text = CompressionAlgorithm.GZip.getName(context.resources)
|
||||
@@ -105,6 +142,7 @@ class EntryAttachmentsItemsAdapter(context: Context)
|
||||
}
|
||||
AttachmentState.NULL,
|
||||
AttachmentState.ERROR,
|
||||
AttachmentState.CANCELED,
|
||||
AttachmentState.COMPLETE -> {
|
||||
holder.binaryFileProgressContainer.visibility = View.GONE
|
||||
holder.binaryFileProgress.visibility = View.GONE
|
||||
@@ -114,7 +152,7 @@ class EntryAttachmentsItemsAdapter(context: Context)
|
||||
}
|
||||
}
|
||||
}
|
||||
holder.itemView.setOnClickListener(null)
|
||||
holder.binaryFileInfo.setOnClickListener(null)
|
||||
}
|
||||
StreamDirection.DOWNLOAD -> {
|
||||
holder.binaryFileProgressIcon.isActivated = false
|
||||
@@ -122,12 +160,17 @@ class EntryAttachmentsItemsAdapter(context: Context)
|
||||
holder.binaryFileDeleteButton.visibility = View.GONE
|
||||
holder.binaryFileProgress.apply {
|
||||
visibility = when (entryAttachmentState.downloadState) {
|
||||
AttachmentState.NULL, AttachmentState.COMPLETE, AttachmentState.ERROR -> View.GONE
|
||||
AttachmentState.START, AttachmentState.IN_PROGRESS -> View.VISIBLE
|
||||
AttachmentState.NULL,
|
||||
AttachmentState.COMPLETE,
|
||||
AttachmentState.CANCELED,
|
||||
AttachmentState.ERROR -> View.GONE
|
||||
|
||||
AttachmentState.START,
|
||||
AttachmentState.IN_PROGRESS -> View.VISIBLE
|
||||
}
|
||||
progress = entryAttachmentState.downloadProgression
|
||||
}
|
||||
holder.itemView.setOnClickListener {
|
||||
holder.binaryFileInfo.setOnClickListener {
|
||||
onItemClickListener?.invoke(entryAttachmentState)
|
||||
}
|
||||
}
|
||||
@@ -136,6 +179,8 @@ class EntryAttachmentsItemsAdapter(context: Context)
|
||||
|
||||
class EntryBinariesViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
var binaryFileThumbnail: ImageView = itemView.findViewById(R.id.item_attachment_thumbnail)
|
||||
var binaryFileInfo: View = itemView.findViewById(R.id.item_attachment_info)
|
||||
var binaryFileBroken: ImageView = itemView.findViewById(R.id.item_attachment_broken)
|
||||
var binaryFileTitle: TextView = itemView.findViewById(R.id.item_attachment_title)
|
||||
var binaryFileSize: TextView = itemView.findViewById(R.id.item_attachment_size)
|
||||
|
||||
@@ -57,7 +57,7 @@ class NodeAdapter (private val context: Context)
|
||||
private val mNodeSortedList: SortedList<Node>
|
||||
private val mInflater: LayoutInflater = LayoutInflater.from(context)
|
||||
|
||||
private var mCalculateViewTypeTextSize = Array(2) { true} // number of view type
|
||||
private var mCalculateViewTypeTextSize = Array(2) { true } // number of view type
|
||||
private var mTextSizeUnit: Int = TypedValue.COMPLEX_UNIT_PX
|
||||
private var mPrefSizeMultiplier: Float = 0F
|
||||
private var mSubtextDefaultDimension: Float = 0F
|
||||
|
||||
@@ -34,7 +34,7 @@ class App : MultiDexApplication() {
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
Database.getInstance().closeAndClear(UriUtil.getBinaryDir(this))
|
||||
Database.getInstance().clearAndClose(UriUtil.getBinaryDir(this))
|
||||
super.onTerminate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,27 +19,96 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.app.database
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import com.kunzisoft.keepass.services.AdvancedUnlockNotificationService
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.utils.SingletonHolderParameter
|
||||
import java.util.*
|
||||
|
||||
class CipherDatabaseAction(applicationContext: Context) {
|
||||
class CipherDatabaseAction(context: Context) {
|
||||
|
||||
private val applicationContext = context.applicationContext
|
||||
private val cipherDatabaseDao =
|
||||
AppDatabase
|
||||
.getDatabase(applicationContext)
|
||||
.cipherDatabaseDao()
|
||||
|
||||
// Temp DAO to easily remove content if object no longer in memory
|
||||
private var useTempDao = PreferencesUtil.isTempAdvancedUnlockEnable(applicationContext)
|
||||
|
||||
private val mIntentAdvancedUnlockService = Intent(applicationContext,
|
||||
AdvancedUnlockNotificationService::class.java)
|
||||
private var mBinder: AdvancedUnlockNotificationService.AdvancedUnlockBinder? = null
|
||||
private var mServiceConnection: ServiceConnection? = null
|
||||
|
||||
private var mDatabaseListeners = LinkedList<DatabaseListener>()
|
||||
|
||||
fun reloadPreferences() {
|
||||
useTempDao = PreferencesUtil.isTempAdvancedUnlockEnable(applicationContext)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun attachService(performedAction: () -> Unit) {
|
||||
// Check if a service is currently running else do nothing
|
||||
if (mBinder != null) {
|
||||
performedAction.invoke()
|
||||
} else if (mServiceConnection == null) {
|
||||
mServiceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, serviceBinder: IBinder?) {
|
||||
mBinder = (serviceBinder as AdvancedUnlockNotificationService.AdvancedUnlockBinder)
|
||||
performedAction.invoke()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
mBinder = null
|
||||
mServiceConnection = null
|
||||
mDatabaseListeners.forEach {
|
||||
it.onDatabaseCleared()
|
||||
}
|
||||
}
|
||||
}
|
||||
applicationContext.bindService(mIntentAdvancedUnlockService,
|
||||
mServiceConnection!!,
|
||||
Context.BIND_ABOVE_CLIENT)
|
||||
if (mBinder == null) {
|
||||
applicationContext.startService(mIntentAdvancedUnlockService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun registerDatabaseListener(listener: DatabaseListener) {
|
||||
mDatabaseListeners.add(listener)
|
||||
}
|
||||
|
||||
fun unregisterDatabaseListener(listener: DatabaseListener) {
|
||||
mDatabaseListeners.remove(listener)
|
||||
}
|
||||
|
||||
interface DatabaseListener {
|
||||
fun onDatabaseCleared()
|
||||
}
|
||||
|
||||
fun getCipherDatabase(databaseUri: Uri,
|
||||
cipherDatabaseResultListener: (CipherDatabaseEntity?) -> Unit) {
|
||||
IOActionTask(
|
||||
{
|
||||
cipherDatabaseDao.getByDatabaseUri(databaseUri.toString())
|
||||
},
|
||||
{
|
||||
cipherDatabaseResultListener.invoke(it)
|
||||
}
|
||||
).execute()
|
||||
if (useTempDao) {
|
||||
attachService {
|
||||
cipherDatabaseResultListener.invoke(mBinder?.getCipherDatabase(databaseUri))
|
||||
}
|
||||
} else {
|
||||
IOActionTask(
|
||||
{
|
||||
cipherDatabaseDao.getByDatabaseUri(databaseUri.toString())
|
||||
},
|
||||
{
|
||||
cipherDatabaseResultListener.invoke(it)
|
||||
}
|
||||
).execute()
|
||||
}
|
||||
}
|
||||
|
||||
fun containsCipherDatabase(databaseUri: Uri,
|
||||
@@ -51,36 +120,52 @@ class CipherDatabaseAction(applicationContext: Context) {
|
||||
|
||||
fun addOrUpdateCipherDatabase(cipherDatabaseEntity: CipherDatabaseEntity,
|
||||
cipherDatabaseResultListener: (() -> Unit)? = null) {
|
||||
IOActionTask(
|
||||
{
|
||||
val cipherDatabaseRetrieve = cipherDatabaseDao.getByDatabaseUri(cipherDatabaseEntity.databaseUri)
|
||||
|
||||
// Update values if element not yet in the database
|
||||
if (cipherDatabaseRetrieve == null) {
|
||||
cipherDatabaseDao.add(cipherDatabaseEntity)
|
||||
} else {
|
||||
cipherDatabaseDao.update(cipherDatabaseEntity)
|
||||
if (useTempDao) {
|
||||
attachService {
|
||||
mBinder?.addOrUpdateCipherDatabase(cipherDatabaseEntity)
|
||||
cipherDatabaseResultListener?.invoke()
|
||||
}
|
||||
} else {
|
||||
IOActionTask(
|
||||
{
|
||||
val cipherDatabaseRetrieve = cipherDatabaseDao.getByDatabaseUri(cipherDatabaseEntity.databaseUri)
|
||||
// Update values if element not yet in the database
|
||||
if (cipherDatabaseRetrieve == null) {
|
||||
cipherDatabaseDao.add(cipherDatabaseEntity)
|
||||
} else {
|
||||
cipherDatabaseDao.update(cipherDatabaseEntity)
|
||||
}
|
||||
},
|
||||
{
|
||||
cipherDatabaseResultListener?.invoke()
|
||||
}
|
||||
},
|
||||
{
|
||||
cipherDatabaseResultListener?.invoke()
|
||||
}
|
||||
).execute()
|
||||
).execute()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteByDatabaseUri(databaseUri: Uri,
|
||||
cipherDatabaseResultListener: (() -> Unit)? = null) {
|
||||
IOActionTask(
|
||||
{
|
||||
cipherDatabaseDao.deleteByDatabaseUri(databaseUri.toString())
|
||||
},
|
||||
{
|
||||
cipherDatabaseResultListener?.invoke()
|
||||
}
|
||||
).execute()
|
||||
if (useTempDao) {
|
||||
attachService {
|
||||
mBinder?.deleteByDatabaseUri(databaseUri)
|
||||
cipherDatabaseResultListener?.invoke()
|
||||
}
|
||||
} else {
|
||||
IOActionTask(
|
||||
{
|
||||
cipherDatabaseDao.deleteByDatabaseUri(databaseUri.toString())
|
||||
},
|
||||
{
|
||||
cipherDatabaseResultListener?.invoke()
|
||||
}
|
||||
).execute()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAll() {
|
||||
attachService {
|
||||
mBinder?.deleteAll()
|
||||
}
|
||||
IOActionTask(
|
||||
{
|
||||
cipherDatabaseDao.deleteAll()
|
||||
|
||||
@@ -43,6 +43,11 @@ data class CipherDatabaseEntity(
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!)
|
||||
|
||||
fun replaceContent(copy: CipherDatabaseEntity) {
|
||||
this.encryptedValue = copy.encryptedValue
|
||||
this.specParameters = copy.specParameters
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(databaseUri)
|
||||
parcel.writeString(encryptedValue)
|
||||
|
||||
@@ -47,7 +47,7 @@ class FileDatabaseHistoryAction(private val applicationContext: Context) {
|
||||
UriUtil.decode(fileDatabaseHistoryEntity?.databaseUri),
|
||||
fileDatabaseInfo.retrieveDatabaseAlias(fileDatabaseHistoryEntity?.databaseAlias ?: ""),
|
||||
fileDatabaseInfo.exists,
|
||||
fileDatabaseInfo.getModificationString(),
|
||||
fileDatabaseInfo.getLastModificationString(),
|
||||
fileDatabaseInfo.getSizeString()
|
||||
)
|
||||
},
|
||||
@@ -90,7 +90,7 @@ class FileDatabaseHistoryAction(private val applicationContext: Context) {
|
||||
UriUtil.decode(fileDatabaseHistoryEntity.databaseUri),
|
||||
fileDatabaseInfo.retrieveDatabaseAlias(fileDatabaseHistoryEntity.databaseAlias),
|
||||
fileDatabaseInfo.exists,
|
||||
fileDatabaseInfo.getModificationString(),
|
||||
fileDatabaseInfo.getLastModificationString(),
|
||||
fileDatabaseInfo.getSizeString()
|
||||
)
|
||||
)
|
||||
@@ -152,7 +152,7 @@ class FileDatabaseHistoryAction(private val applicationContext: Context) {
|
||||
UriUtil.decode(fileDatabaseHistory.databaseUri),
|
||||
fileDatabaseInfo.retrieveDatabaseAlias(fileDatabaseHistory.databaseAlias),
|
||||
fileDatabaseInfo.exists,
|
||||
fileDatabaseInfo.getModificationString(),
|
||||
fileDatabaseInfo.getLastModificationString(),
|
||||
fileDatabaseInfo.getSizeString()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,12 @@ class IOActionTask<T>(
|
||||
mainScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val asyncResult: Deferred<T?> = async {
|
||||
action.invoke()
|
||||
try {
|
||||
action.invoke()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
afterActionDatabaseListener?.invoke(asyncResult.await())
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.kunzisoft.keepass.autofill
|
||||
|
||||
import android.app.assist.AssistStructure
|
||||
import android.view.inputmethod.InlineSuggestionsRequest
|
||||
|
||||
data class AutofillComponent(val assistStructure: AssistStructure,
|
||||
val inlineSuggestionsRequest: InlineSuggestionsRequest?)
|
||||
@@ -19,18 +19,27 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.autofill
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.app.assist.AssistStructure
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BlendMode
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.service.autofill.Dataset
|
||||
import android.service.autofill.FillResponse
|
||||
import android.service.autofill.InlinePresentation
|
||||
import android.util.Log
|
||||
import android.view.autofill.AutofillManager
|
||||
import android.view.autofill.AutofillValue
|
||||
import android.view.inputmethod.InlineSuggestionsRequest
|
||||
import android.widget.RemoteViews
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.autofill.inline.UiVersions
|
||||
import androidx.autofill.inline.v1.InlineSuggestionUi
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
|
||||
@@ -38,8 +47,11 @@ import com.kunzisoft.keepass.activities.helpers.SpecialMode
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImage
|
||||
import com.kunzisoft.keepass.icons.assignDatabaseIcon
|
||||
import com.kunzisoft.keepass.icons.createIconFromDatabaseIcon
|
||||
import com.kunzisoft.keepass.model.EntryInfo
|
||||
import com.kunzisoft.keepass.model.SearchInfo
|
||||
import com.kunzisoft.keepass.settings.AutofillSettingsActivity
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
@@ -47,11 +59,17 @@ object AutofillHelper {
|
||||
|
||||
private const val AUTOFILL_RESPONSE_REQUEST_CODE = 8165
|
||||
|
||||
private const val ASSIST_STRUCTURE = AutofillManager.EXTRA_ASSIST_STRUCTURE
|
||||
private const val EXTRA_ASSIST_STRUCTURE = AutofillManager.EXTRA_ASSIST_STRUCTURE
|
||||
const val EXTRA_INLINE_SUGGESTIONS_REQUEST = "com.kunzisoft.keepass.autofill.INLINE_SUGGESTIONS_REQUEST"
|
||||
|
||||
fun retrieveAssistStructure(intent: Intent?): AssistStructure? {
|
||||
intent?.let {
|
||||
return it.getParcelableExtra(ASSIST_STRUCTURE)
|
||||
fun retrieveAutofillComponent(intent: Intent?): AutofillComponent? {
|
||||
intent?.getParcelableExtra<AssistStructure?>(EXTRA_ASSIST_STRUCTURE)?.let { assistStructure ->
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
AutofillComponent(assistStructure,
|
||||
intent.getParcelableExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST))
|
||||
} else {
|
||||
AutofillComponent(assistStructure, null)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -68,26 +86,10 @@ object AutofillHelper {
|
||||
return ""
|
||||
}
|
||||
|
||||
internal fun addHeader(responseBuilder: FillResponse.Builder,
|
||||
packageName: String,
|
||||
webDomain: String?,
|
||||
applicationId: String?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
if (webDomain != null) {
|
||||
responseBuilder.setHeader(RemoteViews(packageName, R.layout.item_autofill_web_domain).apply {
|
||||
setTextViewText(R.id.autofill_web_domain_text, webDomain)
|
||||
})
|
||||
} else if (applicationId != null) {
|
||||
responseBuilder.setHeader(RemoteViews(packageName, R.layout.item_autofill_app_id).apply {
|
||||
setTextViewText(R.id.autofill_app_id_text, applicationId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildDataset(context: Context,
|
||||
private fun buildDataset(context: Context,
|
||||
entryInfo: EntryInfo,
|
||||
struct: StructureParser.Result): Dataset? {
|
||||
struct: StructureParser.Result,
|
||||
inlinePresentation: InlinePresentation?): Dataset? {
|
||||
val title = makeEntryTitle(entryInfo)
|
||||
val views = newRemoteViews(context, title, entryInfo.icon)
|
||||
val builder = Dataset.Builder(views)
|
||||
@@ -100,6 +102,12 @@ object AutofillHelper {
|
||||
builder.setValue(password, AutofillValue.forText(entryInfo.password))
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
inlinePresentation?.let {
|
||||
builder.setInlinePresentation(it)
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
builder.build()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
@@ -108,44 +116,120 @@ object AutofillHelper {
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun buildInlinePresentationForEntry(context: Context,
|
||||
inlineSuggestionsRequest: InlineSuggestionsRequest,
|
||||
positionItem: Int,
|
||||
entryInfo: EntryInfo): InlinePresentation? {
|
||||
val inlinePresentationSpecs = inlineSuggestionsRequest.inlinePresentationSpecs
|
||||
val maxSuggestion = inlineSuggestionsRequest.maxSuggestionCount
|
||||
|
||||
if (positionItem <= maxSuggestion-1
|
||||
&& inlinePresentationSpecs.size > positionItem) {
|
||||
val inlinePresentationSpec = inlinePresentationSpecs[positionItem]
|
||||
|
||||
// Make sure that the IME spec claims support for v1 UI template.
|
||||
val imeStyle = inlinePresentationSpec.style
|
||||
if (!UiVersions.getVersions(imeStyle).contains(UiVersions.INLINE_UI_VERSION_1))
|
||||
return null
|
||||
|
||||
// Build the content for IME UI
|
||||
val pendingIntent = PendingIntent.getActivity(context,
|
||||
0,
|
||||
Intent(context, AutofillSettingsActivity::class.java),
|
||||
0)
|
||||
return InlinePresentation(
|
||||
InlineSuggestionUi.newContentBuilder(pendingIntent).apply {
|
||||
setContentDescription(context.getString(R.string.autofill_sign_in_prompt))
|
||||
setTitle(entryInfo.title)
|
||||
setSubtitle(entryInfo.username)
|
||||
setStartIcon(Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply {
|
||||
setTintBlendMode(BlendMode.DST)
|
||||
})
|
||||
buildIconFromEntry(context, entryInfo)?.let { icon ->
|
||||
setEndIcon(icon.apply {
|
||||
setTintBlendMode(BlendMode.DST)
|
||||
})
|
||||
}
|
||||
}.build().slice, inlinePresentationSpec, false)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun buildResponse(context: Context,
|
||||
entriesInfo: List<EntryInfo>,
|
||||
parseResult: StructureParser.Result,
|
||||
inlineSuggestionsRequest: InlineSuggestionsRequest?): FillResponse {
|
||||
val responseBuilder = FillResponse.Builder()
|
||||
// Add Header
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val packageName = context.packageName
|
||||
parseResult.webDomain?.let { webDomain ->
|
||||
responseBuilder.setHeader(RemoteViews(packageName, R.layout.item_autofill_web_domain).apply {
|
||||
setTextViewText(R.id.autofill_web_domain_text, webDomain)
|
||||
})
|
||||
} ?: kotlin.run {
|
||||
parseResult.applicationId?.let { applicationId ->
|
||||
responseBuilder.setHeader(RemoteViews(packageName, R.layout.item_autofill_app_id).apply {
|
||||
setTextViewText(R.id.autofill_app_id_text, applicationId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add inline suggestion for new IME and dataset
|
||||
entriesInfo.forEachIndexed { index, entryInfo ->
|
||||
val inlinePresentation = inlineSuggestionsRequest?.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
buildInlinePresentationForEntry(context, inlineSuggestionsRequest, index, entryInfo)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
responseBuilder.addDataset(buildDataset(context, entryInfo, parseResult, inlinePresentation))
|
||||
}
|
||||
return responseBuilder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Autofill response for one entry
|
||||
*/
|
||||
fun buildResponse(activity: Activity, entryInfo: EntryInfo) {
|
||||
buildResponse(activity, ArrayList<EntryInfo>().apply { add(entryInfo) })
|
||||
fun buildResponseAndSetResult(activity: Activity, entryInfo: EntryInfo) {
|
||||
buildResponseAndSetResult(activity, ArrayList<EntryInfo>().apply { add(entryInfo) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Autofill response for many entry
|
||||
*/
|
||||
fun buildResponse(activity: Activity, entriesInfo: List<EntryInfo>) {
|
||||
fun buildResponseAndSetResult(activity: Activity, entriesInfo: List<EntryInfo>) {
|
||||
if (entriesInfo.isEmpty()) {
|
||||
activity.setResult(Activity.RESULT_CANCELED)
|
||||
} else {
|
||||
var setResultOk = false
|
||||
activity.intent?.extras?.let { extras ->
|
||||
if (extras.containsKey(ASSIST_STRUCTURE)) {
|
||||
activity.intent?.getParcelableExtra<AssistStructure>(ASSIST_STRUCTURE)?.let { structure ->
|
||||
StructureParser(structure).parse()?.let { result ->
|
||||
// New Response
|
||||
val responseBuilder = FillResponse.Builder()
|
||||
entriesInfo.forEach {
|
||||
responseBuilder.addDataset(buildDataset(activity, it, result))
|
||||
}
|
||||
val mReplyIntent = Intent()
|
||||
Log.d(activity.javaClass.name, "Successed Autofill auth.")
|
||||
mReplyIntent.putExtra(
|
||||
AutofillManager.EXTRA_AUTHENTICATION_RESULT,
|
||||
responseBuilder.build())
|
||||
setResultOk = true
|
||||
activity.setResult(Activity.RESULT_OK, mReplyIntent)
|
||||
activity.intent?.getParcelableExtra<AssistStructure>(EXTRA_ASSIST_STRUCTURE)?.let { structure ->
|
||||
StructureParser(structure).parse()?.let { result ->
|
||||
// New Response
|
||||
val response = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val inlineSuggestionsRequest = activity.intent?.getParcelableExtra<InlineSuggestionsRequest?>(EXTRA_INLINE_SUGGESTIONS_REQUEST)
|
||||
if (inlineSuggestionsRequest != null) {
|
||||
Toast.makeText(activity.applicationContext, R.string.autofill_inline_suggestions_keyboard, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
buildResponse(activity, entriesInfo, result, inlineSuggestionsRequest)
|
||||
} else {
|
||||
buildResponse(activity, entriesInfo, result, null)
|
||||
}
|
||||
val mReplyIntent = Intent()
|
||||
Log.d(activity.javaClass.name, "Successed Autofill auth.")
|
||||
mReplyIntent.putExtra(
|
||||
AutofillManager.EXTRA_AUTHENTICATION_RESULT,
|
||||
response)
|
||||
setResultOk = true
|
||||
activity.setResult(Activity.RESULT_OK, mReplyIntent)
|
||||
}
|
||||
if (!setResultOk) {
|
||||
Log.w(activity.javaClass.name, "Failed Autofill auth.")
|
||||
activity.setResult(Activity.RESULT_CANCELED)
|
||||
}
|
||||
}
|
||||
if (!setResultOk) {
|
||||
Log.w(activity.javaClass.name, "Failed Autofill auth.")
|
||||
activity.setResult(Activity.RESULT_CANCELED)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,10 +239,16 @@ object AutofillHelper {
|
||||
*/
|
||||
fun startActivityForAutofillResult(activity: Activity,
|
||||
intent: Intent,
|
||||
assistStructure: AssistStructure,
|
||||
autofillComponent: AutofillComponent,
|
||||
searchInfo: SearchInfo?) {
|
||||
EntrySelectionHelper.addSpecialModeInIntent(intent, SpecialMode.SELECTION)
|
||||
intent.putExtra(ASSIST_STRUCTURE, assistStructure)
|
||||
intent.putExtra(EXTRA_ASSIST_STRUCTURE, autofillComponent.assistStructure)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& PreferencesUtil.isAutofillInlineSuggestionsEnable(activity)) {
|
||||
autofillComponent.inlineSuggestionsRequest?.let {
|
||||
intent.putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it)
|
||||
}
|
||||
}
|
||||
EntrySelectionHelper.addSearchInfoInIntent(intent, searchInfo)
|
||||
activity.startActivityForResult(intent, AUTOFILL_RESPONSE_REQUEST_CODE)
|
||||
}
|
||||
@@ -192,4 +282,11 @@ object AutofillHelper {
|
||||
}
|
||||
return presentation
|
||||
}
|
||||
|
||||
private fun buildIconFromEntry(context: Context, entryInfo: EntryInfo): Icon? {
|
||||
return createIconFromDatabaseIcon(context,
|
||||
Database.getInstance().drawFactory,
|
||||
entryInfo.icon,
|
||||
ContextCompat.getColor(context, R.color.green))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,37 +19,50 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.autofill
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.graphics.BlendMode
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.service.autofill.*
|
||||
import android.util.Log
|
||||
import android.view.autofill.AutofillId
|
||||
import android.view.inputmethod.InlineSuggestionsRequest
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.autofill.inline.UiVersions
|
||||
import androidx.autofill.inline.v1.InlineSuggestionUi
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.AutofillLauncherActivity
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.search.SearchHelper
|
||||
import com.kunzisoft.keepass.model.RegisterInfo
|
||||
import com.kunzisoft.keepass.model.SearchInfo
|
||||
import com.kunzisoft.keepass.settings.AutofillSettingsActivity
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
class KeeAutofillService : AutofillService() {
|
||||
|
||||
var applicationIdBlocklist: Set<String>? = null
|
||||
var webDomainBlocklist: Set<String>? = null
|
||||
var askToSaveData: Boolean = false
|
||||
var autofillInlineSuggestionsEnabled: Boolean = false
|
||||
private var mLock = AtomicBoolean()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
getPreferences()
|
||||
}
|
||||
|
||||
private fun getPreferences() {
|
||||
applicationIdBlocklist = PreferencesUtil.applicationIdBlocklist(this)
|
||||
webDomainBlocklist = PreferencesUtil.webDomainBlocklist(this)
|
||||
askToSaveData = PreferencesUtil.askToSaveAutofillData(this) // TODO apply when changed
|
||||
askToSaveData = PreferencesUtil.askToSaveAutofillData(this)
|
||||
autofillInlineSuggestionsEnabled = PreferencesUtil.isAutofillInlineSuggestionsEnable(this)
|
||||
}
|
||||
|
||||
override fun onFillRequest(request: FillRequest,
|
||||
@@ -75,7 +88,16 @@ class KeeAutofillService : AutofillService() {
|
||||
}
|
||||
SearchInfo.getConcreteWebDomain(this, searchInfo.webDomain) { webDomainWithoutSubDomain ->
|
||||
searchInfo.webDomain = webDomainWithoutSubDomain
|
||||
launchSelection(searchInfo, parseResult, callback)
|
||||
val inlineSuggestionsRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& autofillInlineSuggestionsEnabled) {
|
||||
request.inlineSuggestionsRequest
|
||||
} else {
|
||||
null
|
||||
}
|
||||
launchSelection(searchInfo,
|
||||
parseResult,
|
||||
inlineSuggestionsRequest,
|
||||
callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,39 +106,40 @@ class KeeAutofillService : AutofillService() {
|
||||
|
||||
private fun launchSelection(searchInfo: SearchInfo,
|
||||
parseResult: StructureParser.Result,
|
||||
inlineSuggestionsRequest: InlineSuggestionsRequest?,
|
||||
callback: FillCallback) {
|
||||
SearchHelper.checkAutoSearchInfo(this,
|
||||
Database.getInstance(),
|
||||
searchInfo,
|
||||
{ items ->
|
||||
val responseBuilder = FillResponse.Builder()
|
||||
AutofillHelper.addHeader(responseBuilder, packageName,
|
||||
parseResult.webDomain, parseResult.applicationId)
|
||||
items.forEach {
|
||||
responseBuilder.addDataset(AutofillHelper.buildDataset(this, it, parseResult))
|
||||
}
|
||||
callback.onSuccess(responseBuilder.build())
|
||||
callback.onSuccess(
|
||||
AutofillHelper.buildResponse(this,
|
||||
items, parseResult, inlineSuggestionsRequest)
|
||||
)
|
||||
},
|
||||
{
|
||||
// Show UI if no search result
|
||||
showUIForEntrySelection(parseResult, searchInfo, callback)
|
||||
showUIForEntrySelection(parseResult,
|
||||
searchInfo, inlineSuggestionsRequest, callback)
|
||||
},
|
||||
{
|
||||
// Show UI if database not open
|
||||
showUIForEntrySelection(parseResult, searchInfo, callback)
|
||||
showUIForEntrySelection(parseResult,
|
||||
searchInfo, inlineSuggestionsRequest, callback)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showUIForEntrySelection(parseResult: StructureParser.Result,
|
||||
searchInfo: SearchInfo,
|
||||
inlineSuggestionsRequest: InlineSuggestionsRequest?,
|
||||
callback: FillCallback) {
|
||||
parseResult.allAutofillIds().let { autofillIds ->
|
||||
if (autofillIds.isNotEmpty()) {
|
||||
// If the entire Autofill Response is authenticated, AuthActivity is used
|
||||
// to generate Response.
|
||||
val intentSender = AutofillLauncherActivity.getAuthIntentSenderForSelection(this,
|
||||
searchInfo)
|
||||
searchInfo, inlineSuggestionsRequest)
|
||||
val responseBuilder = FillResponse.Builder()
|
||||
val remoteViewsUnlock: RemoteViews = if (!parseResult.webDomain.isNullOrEmpty()) {
|
||||
RemoteViews(packageName, R.layout.item_autofill_unlock_web_domain).apply {
|
||||
@@ -149,7 +172,40 @@ class KeeAutofillService : AutofillService() {
|
||||
)
|
||||
}
|
||||
}
|
||||
// Build response
|
||||
|
||||
// Build inline presentation
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& autofillInlineSuggestionsEnabled) {
|
||||
var inlinePresentation: InlinePresentation? = null
|
||||
inlineSuggestionsRequest?.let {
|
||||
val inlinePresentationSpecs = inlineSuggestionsRequest.inlinePresentationSpecs
|
||||
if (inlineSuggestionsRequest.maxSuggestionCount > 0
|
||||
&& inlinePresentationSpecs.size > 0) {
|
||||
val inlinePresentationSpec = inlinePresentationSpecs[0]
|
||||
|
||||
// Make sure that the IME spec claims support for v1 UI template.
|
||||
val imeStyle = inlinePresentationSpec.style
|
||||
if (UiVersions.getVersions(imeStyle).contains(UiVersions.INLINE_UI_VERSION_1)) {
|
||||
// Build the content for IME UI
|
||||
inlinePresentation = InlinePresentation(
|
||||
InlineSuggestionUi.newContentBuilder(
|
||||
PendingIntent.getActivity(this,
|
||||
0,
|
||||
Intent(this, AutofillSettingsActivity::class.java),
|
||||
0)
|
||||
).apply {
|
||||
setContentDescription(getString(R.string.autofill_sign_in_prompt))
|
||||
setTitle(getString(R.string.autofill_sign_in_prompt))
|
||||
setStartIcon(Icon.createWithResource(this@KeeAutofillService, R.mipmap.ic_launcher_round).apply {
|
||||
setTintBlendMode(BlendMode.DST)
|
||||
})
|
||||
}.build().slice, inlinePresentationSpec, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build response
|
||||
responseBuilder.setAuthentication(autofillIds, intentSender, remoteViewsUnlock, inlinePresentation)
|
||||
}
|
||||
responseBuilder.setAuthentication(autofillIds, intentSender, remoteViewsUnlock)
|
||||
callback.onSuccess(responseBuilder.build())
|
||||
}
|
||||
@@ -190,6 +246,7 @@ class KeeAutofillService : AutofillService() {
|
||||
|
||||
override fun onConnected() {
|
||||
Log.d(TAG, "onConnected")
|
||||
getPreferences()
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
|
||||
@@ -33,7 +33,7 @@ import java.util.*
|
||||
* Parse AssistStructure and guess username and password fields.
|
||||
*/
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
internal class StructureParser(private val structure: AssistStructure) {
|
||||
class StructureParser(private val structure: AssistStructure) {
|
||||
private var result: Result? = null
|
||||
|
||||
private var usernameNeeded = true
|
||||
@@ -274,7 +274,7 @@ internal class StructureParser(private val structure: AssistStructure) {
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
internal class Result {
|
||||
class Result {
|
||||
var applicationId: String? = null
|
||||
|
||||
var webDomain: String? = null
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import javax.crypto.Cipher
|
||||
|
||||
data class AdvancedUnlockCryptoPrompt(var cipher: Cipher,
|
||||
@StringRes var promptTitleId: Int,
|
||||
@StringRes var promptDescriptionId: Int? = null,
|
||||
var isDeviceCredentialOperation: Boolean,
|
||||
var isBiometricOperation: Boolean)
|
||||
@@ -0,0 +1,628 @@
|
||||
/*
|
||||
* Copyright 2020 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import com.getkeepsafe.taptargetview.TapTargetView
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.stylish.StylishFragment
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
|
||||
import com.kunzisoft.keepass.database.exception.IODatabaseException
|
||||
import com.kunzisoft.keepass.education.PasswordActivityEducation
|
||||
import com.kunzisoft.keepass.services.AdvancedUnlockNotificationService
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.view.AdvancedUnlockInfoView
|
||||
|
||||
class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedUnlockCallback {
|
||||
|
||||
private var mBuilderListener: BuilderListener? = null
|
||||
|
||||
private var mAdvancedUnlockEnabled = false
|
||||
private var mAutoOpenPromptEnabled = false
|
||||
|
||||
private var advancedUnlockManager: AdvancedUnlockManager? = null
|
||||
private var biometricMode: Mode = Mode.BIOMETRIC_UNAVAILABLE
|
||||
private var mAdvancedUnlockInfoView: AdvancedUnlockInfoView? = null
|
||||
|
||||
var databaseFileUri: Uri? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Manage setting to auto open biometric prompt
|
||||
*/
|
||||
private var mAutoOpenPrompt: Boolean = false
|
||||
get() {
|
||||
return field && mAutoOpenPromptEnabled
|
||||
}
|
||||
|
||||
// Variable to check if the prompt can be open (if the right activity is currently shown)
|
||||
// checkBiometricAvailability() allows open biometric prompt and onDestroy() removes the authorization
|
||||
private var allowOpenBiometricPrompt = false
|
||||
|
||||
private lateinit var cipherDatabaseAction : CipherDatabaseAction
|
||||
|
||||
private var cipherDatabaseListener: CipherDatabaseAction.DatabaseListener? = null
|
||||
|
||||
// Only to fix multiple fingerprint menu #332
|
||||
private var mAllowAdvancedUnlockMenu = false
|
||||
private var mAddBiometricMenuInProgress = false
|
||||
|
||||
// Only keep connection when we request a device credential activity
|
||||
private var keepConnection = false
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
mAdvancedUnlockEnabled = PreferencesUtil.isAdvancedUnlockEnable(context)
|
||||
mAutoOpenPromptEnabled = PreferencesUtil.isAdvancedUnlockPromptAutoOpenEnable(context)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
mBuilderListener = context as BuilderListener
|
||||
}
|
||||
} catch (e: ClassCastException) {
|
||||
throw ClassCastException(context.toString()
|
||||
+ " must implement " + BuilderListener::class.java.name)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
retainInstance = true
|
||||
setHasOptionsMenu(true)
|
||||
|
||||
cipherDatabaseAction = CipherDatabaseAction.getInstance(requireContext().applicationContext)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
val rootView = inflater.cloneInContext(contextThemed)
|
||||
.inflate(R.layout.fragment_advanced_unlock, container, false)
|
||||
|
||||
mAdvancedUnlockInfoView = rootView.findViewById(R.id.advanced_unlock_view)
|
||||
|
||||
return rootView
|
||||
}
|
||||
|
||||
private data class ActivityResult(var requestCode: Int, var resultCode: Int, var data: Intent?)
|
||||
private var activityResult: ActivityResult? = null
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
// To wait resume
|
||||
if (keepConnection) {
|
||||
activityResult = ActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
keepConnection = false
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mAdvancedUnlockEnabled = PreferencesUtil.isAdvancedUnlockEnable(requireContext())
|
||||
mAutoOpenPromptEnabled = PreferencesUtil.isAdvancedUnlockPromptAutoOpenEnable(requireContext())
|
||||
keepConnection = false
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// biometric menu
|
||||
if (mAllowAdvancedUnlockMenu)
|
||||
inflater.inflate(R.menu.advanced_unlock, menu)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.menu_keystore_remove_key -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
deleteEncryptedDatabaseKey()
|
||||
}
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
fun loadDatabase(databaseUri: Uri?, autoOpenPrompt: Boolean) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// To get device credential unlock result, only if same database uri
|
||||
if (databaseUri != null
|
||||
&& mAdvancedUnlockEnabled) {
|
||||
activityResult?.let {
|
||||
if (databaseUri == databaseFileUri) {
|
||||
advancedUnlockManager?.onActivityResult(it.requestCode, it.resultCode)
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
} ?: run {
|
||||
this.mAutoOpenPrompt = autoOpenPrompt
|
||||
connect(databaseUri)
|
||||
}
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
activityResult = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check unlock availability and change the current mode depending of device's state
|
||||
*/
|
||||
fun checkUnlockAvailability() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
allowOpenBiometricPrompt = true
|
||||
if (PreferencesUtil.isBiometricUnlockEnable(requireContext())) {
|
||||
mAdvancedUnlockInfoView?.setIconResource(R.drawable.fingerprint)
|
||||
|
||||
// biometric not supported (by API level or hardware) so keep option hidden
|
||||
// or manually disable
|
||||
val biometricCanAuthenticate = AdvancedUnlockManager.canAuthenticate(requireContext())
|
||||
if (!PreferencesUtil.isAdvancedUnlockEnable(requireContext())
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE) {
|
||||
toggleMode(Mode.BIOMETRIC_UNAVAILABLE)
|
||||
} else if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED) {
|
||||
toggleMode(Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED)
|
||||
} else {
|
||||
// biometric is available but not configured, show icon but in disabled state with some information
|
||||
if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) {
|
||||
toggleMode(Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
|
||||
} else {
|
||||
selectMode()
|
||||
}
|
||||
}
|
||||
} else if (PreferencesUtil.isDeviceCredentialUnlockEnable(requireContext())) {
|
||||
mAdvancedUnlockInfoView?.setIconResource(R.drawable.bolt)
|
||||
if (AdvancedUnlockManager.isDeviceSecure(requireContext())) {
|
||||
selectMode()
|
||||
} else {
|
||||
toggleMode(Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun selectMode() {
|
||||
// Check if fingerprint well init (be called the first time the fingerprint is configured
|
||||
// and the activity still active)
|
||||
if (advancedUnlockManager?.isKeyManagerInitialized != true) {
|
||||
advancedUnlockManager = AdvancedUnlockManager { requireActivity() }
|
||||
// callback for fingerprint findings
|
||||
advancedUnlockManager?.advancedUnlockCallback = this
|
||||
}
|
||||
// Recheck to change the mode
|
||||
if (advancedUnlockManager?.isKeyManagerInitialized != true) {
|
||||
toggleMode(Mode.KEY_MANAGER_UNAVAILABLE)
|
||||
} else {
|
||||
if (mBuilderListener?.conditionToStoreCredential() == true) {
|
||||
// listen for encryption
|
||||
toggleMode(Mode.STORE_CREDENTIAL)
|
||||
} else {
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.containsCipherDatabase(databaseUri) { containsCipher ->
|
||||
// biometric available but no stored password found yet for this DB so show info don't listen
|
||||
toggleMode(if (containsCipher) {
|
||||
// listen for decryption
|
||||
Mode.EXTRACT_CREDENTIAL
|
||||
} else {
|
||||
// wait for typing
|
||||
Mode.WAIT_CREDENTIAL
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun toggleMode(newBiometricMode: Mode) {
|
||||
if (newBiometricMode != biometricMode) {
|
||||
biometricMode = newBiometricMode
|
||||
initAdvancedUnlockMode()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initNotAvailable() {
|
||||
showViews(false)
|
||||
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener(false, null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun openBiometricSetting() {
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener(false) {
|
||||
// ACTION_SECURITY_SETTINGS does not contain fingerprint enrollment on some devices...
|
||||
requireContext().startActivity(Intent(Settings.ACTION_SETTINGS))
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initSecurityUpdateRequired() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.biometric_security_update_required)
|
||||
|
||||
openBiometricSetting()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initNotConfigured() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.configure_biometric)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
openBiometricSetting()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initKeyManagerNotAvailable() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.keystore_not_accessible)
|
||||
|
||||
openBiometricSetting()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initWaitData() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.no_credentials_stored)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener(false) {
|
||||
onAuthenticationError(BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
|
||||
requireContext().getString(R.string.credential_before_click_advanced_unlock_button))
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun openAdvancedUnlockPrompt(cryptoPrompt: AdvancedUnlockCryptoPrompt) {
|
||||
activity?.runOnUiThread {
|
||||
if (allowOpenBiometricPrompt) {
|
||||
if (cryptoPrompt.isDeviceCredentialOperation)
|
||||
keepConnection = true
|
||||
try {
|
||||
advancedUnlockManager?.openAdvancedUnlockPrompt(cryptoPrompt)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open advanced unlock prompt", e)
|
||||
setAdvancedUnlockedTitleView(R.string.advanced_unlock_prompt_not_initialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initEncryptData() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.open_advanced_unlock_prompt_store_credential)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
advancedUnlockManager?.initEncryptData { cryptoPrompt ->
|
||||
// Set listener to open the biometric dialog and save credential
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener { _ ->
|
||||
openAdvancedUnlockPrompt(cryptoPrompt)
|
||||
}
|
||||
} ?: throw Exception("AdvancedUnlockManager not initialized")
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun initDecryptData() {
|
||||
showViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.open_advanced_unlock_prompt_unlock_database)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
advancedUnlockManager?.let { unlockHelper ->
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
|
||||
cipherDatabase?.let {
|
||||
unlockHelper.initDecryptData(it.specParameters) { cryptoPrompt ->
|
||||
|
||||
// Set listener to open the biometric dialog and check credential
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener { _ ->
|
||||
openAdvancedUnlockPrompt(cryptoPrompt)
|
||||
}
|
||||
|
||||
// Auto open the biometric prompt
|
||||
if (mAutoOpenPrompt) {
|
||||
mAutoOpenPrompt = false
|
||||
openAdvancedUnlockPrompt(cryptoPrompt)
|
||||
}
|
||||
}
|
||||
} ?: deleteEncryptedDatabaseKey()
|
||||
}
|
||||
} ?: throw IODatabaseException()
|
||||
} ?: throw Exception("AdvancedUnlockManager not initialized")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun initAdvancedUnlockMode() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
mAllowAdvancedUnlockMenu = false
|
||||
try {
|
||||
when (biometricMode) {
|
||||
Mode.BIOMETRIC_UNAVAILABLE -> initNotAvailable()
|
||||
Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> initSecurityUpdateRequired()
|
||||
Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED -> initNotConfigured()
|
||||
Mode.KEY_MANAGER_UNAVAILABLE -> initKeyManagerNotAvailable()
|
||||
Mode.WAIT_CREDENTIAL -> initWaitData()
|
||||
Mode.STORE_CREDENTIAL -> initEncryptData()
|
||||
Mode.EXTRACT_CREDENTIAL -> initDecryptData()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onGenericException(e)
|
||||
}
|
||||
invalidateBiometricMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateBiometricMenu() {
|
||||
// Show fingerprint key deletion
|
||||
if (!mAddBiometricMenuInProgress) {
|
||||
mAddBiometricMenuInProgress = true
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.containsCipherDatabase(databaseUri) { containsCipher ->
|
||||
mAllowAdvancedUnlockMenu = containsCipher
|
||||
&& (biometricMode != Mode.BIOMETRIC_UNAVAILABLE
|
||||
&& biometricMode != Mode.KEY_MANAGER_UNAVAILABLE)
|
||||
mAddBiometricMenuInProgress = false
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun connect(databaseUri: Uri) {
|
||||
showViews(true)
|
||||
this.databaseFileUri = databaseUri
|
||||
cipherDatabaseListener = object: CipherDatabaseAction.DatabaseListener {
|
||||
override fun onDatabaseCleared() {
|
||||
deleteEncryptedDatabaseKey()
|
||||
}
|
||||
}
|
||||
cipherDatabaseAction.apply {
|
||||
reloadPreferences()
|
||||
cipherDatabaseListener?.let {
|
||||
registerDatabaseListener(it)
|
||||
}
|
||||
}
|
||||
checkUnlockAvailability()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun disconnect(hideViews: Boolean = true,
|
||||
closePrompt: Boolean = true) {
|
||||
this.databaseFileUri = null
|
||||
// Close the biometric prompt
|
||||
allowOpenBiometricPrompt = false
|
||||
if (closePrompt)
|
||||
advancedUnlockManager?.closeBiometricPrompt()
|
||||
cipherDatabaseListener?.let {
|
||||
cipherDatabaseAction.unregisterDatabaseListener(it)
|
||||
}
|
||||
biometricMode = Mode.BIOMETRIC_UNAVAILABLE
|
||||
if (hideViews) {
|
||||
showViews(false)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun deleteEncryptedDatabaseKey() {
|
||||
allowOpenBiometricPrompt = false
|
||||
mAdvancedUnlockInfoView?.setIconViewClickListener(false, null)
|
||||
advancedUnlockManager?.closeBiometricPrompt()
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.deleteByDatabaseUri(databaseUri) {
|
||||
checkUnlockAvailability()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
activity?.runOnUiThread {
|
||||
Log.e(TAG, "Biometric authentication error. Code : $errorCode Error : $errString")
|
||||
setAdvancedUnlockedMessageView(errString.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onAuthenticationFailed() {
|
||||
activity?.runOnUiThread {
|
||||
Log.e(TAG, "Biometric authentication failed, biometric not recognized")
|
||||
setAdvancedUnlockedMessageView(R.string.advanced_unlock_not_recognized)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onAuthenticationSucceeded() {
|
||||
activity?.runOnUiThread {
|
||||
when (biometricMode) {
|
||||
Mode.BIOMETRIC_UNAVAILABLE -> {
|
||||
}
|
||||
Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> {
|
||||
}
|
||||
Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED -> {
|
||||
}
|
||||
Mode.KEY_MANAGER_UNAVAILABLE -> {
|
||||
}
|
||||
Mode.WAIT_CREDENTIAL -> {
|
||||
}
|
||||
Mode.STORE_CREDENTIAL -> {
|
||||
// newly store the entered password in encrypted way
|
||||
mBuilderListener?.retrieveCredentialForEncryption()?.let { credential ->
|
||||
advancedUnlockManager?.encryptData(credential)
|
||||
}
|
||||
AdvancedUnlockNotificationService.startServiceForTimeout(requireContext())
|
||||
}
|
||||
Mode.EXTRACT_CREDENTIAL -> {
|
||||
// retrieve the encrypted value from preferences
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
cipherDatabaseAction.getCipherDatabase(databaseUri) { cipherDatabase ->
|
||||
cipherDatabase?.encryptedValue?.let { value ->
|
||||
advancedUnlockManager?.decryptData(value)
|
||||
} ?: deleteEncryptedDatabaseKey()
|
||||
}
|
||||
} ?: run {
|
||||
onAuthenticationError(-1, getString(R.string.error_database_uri_null))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleEncryptedResult(encryptedValue: String, ivSpec: String) {
|
||||
databaseFileUri?.let { databaseUri ->
|
||||
mBuilderListener?.onCredentialEncrypted(databaseUri, encryptedValue, ivSpec)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleDecryptedResult(decryptedValue: String) {
|
||||
// Load database directly with password retrieve
|
||||
databaseFileUri?.let {
|
||||
mBuilderListener?.onCredentialDecrypted(it, decryptedValue)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onInvalidKeyException(e: Exception) {
|
||||
setAdvancedUnlockedMessageView(R.string.advanced_unlock_invalid_key)
|
||||
}
|
||||
|
||||
override fun onGenericException(e: Exception) {
|
||||
val errorMessage = e.cause?.localizedMessage ?: e.localizedMessage ?: ""
|
||||
setAdvancedUnlockedMessageView(errorMessage)
|
||||
}
|
||||
|
||||
private fun showViews(show: Boolean) {
|
||||
activity?.runOnUiThread {
|
||||
mAdvancedUnlockInfoView?.visibility = if (show)
|
||||
View.VISIBLE
|
||||
else {
|
||||
View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setAdvancedUnlockedTitleView(textId: Int) {
|
||||
activity?.runOnUiThread {
|
||||
mAdvancedUnlockInfoView?.setTitle(textId)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setAdvancedUnlockedMessageView(textId: Int) {
|
||||
activity?.runOnUiThread {
|
||||
mAdvancedUnlockInfoView?.setMessage(textId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAdvancedUnlockedMessageView(text: CharSequence) {
|
||||
activity?.runOnUiThread {
|
||||
mAdvancedUnlockInfoView?.message = text
|
||||
}
|
||||
}
|
||||
|
||||
fun performEducation(passwordActivityEducation: PasswordActivityEducation,
|
||||
readOnlyEducationPerformed: Boolean,
|
||||
onEducationViewClick: ((TapTargetView?) -> Unit)? = null,
|
||||
onOuterViewClick: ((TapTargetView?) -> Unit)? = null) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||
&& !readOnlyEducationPerformed) {
|
||||
val biometricCanAuthenticate = AdvancedUnlockManager.canAuthenticate(requireContext())
|
||||
PreferencesUtil.isAdvancedUnlockEnable(requireContext())
|
||||
&& (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_SUCCESS)
|
||||
&& mAdvancedUnlockInfoView != null && mAdvancedUnlockInfoView?.visibility == View.VISIBLE
|
||||
&& mAdvancedUnlockInfoView?.unlockIconImageView != null
|
||||
&& passwordActivityEducation.checkAndPerformedBiometricEducation(mAdvancedUnlockInfoView!!.unlockIconImageView!!,
|
||||
onEducationViewClick,
|
||||
onOuterViewClick)
|
||||
}
|
||||
} catch (ignored: Exception) {}
|
||||
}
|
||||
|
||||
enum class Mode {
|
||||
BIOMETRIC_UNAVAILABLE,
|
||||
BIOMETRIC_SECURITY_UPDATE_REQUIRED,
|
||||
DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED,
|
||||
KEY_MANAGER_UNAVAILABLE,
|
||||
WAIT_CREDENTIAL,
|
||||
STORE_CREDENTIAL,
|
||||
EXTRACT_CREDENTIAL
|
||||
}
|
||||
|
||||
interface BuilderListener {
|
||||
fun retrieveCredentialForEncryption(): String
|
||||
fun conditionToStoreCredential(): Boolean
|
||||
fun onCredentialEncrypted(databaseUri: Uri, encryptedCredential: String, ivSpec: String)
|
||||
fun onCredentialDecrypted(databaseUri: Uri, decryptedCredential: String)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (!keepConnection) {
|
||||
// If close prompt, bug "user not authenticated in Android R"
|
||||
disconnect(false)
|
||||
advancedUnlockManager = null
|
||||
}
|
||||
}
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
mAdvancedUnlockInfoView = null
|
||||
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
disconnect()
|
||||
advancedUnlockManager = null
|
||||
mBuilderListener = null
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
mBuilderListener = null
|
||||
|
||||
super.onDetach()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = AdvancedUnlockFragment::class.java.name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
/*
|
||||
* Copyright 2020 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.*
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import java.security.KeyStore
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.util.concurrent.Executors
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity) {
|
||||
|
||||
private var keyStore: KeyStore? = null
|
||||
private var keyGenerator: KeyGenerator? = null
|
||||
private var cipher: Cipher? = null
|
||||
|
||||
private var biometricPrompt: BiometricPrompt? = null
|
||||
private var authenticationCallback = object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
advancedUnlockCallback?.onAuthenticationSucceeded()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
advancedUnlockCallback?.onAuthenticationFailed()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
advancedUnlockCallback?.onAuthenticationError(errorCode, errString)
|
||||
}
|
||||
}
|
||||
|
||||
var advancedUnlockCallback: AdvancedUnlockCallback? = null
|
||||
|
||||
private var isKeyManagerInit = false
|
||||
|
||||
private val biometricUnlockEnable = PreferencesUtil.isBiometricUnlockEnable(retrieveContext())
|
||||
private val deviceCredentialUnlockEnable = PreferencesUtil.isDeviceCredentialUnlockEnable(retrieveContext())
|
||||
|
||||
val isKeyManagerInitialized: Boolean
|
||||
get() {
|
||||
if (!isKeyManagerInit) {
|
||||
advancedUnlockCallback?.onGenericException(Exception("Biometric not initialized"))
|
||||
}
|
||||
return isKeyManagerInit
|
||||
}
|
||||
|
||||
private fun isBiometricOperation(): Boolean {
|
||||
return biometricUnlockEnable || isDeviceCredentialBiometricOperation()
|
||||
}
|
||||
|
||||
// Since Android 30, device credential is also a biometric operation
|
||||
private fun isDeviceCredentialOperation(): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R
|
||||
&& deviceCredentialUnlockEnable
|
||||
}
|
||||
|
||||
private fun isDeviceCredentialBiometricOperation(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& deviceCredentialUnlockEnable
|
||||
}
|
||||
|
||||
init {
|
||||
if (isDeviceSecure(retrieveContext())
|
||||
&& (biometricUnlockEnable || deviceCredentialUnlockEnable)) {
|
||||
try {
|
||||
this.keyStore = KeyStore.getInstance(ADVANCED_UNLOCK_KEYSTORE)
|
||||
this.keyGenerator = KeyGenerator.getInstance(ADVANCED_UNLOCK_KEY_ALGORITHM, ADVANCED_UNLOCK_KEYSTORE)
|
||||
this.cipher = Cipher.getInstance(
|
||||
ADVANCED_UNLOCK_KEY_ALGORITHM + "/"
|
||||
+ ADVANCED_UNLOCK_BLOCKS_MODES + "/"
|
||||
+ ADVANCED_UNLOCK_ENCRYPTION_PADDING)
|
||||
isKeyManagerInit = (keyStore != null
|
||||
&& keyGenerator != null
|
||||
&& cipher != null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initialize the keystore", e)
|
||||
isKeyManagerInit = false
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
}
|
||||
} else {
|
||||
// really not much to do when no fingerprint support found
|
||||
isKeyManagerInit = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSecretKey(): SecretKey? {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
// Create new key if needed
|
||||
keyStore?.let { keyStore ->
|
||||
keyStore.load(null)
|
||||
|
||||
try {
|
||||
if (!keyStore.containsAlias(ADVANCED_UNLOCK_KEYSTORE_KEY)) {
|
||||
// Set the alias of the entry in Android KeyStore where the key will appear
|
||||
// and the constrains (purposes) in the constructor of the Builder
|
||||
keyGenerator?.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
ADVANCED_UNLOCK_KEYSTORE_KEY,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
|
||||
// Require the user to authenticate with a fingerprint to authorize every use
|
||||
// of the key, don't use it for device credential because it's the user authentication
|
||||
.apply {
|
||||
if (biometricUnlockEnable) {
|
||||
setUserAuthenticationRequired(true)
|
||||
}
|
||||
}
|
||||
.build())
|
||||
keyGenerator?.generateKey()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to create a key in keystore", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
}
|
||||
|
||||
return keyStore.getKey(ADVANCED_UNLOCK_KEYSTORE_KEY, null) as SecretKey?
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to retrieve the key in keystore", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun initEncryptData(actionIfCypherInit
|
||||
: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
getSecretKey()?.let { secretKey ->
|
||||
cipher?.let { cipher ->
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
|
||||
actionIfCypherInit.invoke(
|
||||
AdvancedUnlockCryptoPrompt(
|
||||
cipher,
|
||||
R.string.advanced_unlock_prompt_store_credential_title,
|
||||
R.string.advanced_unlock_prompt_store_credential_message,
|
||||
isDeviceCredentialOperation(), isBiometricOperation())
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (unrecoverableKeyException: UnrecoverableKeyException) {
|
||||
Log.e(TAG, "Unable to initialize encrypt data", unrecoverableKeyException)
|
||||
advancedUnlockCallback?.onInvalidKeyException(unrecoverableKeyException)
|
||||
} catch (invalidKeyException: KeyPermanentlyInvalidatedException) {
|
||||
Log.e(TAG, "Unable to initialize encrypt data", invalidKeyException)
|
||||
advancedUnlockCallback?.onInvalidKeyException(invalidKeyException)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initialize encrypt data", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun encryptData(value: String) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
val encrypted = cipher?.doFinal(value.toByteArray())
|
||||
val encryptedBase64 = Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
|
||||
// passes updated iv spec on to callback so this can be stored for decryption
|
||||
cipher?.parameters?.getParameterSpec(IvParameterSpec::class.java)?.let{ spec ->
|
||||
val ivSpecValue = Base64.encodeToString(spec.iv, Base64.NO_WRAP)
|
||||
advancedUnlockCallback?.handleEncryptedResult(encryptedBase64, ivSpecValue)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to encrypt data", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun initDecryptData(ivSpecValue: String, actionIfCypherInit
|
||||
: (cryptoPrompt: AdvancedUnlockCryptoPrompt) -> Unit) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// important to restore spec here that was used for decryption
|
||||
val iv = Base64.decode(ivSpecValue, Base64.NO_WRAP)
|
||||
val spec = IvParameterSpec(iv)
|
||||
|
||||
getSecretKey()?.let { secretKey ->
|
||||
cipher?.let { cipher ->
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||
|
||||
actionIfCypherInit.invoke(
|
||||
AdvancedUnlockCryptoPrompt(
|
||||
cipher,
|
||||
R.string.advanced_unlock_prompt_extract_credential_title,
|
||||
null,
|
||||
isDeviceCredentialOperation(), isBiometricOperation())
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (unrecoverableKeyException: UnrecoverableKeyException) {
|
||||
Log.e(TAG, "Unable to initialize decrypt data", unrecoverableKeyException)
|
||||
deleteKeystoreKey()
|
||||
} catch (invalidKeyException: KeyPermanentlyInvalidatedException) {
|
||||
Log.e(TAG, "Unable to initialize decrypt data", invalidKeyException)
|
||||
advancedUnlockCallback?.onInvalidKeyException(invalidKeyException)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initialize decrypt data", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptData(encryptedValue: String) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// actual decryption here
|
||||
val encrypted = Base64.decode(encryptedValue, Base64.NO_WRAP)
|
||||
cipher?.doFinal(encrypted)?.let { decrypted ->
|
||||
advancedUnlockCallback?.handleDecryptedResult(String(decrypted))
|
||||
}
|
||||
} catch (badPaddingException: BadPaddingException) {
|
||||
Log.e(TAG, "Unable to decrypt data", badPaddingException)
|
||||
advancedUnlockCallback?.onInvalidKeyException(badPaddingException)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to decrypt data", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteKeystoreKey() {
|
||||
try {
|
||||
keyStore?.load(null)
|
||||
keyStore?.deleteEntry(ADVANCED_UNLOCK_KEYSTORE_KEY)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to delete entry key in keystore", e)
|
||||
advancedUnlockCallback?.onGenericException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Synchronized
|
||||
fun openAdvancedUnlockPrompt(cryptoPrompt: AdvancedUnlockCryptoPrompt) {
|
||||
// Init advanced unlock prompt
|
||||
if (biometricPrompt == null) {
|
||||
biometricPrompt = BiometricPrompt(retrieveContext(),
|
||||
Executors.newSingleThreadExecutor(),
|
||||
authenticationCallback)
|
||||
}
|
||||
|
||||
val promptTitle = retrieveContext().getString(cryptoPrompt.promptTitleId)
|
||||
val promptDescription = cryptoPrompt.promptDescriptionId?.let { descriptionId ->
|
||||
retrieveContext().getString(descriptionId)
|
||||
} ?: ""
|
||||
|
||||
if (cryptoPrompt.isBiometricOperation) {
|
||||
val promptInfoExtractCredential = BiometricPrompt.PromptInfo.Builder().apply {
|
||||
setTitle(promptTitle)
|
||||
if (promptDescription.isNotEmpty())
|
||||
setDescription(promptDescription)
|
||||
setConfirmationRequired(false)
|
||||
if (isDeviceCredentialBiometricOperation()) {
|
||||
setAllowedAuthenticators(DEVICE_CREDENTIAL)
|
||||
} else {
|
||||
setNegativeButtonText(retrieveContext().getString(android.R.string.cancel))
|
||||
}
|
||||
}.build()
|
||||
biometricPrompt?.authenticate(
|
||||
promptInfoExtractCredential,
|
||||
BiometricPrompt.CryptoObject(cryptoPrompt.cipher))
|
||||
}
|
||||
else if (cryptoPrompt.isDeviceCredentialOperation) {
|
||||
val keyGuardManager = ContextCompat.getSystemService(retrieveContext(), KeyguardManager::class.java)
|
||||
retrieveContext().startActivityForResult(
|
||||
keyGuardManager?.createConfirmDeviceCredentialIntent(promptTitle, promptDescription),
|
||||
REQUEST_DEVICE_CREDENTIAL)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onActivityResult(requestCode: Int, resultCode: Int) {
|
||||
if (requestCode == REQUEST_DEVICE_CREDENTIAL) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
advancedUnlockCallback?.onAuthenticationSucceeded()
|
||||
} else {
|
||||
advancedUnlockCallback?.onAuthenticationFailed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeBiometricPrompt() {
|
||||
biometricPrompt?.cancelAuthentication()
|
||||
}
|
||||
|
||||
interface AdvancedUnlockErrorCallback {
|
||||
fun onInvalidKeyException(e: Exception)
|
||||
fun onGenericException(e: Exception)
|
||||
}
|
||||
|
||||
interface AdvancedUnlockCallback : AdvancedUnlockErrorCallback {
|
||||
fun onAuthenticationSucceeded()
|
||||
fun onAuthenticationFailed()
|
||||
fun onAuthenticationError(errorCode: Int, errString: CharSequence)
|
||||
fun handleEncryptedResult(encryptedValue: String, ivSpec: String)
|
||||
fun handleDecryptedResult(decryptedValue: String)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = AdvancedUnlockManager::class.java.name
|
||||
|
||||
private const val ADVANCED_UNLOCK_KEYSTORE = "AndroidKeyStore"
|
||||
private const val ADVANCED_UNLOCK_KEYSTORE_KEY = "com.kunzisoft.keepass.biometric.key"
|
||||
private const val ADVANCED_UNLOCK_KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
|
||||
private const val ADVANCED_UNLOCK_BLOCKS_MODES = KeyProperties.BLOCK_MODE_CBC
|
||||
private const val ADVANCED_UNLOCK_ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
|
||||
|
||||
private const val REQUEST_DEVICE_CREDENTIAL = 556
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
fun canAuthenticate(context: Context): Int {
|
||||
return try {
|
||||
BiometricManager.from(context).canAuthenticate(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& PreferencesUtil.isDeviceCredentialUnlockEnable(context)) {
|
||||
BIOMETRIC_STRONG or DEVICE_CREDENTIAL
|
||||
} else {
|
||||
BIOMETRIC_STRONG
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to authenticate with strong biometric.", e)
|
||||
try {
|
||||
BiometricManager.from(context).canAuthenticate(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& PreferencesUtil.isDeviceCredentialUnlockEnable(context)) {
|
||||
BIOMETRIC_WEAK or DEVICE_CREDENTIAL
|
||||
} else {
|
||||
BIOMETRIC_WEAK
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to authenticate with weak biometric.", e)
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
fun isDeviceSecure(context: Context): Boolean {
|
||||
val keyguardManager = ContextCompat.getSystemService(context, KeyguardManager::class.java)
|
||||
return keyguardManager?.isDeviceSecure ?: false
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
fun biometricUnlockSupported(context: Context): Boolean {
|
||||
val biometricCanAuthenticate = try {
|
||||
BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to authenticate with strong biometric.", e)
|
||||
try {
|
||||
BiometricManager.from(context).canAuthenticate(BIOMETRIC_WEAK)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to authenticate with weak biometric.", e)
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
|
||||
}
|
||||
}
|
||||
return (biometricCanAuthenticate == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_STATUS_UNKNOWN
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED
|
||||
)
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
fun deviceCredentialUnlockSupported(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val biometricCanAuthenticate = BiometricManager.from(context).canAuthenticate(DEVICE_CREDENTIAL)
|
||||
return (biometricCanAuthenticate == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_STATUS_UNKNOWN
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED
|
||||
)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
ContextCompat.getSystemService(context, KeyguardManager::class.java)?.apply {
|
||||
return isDeviceSecure
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove entry key in keystore
|
||||
*/
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
fun deleteEntryKeyInKeystoreForBiometric(fragmentActivity: FragmentActivity,
|
||||
advancedCallback: AdvancedUnlockErrorCallback) {
|
||||
AdvancedUnlockManager{ fragmentActivity }.apply {
|
||||
advancedUnlockCallback = object : AdvancedUnlockCallback {
|
||||
override fun onAuthenticationSucceeded() {}
|
||||
|
||||
override fun onAuthenticationFailed() {}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {}
|
||||
|
||||
override fun handleEncryptedResult(encryptedValue: String, ivSpec: String) {}
|
||||
|
||||
override fun handleDecryptedResult(decryptedValue: String) {}
|
||||
|
||||
override fun onInvalidKeyException(e: Exception) {
|
||||
advancedCallback.onInvalidKeyException(e)
|
||||
}
|
||||
|
||||
override fun onGenericException(e: Exception) {
|
||||
advancedCallback.onGenericException(e)
|
||||
}
|
||||
}
|
||||
deleteKeystoreKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.View
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.view.AdvancedUnlockInfoView
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
class AdvancedUnlockedManager(var context: FragmentActivity,
|
||||
var databaseFileUri: Uri,
|
||||
private var advancedUnlockInfoView: AdvancedUnlockInfoView?,
|
||||
private var checkboxPasswordView: CompoundButton?,
|
||||
private var onCheckedPasswordChangeListener: CompoundButton.OnCheckedChangeListener? = null,
|
||||
var passwordView: TextView?,
|
||||
private var loadDatabaseAfterRegisterCredentials: (encryptedPassword: String?, ivSpec: String?) -> Unit,
|
||||
private var loadDatabaseAfterRetrieveCredentials: (decryptedPassword: String?) -> Unit)
|
||||
: BiometricUnlockDatabaseHelper.BiometricUnlockCallback {
|
||||
|
||||
private var biometricUnlockDatabaseHelper: BiometricUnlockDatabaseHelper? = null
|
||||
private var biometricMode: Mode = Mode.BIOMETRIC_UNAVAILABLE
|
||||
|
||||
// Only to fix multiple fingerprint menu #332
|
||||
private var mAllowAdvancedUnlockMenu = false
|
||||
private var mAddBiometricMenuInProgress = false
|
||||
|
||||
/**
|
||||
* Manage setting to auto open biometric prompt
|
||||
*/
|
||||
private var biometricPromptAutoOpenPreference = PreferencesUtil.isBiometricPromptAutoOpenEnable(context)
|
||||
var isBiometricPromptAutoOpenEnable: Boolean = false
|
||||
get() {
|
||||
return field && biometricPromptAutoOpenPreference
|
||||
}
|
||||
|
||||
// Variable to check if the prompt can be open (if the right activity is currently shown)
|
||||
// checkBiometricAvailability() allows open biometric prompt and onDestroy() removes the authorization
|
||||
private var allowOpenBiometricPrompt = false
|
||||
|
||||
private var cipherDatabaseAction = CipherDatabaseAction.getInstance(context.applicationContext)
|
||||
|
||||
init {
|
||||
// Add a check listener to change fingerprint mode
|
||||
checkboxPasswordView?.setOnCheckedChangeListener { compoundButton, checked ->
|
||||
checkBiometricAvailability()
|
||||
// Add old listener to enable the button, only be call here because of onCheckedChange bug
|
||||
onCheckedPasswordChangeListener?.onCheckedChanged(compoundButton, checked)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check biometric availability and change the current mode depending of device's state
|
||||
*/
|
||||
fun checkBiometricAvailability() {
|
||||
|
||||
// biometric not supported (by API level or hardware) so keep option hidden
|
||||
// or manually disable
|
||||
val biometricCanAuthenticate = BiometricUnlockDatabaseHelper.canAuthenticate(context)
|
||||
allowOpenBiometricPrompt = true
|
||||
|
||||
if (!PreferencesUtil.isBiometricUnlockEnable(context)
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE) {
|
||||
toggleMode(Mode.BIOMETRIC_UNAVAILABLE)
|
||||
} else if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED){
|
||||
toggleMode(Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED)
|
||||
} else {
|
||||
// biometric is available but not configured, show icon but in disabled state with some information
|
||||
if (biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) {
|
||||
toggleMode(Mode.BIOMETRIC_NOT_CONFIGURED)
|
||||
} else {
|
||||
// Check if fingerprint well init (be called the first time the fingerprint is configured
|
||||
// and the activity still active)
|
||||
if (biometricUnlockDatabaseHelper?.isKeyManagerInitialized != true) {
|
||||
biometricUnlockDatabaseHelper = BiometricUnlockDatabaseHelper(context)
|
||||
// callback for fingerprint findings
|
||||
biometricUnlockDatabaseHelper?.biometricUnlockCallback = this
|
||||
biometricUnlockDatabaseHelper?.authenticationCallback = biometricAuthenticationCallback
|
||||
}
|
||||
// Recheck to change the mode
|
||||
if (biometricUnlockDatabaseHelper?.isKeyManagerInitialized != true) {
|
||||
toggleMode(Mode.KEY_MANAGER_UNAVAILABLE)
|
||||
} else {
|
||||
if (checkboxPasswordView?.isChecked == true) {
|
||||
// listen for encryption
|
||||
toggleMode(Mode.STORE_CREDENTIAL)
|
||||
} else {
|
||||
cipherDatabaseAction.containsCipherDatabase(databaseFileUri) { containsCipher ->
|
||||
// biometric available but no stored password found yet for this DB so show info don't listen
|
||||
toggleMode(if (containsCipher) {
|
||||
// listen for decryption
|
||||
Mode.EXTRACT_CREDENTIAL
|
||||
} else {
|
||||
// wait for typing
|
||||
Mode.WAIT_CREDENTIAL
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleMode(newBiometricMode: Mode) {
|
||||
if (newBiometricMode != biometricMode) {
|
||||
biometricMode = newBiometricMode
|
||||
initBiometricMode()
|
||||
}
|
||||
}
|
||||
|
||||
private val biometricAuthenticationCallback = object : BiometricPrompt.AuthenticationCallback () {
|
||||
|
||||
override fun onAuthenticationError(
|
||||
errorCode: Int,
|
||||
errString: CharSequence) {
|
||||
context.runOnUiThread {
|
||||
Log.e(TAG, "Biometric authentication error. Code : $errorCode Error : $errString")
|
||||
setAdvancedUnlockedMessageView(errString.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
context.runOnUiThread {
|
||||
Log.e(TAG, "Biometric authentication failed, biometric not recognized")
|
||||
setAdvancedUnlockedMessageView(R.string.biometric_not_recognized)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
context.runOnUiThread {
|
||||
when (biometricMode) {
|
||||
Mode.BIOMETRIC_UNAVAILABLE -> {
|
||||
}
|
||||
Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> {
|
||||
}
|
||||
Mode.BIOMETRIC_NOT_CONFIGURED -> {
|
||||
}
|
||||
Mode.KEY_MANAGER_UNAVAILABLE -> {
|
||||
}
|
||||
Mode.WAIT_CREDENTIAL -> {
|
||||
}
|
||||
Mode.STORE_CREDENTIAL -> {
|
||||
// newly store the entered password in encrypted way
|
||||
biometricUnlockDatabaseHelper?.encryptData(passwordView?.text.toString())
|
||||
}
|
||||
Mode.EXTRACT_CREDENTIAL -> {
|
||||
// retrieve the encrypted value from preferences
|
||||
cipherDatabaseAction.getCipherDatabase(databaseFileUri) {
|
||||
it?.encryptedValue?.let { value ->
|
||||
biometricUnlockDatabaseHelper?.decryptData(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initNotAvailable() {
|
||||
showFingerPrintViews(false)
|
||||
|
||||
advancedUnlockInfoView?.setIconViewClickListener(false, null)
|
||||
}
|
||||
|
||||
private fun initSecurityUpdateRequired() {
|
||||
showFingerPrintViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.biometric_security_update_required)
|
||||
|
||||
advancedUnlockInfoView?.setIconViewClickListener(false) {
|
||||
context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
|
||||
}
|
||||
}
|
||||
|
||||
private fun initNotConfigured() {
|
||||
showFingerPrintViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.configure_biometric)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
advancedUnlockInfoView?.setIconViewClickListener(false) {
|
||||
context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
|
||||
}
|
||||
}
|
||||
|
||||
private fun initKeyManagerNotAvailable() {
|
||||
showFingerPrintViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.keystore_not_accessible)
|
||||
|
||||
advancedUnlockInfoView?.setIconViewClickListener(false) {
|
||||
context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
|
||||
}
|
||||
}
|
||||
|
||||
private fun initWaitData() {
|
||||
showFingerPrintViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.no_credentials_stored)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
advancedUnlockInfoView?.setIconViewClickListener(false) {
|
||||
biometricAuthenticationCallback.onAuthenticationError(BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
|
||||
context.getString(R.string.credential_before_click_biometric_button))
|
||||
}
|
||||
}
|
||||
|
||||
private fun openBiometricPrompt(biometricPrompt: BiometricPrompt?,
|
||||
cryptoObject: BiometricPrompt.CryptoObject?,
|
||||
promptInfo: BiometricPrompt.PromptInfo) {
|
||||
context.runOnUiThread {
|
||||
if (allowOpenBiometricPrompt) {
|
||||
if (biometricPrompt != null) {
|
||||
if (cryptoObject != null) {
|
||||
biometricPrompt.authenticate(promptInfo, cryptoObject)
|
||||
} else {
|
||||
setAdvancedUnlockedTitleView(R.string.crypto_object_not_initialized)
|
||||
}
|
||||
} else {
|
||||
setAdvancedUnlockedTitleView(R.string.biometric_prompt_not_initialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initEncryptData() {
|
||||
showFingerPrintViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.open_biometric_prompt_store_credential)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
biometricUnlockDatabaseHelper?.initEncryptData { biometricPrompt, cryptoObject, promptInfo ->
|
||||
// Set listener to open the biometric dialog and save credential
|
||||
advancedUnlockInfoView?.setIconViewClickListener { _ ->
|
||||
openBiometricPrompt(biometricPrompt, cryptoObject, promptInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initDecryptData() {
|
||||
showFingerPrintViews(true)
|
||||
setAdvancedUnlockedTitleView(R.string.open_biometric_prompt_unlock_database)
|
||||
setAdvancedUnlockedMessageView("")
|
||||
|
||||
if (biometricUnlockDatabaseHelper != null) {
|
||||
cipherDatabaseAction.getCipherDatabase(databaseFileUri) {
|
||||
|
||||
it?.specParameters?.let { specs ->
|
||||
biometricUnlockDatabaseHelper?.initDecryptData(specs) { biometricPrompt, cryptoObject, promptInfo ->
|
||||
|
||||
// Set listener to open the biometric dialog and check credential
|
||||
advancedUnlockInfoView?.setIconViewClickListener { _ ->
|
||||
openBiometricPrompt(biometricPrompt, cryptoObject, promptInfo)
|
||||
}
|
||||
|
||||
// Auto open the biometric prompt
|
||||
if (isBiometricPromptAutoOpenEnable) {
|
||||
isBiometricPromptAutoOpenEnable = false
|
||||
openBiometricPrompt(biometricPrompt, cryptoObject, promptInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun initBiometricMode() {
|
||||
mAllowAdvancedUnlockMenu = false
|
||||
when (biometricMode) {
|
||||
Mode.BIOMETRIC_UNAVAILABLE -> initNotAvailable()
|
||||
Mode.BIOMETRIC_SECURITY_UPDATE_REQUIRED -> initSecurityUpdateRequired()
|
||||
Mode.BIOMETRIC_NOT_CONFIGURED -> initNotConfigured()
|
||||
Mode.KEY_MANAGER_UNAVAILABLE -> initKeyManagerNotAvailable()
|
||||
Mode.WAIT_CREDENTIAL -> initWaitData()
|
||||
Mode.STORE_CREDENTIAL -> initEncryptData()
|
||||
Mode.EXTRACT_CREDENTIAL -> initDecryptData()
|
||||
}
|
||||
|
||||
invalidateBiometricMenu()
|
||||
}
|
||||
|
||||
private fun invalidateBiometricMenu() {
|
||||
// Show fingerprint key deletion
|
||||
if (!mAddBiometricMenuInProgress) {
|
||||
mAddBiometricMenuInProgress = true
|
||||
cipherDatabaseAction.containsCipherDatabase(databaseFileUri) { containsCipher ->
|
||||
mAllowAdvancedUnlockMenu = containsCipher
|
||||
&& (biometricMode != Mode.BIOMETRIC_UNAVAILABLE
|
||||
&& biometricMode != Mode.KEY_MANAGER_UNAVAILABLE)
|
||||
mAddBiometricMenuInProgress = false
|
||||
context.invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
// Close the biometric prompt
|
||||
allowOpenBiometricPrompt = false
|
||||
biometricUnlockDatabaseHelper?.closeBiometricPrompt()
|
||||
// Restore the checked listener
|
||||
checkboxPasswordView?.setOnCheckedChangeListener(onCheckedPasswordChangeListener)
|
||||
}
|
||||
|
||||
fun inflateOptionsMenu(menuInflater: MenuInflater, menu: Menu) {
|
||||
if (mAllowAdvancedUnlockMenu)
|
||||
menuInflater.inflate(R.menu.advanced_unlock, menu)
|
||||
}
|
||||
|
||||
fun deleteEntryKey() {
|
||||
allowOpenBiometricPrompt = false
|
||||
advancedUnlockInfoView?.setIconViewClickListener(false, null)
|
||||
biometricUnlockDatabaseHelper?.closeBiometricPrompt()
|
||||
biometricUnlockDatabaseHelper?.deleteEntryKey()
|
||||
cipherDatabaseAction.deleteByDatabaseUri(databaseFileUri) {
|
||||
checkBiometricAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleEncryptedResult(encryptedValue: String, ivSpec: String) {
|
||||
loadDatabaseAfterRegisterCredentials.invoke(encryptedValue, ivSpec)
|
||||
}
|
||||
|
||||
override fun handleDecryptedResult(decryptedValue: String) {
|
||||
// Load database directly with password retrieve
|
||||
loadDatabaseAfterRetrieveCredentials.invoke(decryptedValue)
|
||||
}
|
||||
|
||||
override fun onInvalidKeyException(e: Exception) {
|
||||
setAdvancedUnlockedMessageView(R.string.biometric_invalid_key)
|
||||
}
|
||||
|
||||
override fun onBiometricException(e: Exception) {
|
||||
e.localizedMessage?.let {
|
||||
setAdvancedUnlockedMessageView(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFingerPrintViews(show: Boolean) {
|
||||
context.runOnUiThread {
|
||||
advancedUnlockInfoView?.visibility = if (show) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAdvancedUnlockedTitleView(textId: Int) {
|
||||
context.runOnUiThread {
|
||||
advancedUnlockInfoView?.setTitle(textId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAdvancedUnlockedMessageView(textId: Int) {
|
||||
context.runOnUiThread {
|
||||
advancedUnlockInfoView?.setMessage(textId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAdvancedUnlockedMessageView(text: CharSequence) {
|
||||
context.runOnUiThread {
|
||||
advancedUnlockInfoView?.message = text
|
||||
}
|
||||
}
|
||||
|
||||
enum class Mode {
|
||||
BIOMETRIC_UNAVAILABLE,
|
||||
BIOMETRIC_SECURITY_UPDATE_REQUIRED,
|
||||
BIOMETRIC_NOT_CONFIGURED,
|
||||
KEY_MANAGER_UNAVAILABLE,
|
||||
WAIT_CREDENTIAL,
|
||||
STORE_CREDENTIAL,
|
||||
EXTRACT_CREDENTIAL
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = AdvancedUnlockedManager::class.java.name
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.biometric
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.kunzisoft.keepass.R
|
||||
import java.security.KeyStore
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.util.concurrent.Executors
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
class BiometricUnlockDatabaseHelper(private val context: FragmentActivity) {
|
||||
|
||||
private var biometricPrompt: BiometricPrompt? = null
|
||||
|
||||
private var keyStore: KeyStore? = null
|
||||
private var keyGenerator: KeyGenerator? = null
|
||||
private var cipher: Cipher? = null
|
||||
private var keyguardManager: KeyguardManager? = null
|
||||
private var cryptoObject: BiometricPrompt.CryptoObject? = null
|
||||
|
||||
private var isKeyManagerInit = false
|
||||
var authenticationCallback: BiometricPrompt.AuthenticationCallback? = null
|
||||
var biometricUnlockCallback: BiometricUnlockCallback? = null
|
||||
|
||||
private val promptInfoStoreCredential = BiometricPrompt.PromptInfo.Builder().apply {
|
||||
setTitle(context.getString(R.string.biometric_prompt_store_credential_title))
|
||||
setDescription(context.getString(R.string.biometric_prompt_store_credential_message))
|
||||
setConfirmationRequired(true)
|
||||
// TODO device credential #102 #152
|
||||
/*
|
||||
if (keyguardManager?.isDeviceSecure == true)
|
||||
setDeviceCredentialAllowed(true)
|
||||
else
|
||||
*/
|
||||
setNegativeButtonText(context.getString(android.R.string.cancel))
|
||||
}.build()
|
||||
|
||||
private val promptInfoExtractCredential = BiometricPrompt.PromptInfo.Builder().apply {
|
||||
setTitle(context.getString(R.string.biometric_prompt_extract_credential_title))
|
||||
//setDescription(context.getString(R.string.biometric_prompt_extract_credential_message))
|
||||
setConfirmationRequired(false)
|
||||
// TODO device credential #102 #152
|
||||
/*
|
||||
if (keyguardManager?.isDeviceSecure == true)
|
||||
setDeviceCredentialAllowed(true)
|
||||
else
|
||||
*/
|
||||
setNegativeButtonText(context.getString(android.R.string.cancel))
|
||||
}.build()
|
||||
|
||||
val isKeyManagerInitialized: Boolean
|
||||
get() {
|
||||
if (!isKeyManagerInit) {
|
||||
biometricUnlockCallback?.onBiometricException(Exception("Biometric not initialized"))
|
||||
}
|
||||
return isKeyManagerInit
|
||||
}
|
||||
|
||||
init {
|
||||
if (allowInitKeyStore(context)) {
|
||||
this.keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager?
|
||||
try {
|
||||
this.keyStore = KeyStore.getInstance(BIOMETRIC_KEYSTORE)
|
||||
this.keyGenerator = KeyGenerator.getInstance(BIOMETRIC_KEY_ALGORITHM, BIOMETRIC_KEYSTORE)
|
||||
this.cipher = Cipher.getInstance(
|
||||
BIOMETRIC_KEY_ALGORITHM + "/"
|
||||
+ BIOMETRIC_BLOCKS_MODES + "/"
|
||||
+ BIOMETRIC_ENCRYPTION_PADDING)
|
||||
this.cryptoObject = BiometricPrompt.CryptoObject(cipher!!)
|
||||
isKeyManagerInit = (keyStore != null
|
||||
&& keyGenerator != null
|
||||
&& cipher != null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initialize the keystore", e)
|
||||
isKeyManagerInit = false
|
||||
biometricUnlockCallback?.onBiometricException(e)
|
||||
}
|
||||
} else {
|
||||
// really not much to do when no fingerprint support found
|
||||
isKeyManagerInit = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSecretKey(): SecretKey? {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
// Create new key if needed
|
||||
keyStore?.let { keyStore ->
|
||||
keyStore.load(null)
|
||||
|
||||
try {
|
||||
if (!keyStore.containsAlias(BIOMETRIC_KEYSTORE_KEY)) {
|
||||
// Set the alias of the entry in Android KeyStore where the key will appear
|
||||
// and the constrains (purposes) in the constructor of the Builder
|
||||
keyGenerator?.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
BIOMETRIC_KEYSTORE_KEY,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
|
||||
// Require the user to authenticate with a fingerprint to authorize every use
|
||||
// of the key
|
||||
.setUserAuthenticationRequired(true)
|
||||
.build())
|
||||
keyGenerator?.generateKey()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to create a key in keystore", e)
|
||||
biometricUnlockCallback?.onBiometricException(e)
|
||||
}
|
||||
|
||||
return keyStore.getKey(BIOMETRIC_KEYSTORE_KEY, null) as SecretKey?
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to retrieve the key in keystore", e)
|
||||
biometricUnlockCallback?.onBiometricException(e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun initEncryptData(actionIfCypherInit
|
||||
: (biometricPrompt: BiometricPrompt?,
|
||||
cryptoObject: BiometricPrompt.CryptoObject?,
|
||||
promptInfo: BiometricPrompt.PromptInfo) -> Unit) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
getSecretKey()?.let { secretKey ->
|
||||
cipher?.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
|
||||
initBiometricPrompt()
|
||||
actionIfCypherInit.invoke(biometricPrompt, cryptoObject, promptInfoStoreCredential)
|
||||
}
|
||||
|
||||
} catch (unrecoverableKeyException: UnrecoverableKeyException) {
|
||||
Log.e(TAG, "Unable to initialize encrypt data", unrecoverableKeyException)
|
||||
biometricUnlockCallback?.onInvalidKeyException(unrecoverableKeyException)
|
||||
} catch (invalidKeyException: KeyPermanentlyInvalidatedException) {
|
||||
Log.e(TAG, "Unable to initialize encrypt data", invalidKeyException)
|
||||
biometricUnlockCallback?.onInvalidKeyException(invalidKeyException)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initialize encrypt data", e)
|
||||
biometricUnlockCallback?.onBiometricException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun encryptData(value: String) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
val encrypted = cipher?.doFinal(value.toByteArray())
|
||||
val encryptedBase64 = Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
|
||||
// passes updated iv spec on to callback so this can be stored for decryption
|
||||
cipher?.parameters?.getParameterSpec(IvParameterSpec::class.java)?.let{ spec ->
|
||||
val ivSpecValue = Base64.encodeToString(spec.iv, Base64.NO_WRAP)
|
||||
biometricUnlockCallback?.handleEncryptedResult(encryptedBase64, ivSpecValue)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to encrypt data", e)
|
||||
biometricUnlockCallback?.onBiometricException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun initDecryptData(ivSpecValue: String, actionIfCypherInit
|
||||
: (biometricPrompt: BiometricPrompt?,
|
||||
cryptoObject: BiometricPrompt.CryptoObject?,
|
||||
promptInfo: BiometricPrompt.PromptInfo) -> Unit) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// important to restore spec here that was used for decryption
|
||||
val iv = Base64.decode(ivSpecValue, Base64.NO_WRAP)
|
||||
val spec = IvParameterSpec(iv)
|
||||
|
||||
getSecretKey()?.let { secretKey ->
|
||||
cipher?.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||
|
||||
initBiometricPrompt()
|
||||
actionIfCypherInit.invoke(biometricPrompt, cryptoObject, promptInfoExtractCredential)
|
||||
}
|
||||
|
||||
} catch (unrecoverableKeyException: UnrecoverableKeyException) {
|
||||
Log.e(TAG, "Unable to initialize decrypt data", unrecoverableKeyException)
|
||||
deleteEntryKey()
|
||||
} catch (invalidKeyException: KeyPermanentlyInvalidatedException) {
|
||||
Log.e(TAG, "Unable to initialize decrypt data", invalidKeyException)
|
||||
biometricUnlockCallback?.onInvalidKeyException(invalidKeyException)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initialize decrypt data", e)
|
||||
biometricUnlockCallback?.onBiometricException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptData(encryptedValue: String) {
|
||||
if (!isKeyManagerInitialized) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// actual decryption here
|
||||
val encrypted = Base64.decode(encryptedValue, Base64.NO_WRAP)
|
||||
cipher?.doFinal(encrypted)?.let { decrypted ->
|
||||
biometricUnlockCallback?.handleDecryptedResult(String(decrypted))
|
||||
}
|
||||
} catch (badPaddingException: BadPaddingException) {
|
||||
Log.e(TAG, "Unable to decrypt data", badPaddingException)
|
||||
biometricUnlockCallback?.onInvalidKeyException(badPaddingException)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to decrypt data", e)
|
||||
biometricUnlockCallback?.onBiometricException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteEntryKey() {
|
||||
try {
|
||||
keyStore?.load(null)
|
||||
keyStore?.deleteEntry(BIOMETRIC_KEYSTORE_KEY)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to delete entry key in keystore", e)
|
||||
biometricUnlockCallback?.onBiometricException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun initBiometricPrompt() {
|
||||
if (biometricPrompt == null) {
|
||||
authenticationCallback?.let {
|
||||
biometricPrompt = BiometricPrompt(context, Executors.newSingleThreadExecutor(), it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeBiometricPrompt() {
|
||||
biometricPrompt?.cancelAuthentication()
|
||||
}
|
||||
|
||||
interface BiometricUnlockErrorCallback {
|
||||
fun onInvalidKeyException(e: Exception)
|
||||
fun onBiometricException(e: Exception)
|
||||
}
|
||||
|
||||
interface BiometricUnlockCallback : BiometricUnlockErrorCallback {
|
||||
fun handleEncryptedResult(encryptedValue: String, ivSpec: String)
|
||||
fun handleDecryptedResult(decryptedValue: String)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = BiometricUnlockDatabaseHelper::class.java.name
|
||||
|
||||
private const val BIOMETRIC_KEYSTORE = "AndroidKeyStore"
|
||||
private const val BIOMETRIC_KEYSTORE_KEY = "com.kunzisoft.keepass.biometric.key"
|
||||
private const val BIOMETRIC_KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
|
||||
private const val BIOMETRIC_BLOCKS_MODES = KeyProperties.BLOCK_MODE_CBC
|
||||
private const val BIOMETRIC_ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
|
||||
|
||||
fun canAuthenticate(context: Context): Int {
|
||||
return try {
|
||||
BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to authenticate with strong biometric.", e)
|
||||
try {
|
||||
BiometricManager.from(context).canAuthenticate(BIOMETRIC_WEAK)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to authenticate with weak biometric.", e)
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun allowInitKeyStore(context: Context): Boolean {
|
||||
val biometricCanAuthenticate = canAuthenticate(context)
|
||||
return ( biometricCanAuthenticate == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_STATUS_UNKNOWN
|
||||
)
|
||||
}
|
||||
|
||||
fun unlockSupported(context: Context): Boolean {
|
||||
val biometricCanAuthenticate = canAuthenticate(context)
|
||||
return ( biometricCanAuthenticate == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_STATUS_UNKNOWN
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
|
||||
|| biometricCanAuthenticate == BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove entry key in keystore
|
||||
*/
|
||||
fun deleteEntryKeyInKeystoreForBiometric(context: FragmentActivity,
|
||||
biometricCallback: BiometricUnlockErrorCallback) {
|
||||
BiometricUnlockDatabaseHelper(context).apply {
|
||||
biometricUnlockCallback = object : BiometricUnlockCallback {
|
||||
|
||||
override fun handleEncryptedResult(encryptedValue: String, ivSpec: String) {}
|
||||
|
||||
override fun handleDecryptedResult(decryptedValue: String) {}
|
||||
|
||||
override fun onInvalidKeyException(e: Exception) {
|
||||
biometricCallback.onInvalidKeyException(e)
|
||||
}
|
||||
|
||||
override fun onBiometricException(e: Exception) {
|
||||
biometricCallback.onBiometricException(e)
|
||||
}
|
||||
}
|
||||
deleteEntryKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,11 +29,12 @@ object StreamCipherFactory {
|
||||
|
||||
private val SALSA_IV = byteArrayOf(0xE8.toByte(), 0x30, 0x09, 0x4B, 0x97.toByte(), 0x20, 0x5D, 0x2A)
|
||||
|
||||
fun getInstance(alg: CrsAlgorithm?, key: ByteArray): StreamCipher? {
|
||||
@Throws(Exception::class)
|
||||
fun getInstance(alg: CrsAlgorithm?, key: ByteArray): StreamCipher {
|
||||
return when {
|
||||
alg === CrsAlgorithm.Salsa20 -> getSalsa20(key)
|
||||
alg === CrsAlgorithm.ChaCha20 -> getChaCha20(key)
|
||||
else -> null
|
||||
else -> throw Exception("Invalid random cipher")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
package com.kunzisoft.keepass.crypto.keyDerivation
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.annotation.StringRes
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.stream.bytes16ToUuid
|
||||
import com.kunzisoft.keepass.utils.UnsignedInt
|
||||
@@ -27,7 +28,11 @@ import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
|
||||
class Argon2Kdf internal constructor() : KdfEngine() {
|
||||
class Argon2Kdf(private val type: Type) : KdfEngine() {
|
||||
|
||||
init {
|
||||
uuid = type.CIPHER_UUID
|
||||
}
|
||||
|
||||
override val defaultParameters: KdfParameters
|
||||
get() {
|
||||
@@ -45,12 +50,8 @@ class Argon2Kdf internal constructor() : KdfEngine() {
|
||||
override val defaultKeyRounds: Long
|
||||
get() = DEFAULT_ITERATIONS
|
||||
|
||||
init {
|
||||
uuid = CIPHER_UUID
|
||||
}
|
||||
|
||||
override fun getName(resources: Resources): String {
|
||||
return resources.getString(R.string.kdf_Argon2)
|
||||
return resources.getString(type.nameId)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
@@ -72,7 +73,9 @@ class Argon2Kdf internal constructor() : KdfEngine() {
|
||||
val secretKey = kdfParameters.getByteArray(PARAM_SECRET_KEY)
|
||||
val assocData = kdfParameters.getByteArray(PARAM_ASSOC_DATA)
|
||||
|
||||
return Argon2Native.transformKey(masterKey,
|
||||
return Argon2Native.transformKey(
|
||||
type,
|
||||
masterKey,
|
||||
salt,
|
||||
parallelism,
|
||||
memory,
|
||||
@@ -141,9 +144,8 @@ class Argon2Kdf internal constructor() : KdfEngine() {
|
||||
override val maxParallelism: Long
|
||||
get() = MAX_PARALLELISM
|
||||
|
||||
companion object {
|
||||
|
||||
val CIPHER_UUID: UUID = bytes16ToUuid(
|
||||
enum class Type(val CIPHER_UUID: UUID, @StringRes val nameId: Int) {
|
||||
ARGON2_D(bytes16ToUuid(
|
||||
byteArrayOf(0xEF.toByte(),
|
||||
0x63.toByte(),
|
||||
0x6D.toByte(),
|
||||
@@ -159,7 +161,27 @@ class Argon2Kdf internal constructor() : KdfEngine() {
|
||||
0x03.toByte(),
|
||||
0xE3.toByte(),
|
||||
0x0A.toByte(),
|
||||
0x0C.toByte()))
|
||||
0x0C.toByte())), R.string.kdf_Argon2d),
|
||||
ARGON2_ID(bytes16ToUuid(
|
||||
byteArrayOf(0x9E.toByte(),
|
||||
0x29.toByte(),
|
||||
0x8B.toByte(),
|
||||
0x19.toByte(),
|
||||
0x56.toByte(),
|
||||
0xDB.toByte(),
|
||||
0x47.toByte(),
|
||||
0x73.toByte(),
|
||||
0xB2.toByte(),
|
||||
0x3D.toByte(),
|
||||
0xFC.toByte(),
|
||||
0x3E.toByte(),
|
||||
0xC6.toByte(),
|
||||
0xF0.toByte(),
|
||||
0xA1.toByte(),
|
||||
0xE6.toByte())), R.string.kdf_Argon2id);
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PARAM_SALT = "S" // byte[]
|
||||
private const val PARAM_PARALLELISM = "P" // UInt32
|
||||
|
||||
@@ -26,12 +26,29 @@ import java.io.IOException;
|
||||
|
||||
public class Argon2Native {
|
||||
|
||||
public static byte[] transformKey(byte[] password, byte[] salt, UnsignedInt parallelism,
|
||||
enum CType {
|
||||
ARGON2_D(0),
|
||||
ARGON2_I(1),
|
||||
ARGON2_ID(2);
|
||||
|
||||
int cValue = 0;
|
||||
|
||||
CType(int i) {
|
||||
cValue = i;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] transformKey(Argon2Kdf.Type type, byte[] password, byte[] salt, UnsignedInt parallelism,
|
||||
UnsignedInt memory, UnsignedInt iterations, byte[] secretKey,
|
||||
byte[] associatedData, UnsignedInt version) throws IOException {
|
||||
NativeLib.INSTANCE.init();
|
||||
|
||||
CType cType = CType.ARGON2_D;
|
||||
if (type.equals(Argon2Kdf.Type.ARGON2_ID))
|
||||
cType = CType.ARGON2_ID;
|
||||
|
||||
return nTransformMasterKey(
|
||||
cType.cValue,
|
||||
password,
|
||||
salt,
|
||||
parallelism.toKotlinInt(),
|
||||
@@ -42,7 +59,7 @@ public class Argon2Native {
|
||||
version.toKotlinInt());
|
||||
}
|
||||
|
||||
private static native byte[] nTransformMasterKey(byte[] password, byte[] salt, int parallelism,
|
||||
private static native byte[] nTransformMasterKey(int type, byte[] password, byte[] salt, int parallelism,
|
||||
int memory, int iterations, byte[] secretKey,
|
||||
byte[] associatedData, int version) throws IOException;
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@ package com.kunzisoft.keepass.crypto.keyDerivation
|
||||
|
||||
object KdfFactory {
|
||||
var aesKdf = AesKdf()
|
||||
var argon2Kdf = Argon2Kdf()
|
||||
var argon2dKdf = Argon2Kdf(Argon2Kdf.Type.ARGON2_D)
|
||||
var argon2idKdf = Argon2Kdf(Argon2Kdf.Type.ARGON2_ID)
|
||||
}
|
||||
|
||||
@@ -24,39 +24,26 @@ import android.net.Uri
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
|
||||
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
|
||||
open class AssignPasswordInDatabaseRunnable (
|
||||
context: Context,
|
||||
database: Database,
|
||||
protected val mDatabaseUri: Uri,
|
||||
withMasterPassword: Boolean,
|
||||
masterPassword: String?,
|
||||
withKeyFile: Boolean,
|
||||
keyFile: Uri?)
|
||||
protected val mMainCredential: MainCredential)
|
||||
: SaveDatabaseRunnable(context, database, true) {
|
||||
|
||||
private var mMasterPassword: String? = null
|
||||
protected var mKeyFileUri: Uri? = null
|
||||
|
||||
private var mBackupKey: ByteArray? = null
|
||||
|
||||
init {
|
||||
if (withMasterPassword)
|
||||
this.mMasterPassword = masterPassword
|
||||
if (withKeyFile)
|
||||
this.mKeyFileUri = keyFile
|
||||
}
|
||||
|
||||
override fun onStartRun() {
|
||||
// Set key
|
||||
try {
|
||||
// TODO move master key methods
|
||||
mBackupKey = ByteArray(database.masterKey.size)
|
||||
System.arraycopy(database.masterKey, 0, mBackupKey!!, 0, mBackupKey!!.size)
|
||||
|
||||
val uriInputStream = UriUtil.getUriInputStream(context.contentResolver, mKeyFileUri)
|
||||
database.retrieveMasterKey(mMasterPassword, uriInputStream)
|
||||
val uriInputStream = UriUtil.getUriInputStream(context.contentResolver, mMainCredential.keyFileUri)
|
||||
database.retrieveMasterKey(mMainCredential.masterPassword, uriInputStream)
|
||||
} catch (e: Exception) {
|
||||
erase(mBackupKey)
|
||||
setError(e)
|
||||
|
||||
@@ -24,21 +24,18 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import com.kunzisoft.keepass.utils.closeDatabase
|
||||
|
||||
class CreateDatabaseRunnable(context: Context,
|
||||
private val mDatabase: Database,
|
||||
databaseUri: Uri,
|
||||
private val databaseName: String,
|
||||
private val rootName: String,
|
||||
withMasterPassword: Boolean,
|
||||
masterPassword: String?,
|
||||
withKeyFile: Boolean,
|
||||
keyFile: Uri?,
|
||||
mainCredential: MainCredential,
|
||||
private val createDatabaseResult: ((Result) -> Unit)?)
|
||||
: AssignPasswordInDatabaseRunnable(context, mDatabase, databaseUri, withMasterPassword, masterPassword, withKeyFile, keyFile) {
|
||||
: AssignPasswordInDatabaseRunnable(context, mDatabase, databaseUri, mainCredential) {
|
||||
|
||||
override fun onStartRun() {
|
||||
try {
|
||||
@@ -47,7 +44,7 @@ class CreateDatabaseRunnable(context: Context,
|
||||
createData(mDatabaseUri, databaseName, rootName)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
mDatabase.closeAndClear(UriUtil.getBinaryDir(context))
|
||||
mDatabase.clearAndClose(UriUtil.getBinaryDir(context))
|
||||
setError(e)
|
||||
}
|
||||
|
||||
@@ -62,7 +59,7 @@ class CreateDatabaseRunnable(context: Context,
|
||||
if (PreferencesUtil.rememberDatabaseLocations(context)) {
|
||||
FileDatabaseHistoryAction.getInstance(context.applicationContext)
|
||||
.addOrUpdateDatabaseUri(mDatabaseUri,
|
||||
if (PreferencesUtil.rememberKeyFileLocations(context)) mKeyFileUri else null)
|
||||
if (PreferencesUtil.rememberKeyFileLocations(context)) mMainCredential.keyFileUri else null)
|
||||
}
|
||||
|
||||
// Register the current time to init the lock timer
|
||||
|
||||
@@ -25,19 +25,17 @@ import com.kunzisoft.keepass.app.database.CipherDatabaseAction
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseEntity
|
||||
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException
|
||||
import com.kunzisoft.keepass.database.exception.LoadDatabaseException
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.tasks.ActionRunnable
|
||||
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import com.kunzisoft.keepass.utils.closeDatabase
|
||||
|
||||
class LoadDatabaseRunnable(private val context: Context,
|
||||
private val mDatabase: Database,
|
||||
private val mUri: Uri,
|
||||
private val mPass: String?,
|
||||
private val mKey: Uri?,
|
||||
private val mMainCredential: MainCredential,
|
||||
private val mReadonly: Boolean,
|
||||
private val mCipherEntity: CipherDatabaseEntity?,
|
||||
private val mFixDuplicateUUID: Boolean,
|
||||
@@ -47,21 +45,20 @@ class LoadDatabaseRunnable(private val context: Context,
|
||||
|
||||
override fun onStartRun() {
|
||||
// Clear before we load
|
||||
mDatabase.closeAndClear(UriUtil.getBinaryDir(context))
|
||||
mDatabase.clearAndClose(UriUtil.getBinaryDir(context))
|
||||
}
|
||||
|
||||
override fun onActionRun() {
|
||||
try {
|
||||
mDatabase.loadData(mUri, mPass, mKey,
|
||||
mDatabase.loadData(mUri,
|
||||
mMainCredential,
|
||||
mReadonly,
|
||||
context.contentResolver,
|
||||
UriUtil.getBinaryDir(context),
|
||||
Database.LoadedKey.generateNewCipherKey(),
|
||||
mFixDuplicateUUID,
|
||||
progressTaskUpdater)
|
||||
}
|
||||
catch (e: DuplicateUuidDatabaseException) {
|
||||
setError(e)
|
||||
}
|
||||
catch (e: LoadDatabaseException) {
|
||||
setError(e)
|
||||
}
|
||||
@@ -71,7 +68,7 @@ class LoadDatabaseRunnable(private val context: Context,
|
||||
if (PreferencesUtil.rememberDatabaseLocations(context)) {
|
||||
FileDatabaseHistoryAction.getInstance(context)
|
||||
.addOrUpdateDatabaseUri(mUri,
|
||||
if (PreferencesUtil.rememberKeyFileLocations(context)) mKey else null)
|
||||
if (PreferencesUtil.rememberKeyFileLocations(context)) mMainCredential.keyFileUri else null)
|
||||
}
|
||||
|
||||
// Register the biometric
|
||||
@@ -83,7 +80,7 @@ class LoadDatabaseRunnable(private val context: Context,
|
||||
// Register the current time to init the lock timer
|
||||
PreferencesUtil.saveCurrentTime(context)
|
||||
} else {
|
||||
mDatabase.closeAndClear(UriUtil.getBinaryDir(context))
|
||||
mDatabase.clearAndClose(UriUtil.getBinaryDir(context))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.kunzisoft.keepass.activities.dialogs.DatabaseChangedDialogFragment
|
||||
import com.kunzisoft.keepass.activities.dialogs.DatabaseChangedDialogFragment.Companion.DATABASE_CHANGED_DIALOG_TAG
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseEntity
|
||||
import com.kunzisoft.keepass.crypto.keyDerivation.KdfEngine
|
||||
import com.kunzisoft.keepass.database.element.Entry
|
||||
@@ -35,34 +37,37 @@ import com.kunzisoft.keepass.database.element.node.Node
|
||||
import com.kunzisoft.keepass.database.element.node.NodeId
|
||||
import com.kunzisoft.keepass.database.element.node.Type
|
||||
import com.kunzisoft.keepass.database.element.security.EncryptionAlgorithm
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_ASSIGN_PASSWORD_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_COPY_NODES_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_GROUP_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_ENTRY_HISTORY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_NODES_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_LOAD_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_MOVE_NODES_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_REMOVE_UNLINKED_DATA_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RESTORE_ENTRY_HISTORY
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_SAVE
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_COLOR_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_COMPRESSION_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_DEFAULT_USERNAME_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_DESCRIPTION_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENCRYPTION_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_GROUP_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ITERATIONS_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_KEY_DERIVATION_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_MAX_HISTORY_ITEMS_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_MAX_HISTORY_SIZE_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_MEMORY_USAGE_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_NAME_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_PARALLELISM_TASK
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.getBundleFromListNodes
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
import com.kunzisoft.keepass.model.SnapFileDatabaseInfo
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_ASSIGN_PASSWORD_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_COPY_NODES_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_GROUP_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_ENTRY_HISTORY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_NODES_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_LOAD_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RELOAD_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_MOVE_NODES_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_REMOVE_UNLINKED_DATA_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RESTORE_ENTRY_HISTORY
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_SAVE
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_COLOR_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_COMPRESSION_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_DEFAULT_USERNAME_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_DESCRIPTION_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENCRYPTION_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_GROUP_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ITERATIONS_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_KEY_DERIVATION_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_MAX_HISTORY_ITEMS_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_MAX_HISTORY_SIZE_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_MEMORY_USAGE_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_NAME_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_PARALLELISM_TASK
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getBundleFromListNodes
|
||||
import com.kunzisoft.keepass.tasks.ActionRunnable
|
||||
import com.kunzisoft.keepass.tasks.ProgressTaskDialogFragment
|
||||
import com.kunzisoft.keepass.tasks.ProgressTaskDialogFragment.Companion.PROGRESS_TASK_DIALOG_TAG
|
||||
@@ -84,6 +89,7 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
|
||||
private var serviceConnection: ServiceConnection? = null
|
||||
|
||||
private var progressTaskDialogFragment: ProgressTaskDialogFragment? = null
|
||||
private var databaseChangedDialogFragment: DatabaseChangedDialogFragment? = null
|
||||
|
||||
private val actionTaskListener = object: DatabaseTaskNotificationService.ActionTaskListener {
|
||||
override fun onStartAction(titleId: Int?, messageId: Int?, warningId: Int?) {
|
||||
@@ -101,6 +107,28 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
|
||||
}
|
||||
}
|
||||
|
||||
private val mActionDatabaseListener = object: DatabaseChangedDialogFragment.ActionDatabaseChangedListener {
|
||||
override fun validateDatabaseChanged() {
|
||||
mBinder?.getService()?.saveDatabaseInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private var databaseInfoListener = object: DatabaseTaskNotificationService.DatabaseInfoListener {
|
||||
override fun onDatabaseInfoChanged(previousDatabaseInfo: SnapFileDatabaseInfo,
|
||||
newDatabaseInfo: SnapFileDatabaseInfo) {
|
||||
if (databaseChangedDialogFragment == null) {
|
||||
databaseChangedDialogFragment = activity.supportFragmentManager
|
||||
.findFragmentByTag(DATABASE_CHANGED_DIALOG_TAG) as DatabaseChangedDialogFragment?
|
||||
databaseChangedDialogFragment?.actionDatabaseListener = mActionDatabaseListener
|
||||
}
|
||||
if (progressTaskDialogFragment == null) {
|
||||
databaseChangedDialogFragment = DatabaseChangedDialogFragment.getInstance(previousDatabaseInfo, newDatabaseInfo)
|
||||
databaseChangedDialogFragment?.actionDatabaseListener = mActionDatabaseListener
|
||||
databaseChangedDialogFragment?.show(activity.supportFragmentManager, DATABASE_CHANGED_DIALOG_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDialog(titleId: Int? = null,
|
||||
messageId: Int? = null,
|
||||
warningId: Int? = null) {
|
||||
@@ -140,11 +168,14 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
|
||||
override fun onServiceConnected(name: ComponentName?, serviceBinder: IBinder?) {
|
||||
mBinder = (serviceBinder as DatabaseTaskNotificationService.ActionTaskBinder?)?.apply {
|
||||
addActionTaskListener(actionTaskListener)
|
||||
addDatabaseFileInfoListener(databaseInfoListener)
|
||||
getService().checkAction()
|
||||
getService().checkDatabaseInfo()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
mBinder?.removeDatabaseFileInfoListener(databaseInfoListener)
|
||||
mBinder?.removeActionTaskListener(actionTaskListener)
|
||||
mBinder = null
|
||||
}
|
||||
@@ -206,6 +237,7 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
|
||||
fun unregisterProgressTask() {
|
||||
stopDialog()
|
||||
|
||||
mBinder?.removeDatabaseFileInfoListener(databaseInfoListener)
|
||||
mBinder?.removeActionTaskListener(actionTaskListener)
|
||||
mBinder = null
|
||||
|
||||
@@ -233,30 +265,22 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
|
||||
*/
|
||||
|
||||
fun startDatabaseCreate(databaseUri: Uri,
|
||||
masterPasswordChecked: Boolean,
|
||||
masterPassword: String?,
|
||||
keyFileChecked: Boolean,
|
||||
keyFile: Uri?) {
|
||||
mainCredential: MainCredential) {
|
||||
start(Bundle().apply {
|
||||
putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, databaseUri)
|
||||
putBoolean(DatabaseTaskNotificationService.MASTER_PASSWORD_CHECKED_KEY, masterPasswordChecked)
|
||||
putString(DatabaseTaskNotificationService.MASTER_PASSWORD_KEY, masterPassword)
|
||||
putBoolean(DatabaseTaskNotificationService.KEY_FILE_CHECKED_KEY, keyFileChecked)
|
||||
putParcelable(DatabaseTaskNotificationService.KEY_FILE_URI_KEY, keyFile)
|
||||
putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential)
|
||||
}
|
||||
, ACTION_DATABASE_CREATE_TASK)
|
||||
}
|
||||
|
||||
fun startDatabaseLoad(databaseUri: Uri,
|
||||
masterPassword: String?,
|
||||
keyFile: Uri?,
|
||||
mainCredential: MainCredential,
|
||||
readOnly: Boolean,
|
||||
cipherEntity: CipherDatabaseEntity?,
|
||||
fixDuplicateUuid: Boolean) {
|
||||
start(Bundle().apply {
|
||||
putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, databaseUri)
|
||||
putString(DatabaseTaskNotificationService.MASTER_PASSWORD_KEY, masterPassword)
|
||||
putParcelable(DatabaseTaskNotificationService.KEY_FILE_URI_KEY, keyFile)
|
||||
putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential)
|
||||
putBoolean(DatabaseTaskNotificationService.READ_ONLY_KEY, readOnly)
|
||||
putParcelable(DatabaseTaskNotificationService.CIPHER_ENTITY_KEY, cipherEntity)
|
||||
putBoolean(DatabaseTaskNotificationService.FIX_DUPLICATE_UUID_KEY, fixDuplicateUuid)
|
||||
@@ -264,18 +288,19 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
|
||||
, ACTION_DATABASE_LOAD_TASK)
|
||||
}
|
||||
|
||||
fun startDatabaseReload(fixDuplicateUuid: Boolean) {
|
||||
start(Bundle().apply {
|
||||
putBoolean(DatabaseTaskNotificationService.FIX_DUPLICATE_UUID_KEY, fixDuplicateUuid)
|
||||
}
|
||||
, ACTION_DATABASE_RELOAD_TASK)
|
||||
}
|
||||
|
||||
fun startDatabaseAssignPassword(databaseUri: Uri,
|
||||
masterPasswordChecked: Boolean,
|
||||
masterPassword: String?,
|
||||
keyFileChecked: Boolean,
|
||||
keyFile: Uri?) {
|
||||
mainCredential: MainCredential) {
|
||||
|
||||
start(Bundle().apply {
|
||||
putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, databaseUri)
|
||||
putBoolean(DatabaseTaskNotificationService.MASTER_PASSWORD_CHECKED_KEY, masterPasswordChecked)
|
||||
putString(DatabaseTaskNotificationService.MASTER_PASSWORD_KEY, masterPassword)
|
||||
putBoolean(DatabaseTaskNotificationService.KEY_FILE_CHECKED_KEY, keyFileChecked)
|
||||
putParcelable(DatabaseTaskNotificationService.KEY_FILE_URI_KEY, keyFile)
|
||||
putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential)
|
||||
}
|
||||
, ACTION_DATABASE_ASSIGN_PASSWORD_TASK)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.database.action
|
||||
|
||||
import android.content.Context
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.exception.LoadDatabaseException
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.tasks.ActionRunnable
|
||||
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
|
||||
class ReloadDatabaseRunnable(private val context: Context,
|
||||
private val mDatabase: Database,
|
||||
private val progressTaskUpdater: ProgressTaskUpdater?,
|
||||
private val mLoadDatabaseResult: ((Result) -> Unit)?)
|
||||
: ActionRunnable() {
|
||||
|
||||
private var tempCipherKey: Database.LoadedKey? = null
|
||||
|
||||
override fun onStartRun() {
|
||||
tempCipherKey = mDatabase.loadedCipherKey
|
||||
// Clear before we load
|
||||
mDatabase.clear(UriUtil.getBinaryDir(context))
|
||||
}
|
||||
|
||||
override fun onActionRun() {
|
||||
try {
|
||||
mDatabase.reloadData(context.contentResolver,
|
||||
UriUtil.getBinaryDir(context),
|
||||
tempCipherKey ?: Database.LoadedKey.generateNewCipherKey(),
|
||||
progressTaskUpdater)
|
||||
} catch (e: LoadDatabaseException) {
|
||||
setError(e)
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
// Register the current time to init the lock timer
|
||||
PreferencesUtil.saveCurrentTime(context)
|
||||
} else {
|
||||
tempCipherKey = null
|
||||
mDatabase.clearAndClose(UriUtil.getBinaryDir(context))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinishRun() {
|
||||
mLoadDatabaseResult?.invoke(result)
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,12 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.database.element
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.kunzisoft.keepass.database.element.database.BinaryAttachment
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
data class Attachment(var name: String,
|
||||
var binaryAttachment: BinaryAttachment) : Parcelable {
|
||||
@@ -65,5 +68,28 @@ data class Attachment(var name: String,
|
||||
override fun newArray(size: Int): Array<Attachment?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
|
||||
fun loadBitmap(attachment: Attachment,
|
||||
binaryCipherKey: Database.LoadedKey?,
|
||||
actionOnFinish: (Bitmap?) -> Unit) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val asyncResult: Deferred<Bitmap?> = async {
|
||||
runCatching {
|
||||
binaryCipherKey?.let { binaryKey ->
|
||||
var bitmap: Bitmap?
|
||||
attachment.binaryAttachment.getUnGzipInputDataStream(binaryKey).use { bitmapInputStream ->
|
||||
bitmap = BitmapFactory.decodeStream(bitmapInputStream)
|
||||
}
|
||||
bitmap
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
actionOnFinish(asyncResult.await())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,7 @@ import com.kunzisoft.keepass.database.element.node.NodeId
|
||||
import com.kunzisoft.keepass.database.element.node.NodeIdInt
|
||||
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
|
||||
import com.kunzisoft.keepass.database.element.security.EncryptionAlgorithm
|
||||
import com.kunzisoft.keepass.database.exception.DatabaseOutputException
|
||||
import com.kunzisoft.keepass.database.exception.FileNotFoundDatabaseException
|
||||
import com.kunzisoft.keepass.database.exception.LoadDatabaseException
|
||||
import com.kunzisoft.keepass.database.exception.SignatureDatabaseException
|
||||
import com.kunzisoft.keepass.database.exception.*
|
||||
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDB
|
||||
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX
|
||||
import com.kunzisoft.keepass.database.file.input.DatabaseInputKDB
|
||||
@@ -44,12 +41,17 @@ import com.kunzisoft.keepass.database.file.output.DatabaseOutputKDBX
|
||||
import com.kunzisoft.keepass.database.search.SearchHelper
|
||||
import com.kunzisoft.keepass.database.search.SearchParameters
|
||||
import com.kunzisoft.keepass.icons.IconDrawableFactory
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
import com.kunzisoft.keepass.stream.readBytes4ToUInt
|
||||
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
|
||||
import com.kunzisoft.keepass.utils.SingletonHolder
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import java.io.*
|
||||
import java.security.Key
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
|
||||
@@ -77,6 +79,19 @@ class Database {
|
||||
var loadTimestamp: Long? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Cipher key regenerated when the database is loaded and closed
|
||||
* Can be used to temporarily store database elements
|
||||
*/
|
||||
var loadedCipherKey: LoadedKey?
|
||||
private set(value) {
|
||||
mDatabaseKDB?.loadedCipherKey = value
|
||||
mDatabaseKDBX?.loadedCipherKey = value
|
||||
}
|
||||
get() {
|
||||
return mDatabaseKDB?.loadedCipherKey ?: mDatabaseKDBX?.loadedCipherKey
|
||||
}
|
||||
|
||||
val iconFactory: IconImageFactory
|
||||
get() {
|
||||
return mDatabaseKDB?.iconFactory ?: mDatabaseKDBX?.iconFactory ?: IconImageFactory()
|
||||
@@ -323,36 +338,32 @@ class Database {
|
||||
}
|
||||
|
||||
fun createData(databaseUri: Uri, databaseName: String, rootName: String) {
|
||||
setDatabaseKDBX(DatabaseKDBX(databaseName, rootName))
|
||||
val newDatabase = DatabaseKDBX(databaseName, rootName)
|
||||
newDatabase.loadedCipherKey = LoadedKey.generateNewCipherKey()
|
||||
setDatabaseKDBX(newDatabase)
|
||||
this.fileUri = databaseUri
|
||||
// Set Database state
|
||||
this.loaded = true
|
||||
}
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
fun loadData(uri: Uri, password: String?, keyfile: Uri?,
|
||||
readOnly: Boolean,
|
||||
contentResolver: ContentResolver,
|
||||
cacheDirectory: File,
|
||||
fixDuplicateUUID: Boolean,
|
||||
progressTaskUpdater: ProgressTaskUpdater?) {
|
||||
class LoadedKey(val key: Key, val iv: ByteArray): Serializable {
|
||||
companion object {
|
||||
const val BINARY_CIPHER = "Blowfish/CBC/PKCS5Padding"
|
||||
|
||||
this.fileUri = uri
|
||||
isReadOnly = readOnly
|
||||
if (uri.scheme == "file") {
|
||||
val file = File(uri.path!!)
|
||||
isReadOnly = !file.canWrite()
|
||||
}
|
||||
|
||||
// Pass KeyFile Uri as InputStreams
|
||||
var databaseInputStream: InputStream? = null
|
||||
var keyFileInputStream: InputStream? = null
|
||||
try {
|
||||
// Get keyFile inputStream
|
||||
keyfile?.let {
|
||||
keyFileInputStream = UriUtil.getUriInputStream(contentResolver, keyfile)
|
||||
fun generateNewCipherKey(): LoadedKey {
|
||||
val iv = ByteArray(8)
|
||||
SecureRandom().nextBytes(iv)
|
||||
return LoadedKey(KeyGenerator.getInstance("Blowfish").generateKey(), iv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
private fun readDatabaseStream(contentResolver: ContentResolver, uri: Uri,
|
||||
openDatabaseKDB: (InputStream) -> DatabaseKDB,
|
||||
openDatabaseKDBX: (InputStream) -> DatabaseKDBX) {
|
||||
var databaseInputStream: InputStream? = null
|
||||
try {
|
||||
// Load Data, pass Uris as InputStreams
|
||||
val databaseStream = UriUtil.getUriInputStream(contentResolver, uri)
|
||||
?: throw IOException("Database input stream cannot be retrieve")
|
||||
@@ -374,22 +385,10 @@ class Database {
|
||||
|
||||
when {
|
||||
// Header of database KDB
|
||||
DatabaseHeaderKDB.matchesHeader(sig1, sig2) -> setDatabaseKDB(DatabaseInputKDB(
|
||||
cacheDirectory,
|
||||
fixDuplicateUUID)
|
||||
.openDatabase(databaseInputStream,
|
||||
password,
|
||||
keyFileInputStream,
|
||||
progressTaskUpdater))
|
||||
DatabaseHeaderKDB.matchesHeader(sig1, sig2) -> setDatabaseKDB(openDatabaseKDB(databaseInputStream))
|
||||
|
||||
// Header of database KDBX
|
||||
DatabaseHeaderKDBX.matchesHeader(sig1, sig2) -> setDatabaseKDBX(DatabaseInputKDBX(
|
||||
cacheDirectory,
|
||||
fixDuplicateUUID)
|
||||
.openDatabase(databaseInputStream,
|
||||
password,
|
||||
keyFileInputStream,
|
||||
progressTaskUpdater))
|
||||
DatabaseHeaderKDBX.matchesHeader(sig1, sig2) -> setDatabaseKDBX(openDatabaseKDBX(databaseInputStream))
|
||||
|
||||
// Header not recognized
|
||||
else -> throw SignatureDatabaseException()
|
||||
@@ -397,14 +396,108 @@ class Database {
|
||||
|
||||
this.mSearchHelper = SearchHelper()
|
||||
loaded = true
|
||||
|
||||
} catch (e: LoadDatabaseException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw LoadDatabaseException(e)
|
||||
} finally {
|
||||
databaseInputStream?.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
fun loadData(uri: Uri,
|
||||
mainCredential: MainCredential,
|
||||
readOnly: Boolean,
|
||||
contentResolver: ContentResolver,
|
||||
cacheDirectory: File,
|
||||
tempCipherKey: LoadedKey,
|
||||
fixDuplicateUUID: Boolean,
|
||||
progressTaskUpdater: ProgressTaskUpdater?) {
|
||||
|
||||
// Save database URI
|
||||
this.fileUri = uri
|
||||
|
||||
// Check if the file is writable
|
||||
this.isReadOnly = readOnly
|
||||
|
||||
// Pass KeyFile Uri as InputStreams
|
||||
var keyFileInputStream: InputStream? = null
|
||||
try {
|
||||
// Get keyFile inputStream
|
||||
mainCredential.keyFileUri?.let { keyFile ->
|
||||
keyFileInputStream = UriUtil.getUriInputStream(contentResolver, keyFile)
|
||||
}
|
||||
|
||||
// Read database stream for the first time
|
||||
readDatabaseStream(contentResolver, uri,
|
||||
{ databaseInputStream ->
|
||||
DatabaseInputKDB(cacheDirectory)
|
||||
.openDatabase(databaseInputStream,
|
||||
mainCredential.masterPassword,
|
||||
keyFileInputStream,
|
||||
tempCipherKey,
|
||||
progressTaskUpdater,
|
||||
fixDuplicateUUID)
|
||||
},
|
||||
{ databaseInputStream ->
|
||||
DatabaseInputKDBX(cacheDirectory)
|
||||
.openDatabase(databaseInputStream,
|
||||
mainCredential.masterPassword,
|
||||
keyFileInputStream,
|
||||
tempCipherKey,
|
||||
progressTaskUpdater,
|
||||
fixDuplicateUUID)
|
||||
}
|
||||
)
|
||||
} catch (e: FileNotFoundException) {
|
||||
Log.e(TAG, "Unable to load keyfile", e)
|
||||
throw FileNotFoundDatabaseException()
|
||||
} catch (e: LoadDatabaseException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw LoadDatabaseException(e)
|
||||
} finally {
|
||||
keyFileInputStream?.close()
|
||||
databaseInputStream?.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
fun reloadData(contentResolver: ContentResolver,
|
||||
cacheDirectory: File,
|
||||
tempCipherKey: LoadedKey,
|
||||
progressTaskUpdater: ProgressTaskUpdater?) {
|
||||
|
||||
// Retrieve the stream from the old database URI
|
||||
try {
|
||||
fileUri?.let { oldDatabaseUri ->
|
||||
readDatabaseStream(contentResolver, oldDatabaseUri,
|
||||
{ databaseInputStream ->
|
||||
DatabaseInputKDB(cacheDirectory)
|
||||
.openDatabase(databaseInputStream,
|
||||
masterKey,
|
||||
tempCipherKey,
|
||||
progressTaskUpdater)
|
||||
},
|
||||
{ databaseInputStream ->
|
||||
DatabaseInputKDBX(cacheDirectory)
|
||||
.openDatabase(databaseInputStream,
|
||||
masterKey,
|
||||
tempCipherKey,
|
||||
progressTaskUpdater)
|
||||
}
|
||||
)
|
||||
} ?: run {
|
||||
Log.e(TAG, "Database URI is null, database cannot be reloaded")
|
||||
throw IODatabaseException()
|
||||
}
|
||||
} catch (e: FileNotFoundException) {
|
||||
Log.e(TAG, "Unable to load keyfile", e)
|
||||
throw FileNotFoundDatabaseException()
|
||||
} catch (e: LoadDatabaseException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw LoadDatabaseException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +519,7 @@ class Database {
|
||||
max: Int = Integer.MAX_VALUE): Group? {
|
||||
return mSearchHelper?.createVirtualGroupWithSearchResult(this,
|
||||
searchInfoString, SearchParameters().apply {
|
||||
searchInTitles = false
|
||||
searchInTitles = true
|
||||
searchInUserNames = false
|
||||
searchInPasswords = false
|
||||
searchInUrls = true
|
||||
@@ -531,7 +624,7 @@ class Database {
|
||||
this.fileUri = uri
|
||||
}
|
||||
|
||||
fun closeAndClear(filesDirectory: File? = null) {
|
||||
fun clear(filesDirectory: File? = null) {
|
||||
drawFactory.clearCache()
|
||||
// Delete the cache of the database if present
|
||||
mDatabaseKDB?.clearCache()
|
||||
@@ -544,7 +637,10 @@ class Database {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to clear the directory cache.", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAndClose(filesDirectory: File? = null) {
|
||||
clear(filesDirectory)
|
||||
this.mDatabaseKDB = null
|
||||
this.mDatabaseKDBX = null
|
||||
this.fileUri = null
|
||||
@@ -562,7 +658,9 @@ class Database {
|
||||
}
|
||||
}
|
||||
|
||||
fun validatePasswordEncoding(password: String?, containsKeyFile: Boolean): Boolean {
|
||||
fun validatePasswordEncoding(mainCredential: MainCredential): Boolean {
|
||||
val password = mainCredential.masterPassword
|
||||
val containsKeyFile = mainCredential.keyFileUri != null
|
||||
return mDatabaseKDB?.validatePasswordEncoding(password, containsKeyFile)
|
||||
?: mDatabaseKDBX?.validatePasswordEncoding(password, containsKeyFile)
|
||||
?: false
|
||||
|
||||
@@ -346,12 +346,6 @@ class Entry : Node, EntryVersionedInterface<Group> {
|
||||
|| entryKDBX?.containsAttachment() == true
|
||||
}
|
||||
|
||||
private fun addAttachments(binaryPool: BinaryPool, attachments: List<Attachment>) {
|
||||
attachments.forEach {
|
||||
putAttachment(it, binaryPool)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAttachment(attachment: Attachment) {
|
||||
entryKDB?.removeAttachment(attachment)
|
||||
entryKDBX?.removeAttachment(attachment)
|
||||
@@ -426,6 +420,8 @@ class Entry : Node, EntryVersionedInterface<Group> {
|
||||
entryInfo.icon = icon
|
||||
entryInfo.username = username
|
||||
entryInfo.password = password
|
||||
entryInfo.creationTime = creationTime
|
||||
entryInfo.lastModificationTime = lastModificationTime
|
||||
entryInfo.expires = expires
|
||||
entryInfo.expiryTime = expiryTime
|
||||
entryInfo.url = url
|
||||
@@ -456,17 +452,19 @@ class Entry : Node, EntryVersionedInterface<Group> {
|
||||
icon = newEntryInfo.icon
|
||||
username = newEntryInfo.username
|
||||
password = newEntryInfo.password
|
||||
// Update date time, creation time stay as is
|
||||
lastModificationTime = DateInstant()
|
||||
lastAccessTime = DateInstant()
|
||||
expires = newEntryInfo.expires
|
||||
expiryTime = newEntryInfo.expiryTime
|
||||
url = newEntryInfo.url
|
||||
notes = newEntryInfo.notes
|
||||
addExtraFields(newEntryInfo.customFields)
|
||||
database?.binaryPool?.let { binaryPool ->
|
||||
addAttachments(binaryPool, newEntryInfo.attachments)
|
||||
newEntryInfo.attachments.forEach { attachment ->
|
||||
putAttachment(attachment, binaryPool)
|
||||
}
|
||||
}
|
||||
// Update date time
|
||||
lastAccessTime = DateInstant()
|
||||
lastModificationTime = DateInstant()
|
||||
|
||||
database?.stopManageEntry(this)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.kunzisoft.keepass.database.element.icon.IconImage
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImageStandard
|
||||
import com.kunzisoft.keepass.database.element.node.*
|
||||
import com.kunzisoft.keepass.model.EntryInfo
|
||||
import com.kunzisoft.keepass.model.GroupInfo
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
@@ -232,6 +233,14 @@ class Group : Node, GroupVersionedInterface<Group, Entry> {
|
||||
override val isCurrentlyExpires: Boolean
|
||||
get() = groupKDB?.isCurrentlyExpires ?: groupKDBX?.isCurrentlyExpires ?: false
|
||||
|
||||
var notes: String?
|
||||
get() = groupKDBX?.notes
|
||||
set(value) {
|
||||
value?.let {
|
||||
groupKDBX?.notes = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun getChildGroups(): List<Group> {
|
||||
return groupKDB?.getChildGroups()?.map {
|
||||
Group(it)
|
||||
@@ -391,6 +400,35 @@ class Group : Node, GroupVersionedInterface<Group, Entry> {
|
||||
return groupKDBX?.containsCustomData() ?: false
|
||||
}
|
||||
|
||||
/*
|
||||
------------
|
||||
Converter
|
||||
------------
|
||||
*/
|
||||
|
||||
fun getGroupInfo(): GroupInfo {
|
||||
val groupInfo = GroupInfo()
|
||||
groupInfo.title = title
|
||||
groupInfo.icon = icon
|
||||
groupInfo.creationTime = creationTime
|
||||
groupInfo.lastModificationTime = lastModificationTime
|
||||
groupInfo.expires = expires
|
||||
groupInfo.expiryTime = expiryTime
|
||||
groupInfo.notes = notes
|
||||
return groupInfo
|
||||
}
|
||||
|
||||
fun setGroupInfo(groupInfo: GroupInfo) {
|
||||
title = groupInfo.title
|
||||
icon = groupInfo.icon
|
||||
// Update date time, creation time stay as is
|
||||
lastModificationTime = DateInstant()
|
||||
lastAccessTime = DateInstant()
|
||||
expires = groupInfo.expires
|
||||
expiryTime = groupInfo.expiryTime
|
||||
notes = groupInfo.notes
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
@@ -21,23 +21,33 @@ package com.kunzisoft.keepass.database.element.database
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.kunzisoft.keepass.stream.readBytes
|
||||
import android.util.Base64
|
||||
import android.util.Base64InputStream
|
||||
import android.util.Base64OutputStream
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.stream.readAllBytes
|
||||
import org.apache.commons.io.output.CountingOutputStream
|
||||
import java.io.*
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.CipherOutputStream
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
|
||||
class BinaryAttachment : Parcelable {
|
||||
|
||||
private var dataFile: File? = null
|
||||
var length: Long = 0
|
||||
private set
|
||||
var isCompressed: Boolean = false
|
||||
private set
|
||||
var isProtected: Boolean = false
|
||||
private set
|
||||
var isCorrupted: Boolean = false
|
||||
|
||||
fun length(): Long {
|
||||
return dataFile?.length() ?: 0
|
||||
}
|
||||
// Cipher to encrypt temp file
|
||||
private var cipherEncryption: Cipher = Cipher.getInstance(Database.LoadedKey.BINARY_CIPHER)
|
||||
private var cipherDecryption: Cipher = Cipher.getInstance(Database.LoadedKey.BINARY_CIPHER)
|
||||
|
||||
/**
|
||||
* Empty protected binary
|
||||
@@ -46,6 +56,7 @@ class BinaryAttachment : Parcelable {
|
||||
|
||||
constructor(dataFile: File, compressed: Boolean = false, protected: Boolean = false) {
|
||||
this.dataFile = dataFile
|
||||
this.length = 0
|
||||
this.isCompressed = compressed
|
||||
this.isProtected = protected
|
||||
}
|
||||
@@ -54,58 +65,77 @@ class BinaryAttachment : Parcelable {
|
||||
parcel.readString()?.let {
|
||||
dataFile = File(it)
|
||||
}
|
||||
length = parcel.readLong()
|
||||
isCompressed = parcel.readByte().toInt() != 0
|
||||
isProtected = parcel.readByte().toInt() != 0
|
||||
isCorrupted = parcel.readByte().toInt() != 0
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getInputDataStream(): InputStream {
|
||||
fun getInputDataStream(cipherKey: Database.LoadedKey): InputStream {
|
||||
return buildInputStream(dataFile!!, cipherKey)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getOutputDataStream(cipherKey: Database.LoadedKey): OutputStream {
|
||||
return buildOutputStream(dataFile!!, cipherKey)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getUnGzipInputDataStream(cipherKey: Database.LoadedKey): InputStream {
|
||||
return if (isCompressed) {
|
||||
GZIPInputStream(getInputDataStream(cipherKey))
|
||||
} else {
|
||||
getInputDataStream(cipherKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getGzipOutputDataStream(cipherKey: Database.LoadedKey): OutputStream {
|
||||
return if (isCompressed) {
|
||||
GZIPOutputStream(getOutputDataStream(cipherKey))
|
||||
} else {
|
||||
getOutputDataStream(cipherKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun buildInputStream(file: File?, cipherKey: Database.LoadedKey): InputStream {
|
||||
return when {
|
||||
length() > 0 -> FileInputStream(dataFile!!)
|
||||
file != null && file.length() > 0 -> {
|
||||
cipherDecryption.init(Cipher.DECRYPT_MODE, cipherKey.key, IvParameterSpec(cipherKey.iv))
|
||||
Base64InputStream(CipherInputStream(FileInputStream(file), cipherDecryption), Base64.NO_WRAP)
|
||||
}
|
||||
else -> ByteArrayInputStream(ByteArray(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getUnGzipInputDataStream(): InputStream {
|
||||
return if (isCompressed)
|
||||
GZIPInputStream(getInputDataStream())
|
||||
else
|
||||
getInputDataStream()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getOutputDataStream(): OutputStream {
|
||||
private fun buildOutputStream(file: File?, cipherKey: Database.LoadedKey): OutputStream {
|
||||
return when {
|
||||
dataFile != null -> FileOutputStream(dataFile!!)
|
||||
file != null -> {
|
||||
cipherEncryption.init(Cipher.ENCRYPT_MODE, cipherKey.key, IvParameterSpec(cipherKey.iv))
|
||||
BinaryCountingOutputStream(Base64OutputStream(CipherOutputStream(FileOutputStream(file), cipherEncryption), Base64.NO_WRAP))
|
||||
}
|
||||
else -> throw IOException("Unable to write in an unknown file")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getGzipOutputDataStream(): OutputStream {
|
||||
return if (isCompressed) {
|
||||
GZIPOutputStream(getOutputDataStream())
|
||||
} else {
|
||||
getOutputDataStream()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun compress(bufferSize: Int = DEFAULT_BUFFER_SIZE) {
|
||||
fun compress(cipherKey: Database.LoadedKey) {
|
||||
dataFile?.let { concreteDataFile ->
|
||||
// To compress, create a new binary with file
|
||||
if (!isCompressed) {
|
||||
// Encrypt the new gzipped temp file
|
||||
val fileBinaryCompress = File(concreteDataFile.parent, concreteDataFile.name + "_temp")
|
||||
GZIPOutputStream(FileOutputStream(fileBinaryCompress)).use { outputStream ->
|
||||
getInputDataStream().use { inputStream ->
|
||||
inputStream.readBytes(bufferSize) { buffer ->
|
||||
getInputDataStream(cipherKey).use { inputStream ->
|
||||
GZIPOutputStream(buildOutputStream(fileBinaryCompress, cipherKey)).use { outputStream ->
|
||||
inputStream.readAllBytes { buffer ->
|
||||
outputStream.write(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove unGzip file
|
||||
// Remove ungzip file
|
||||
if (concreteDataFile.delete()) {
|
||||
if (fileBinaryCompress.renameTo(concreteDataFile)) {
|
||||
// Harmonize with database compression
|
||||
@@ -117,13 +147,14 @@ class BinaryAttachment : Parcelable {
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun decompress(bufferSize: Int = DEFAULT_BUFFER_SIZE) {
|
||||
fun decompress(cipherKey: Database.LoadedKey) {
|
||||
dataFile?.let { concreteDataFile ->
|
||||
if (isCompressed) {
|
||||
// Encrypt the new ungzipped temp file
|
||||
val fileBinaryDecompress = File(concreteDataFile.parent, concreteDataFile.name + "_temp")
|
||||
FileOutputStream(fileBinaryDecompress).use { outputStream ->
|
||||
getUnGzipInputDataStream().use { inputStream ->
|
||||
inputStream.readBytes(bufferSize) { buffer ->
|
||||
getUnGzipInputDataStream(cipherKey).use { inputStream ->
|
||||
buildOutputStream(fileBinaryDecompress, cipherKey).use { outputStream ->
|
||||
inputStream.readAllBytes { buffer ->
|
||||
outputStream.write(buffer)
|
||||
}
|
||||
}
|
||||
@@ -170,6 +201,7 @@ class BinaryAttachment : Parcelable {
|
||||
result = 31 * result + if (isProtected) 1 else 0
|
||||
result = 31 * result + if (isCorrupted) 1 else 0
|
||||
result = 31 * result + dataFile!!.hashCode()
|
||||
result = 31 * result + length.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -183,11 +215,31 @@ class BinaryAttachment : Parcelable {
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
dest.writeString(dataFile?.absolutePath)
|
||||
dest.writeLong(length)
|
||||
dest.writeByte((if (isCompressed) 1 else 0).toByte())
|
||||
dest.writeByte((if (isProtected) 1 else 0).toByte())
|
||||
dest.writeByte((if (isCorrupted) 1 else 0).toByte())
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom OutputStream to calculate the size of binary file
|
||||
*/
|
||||
private inner class BinaryCountingOutputStream(out: OutputStream): CountingOutputStream(out) {
|
||||
init {
|
||||
length = 0
|
||||
}
|
||||
|
||||
override fun beforeWrite(n: Int) {
|
||||
super.beforeWrite(n)
|
||||
length = byteCount
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
super.close()
|
||||
length = byteCount
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = BinaryAttachment::class.java.name
|
||||
|
||||
@@ -163,10 +163,6 @@ class DatabaseKDB : DatabaseVersioned<Int, UUID, GroupKDB, EntryKDB>() {
|
||||
finalKey = messageDigest.digest()
|
||||
}
|
||||
|
||||
override fun loadXmlKeyFile(keyInputStream: InputStream): ByteArray? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun createGroup(): GroupKDB {
|
||||
return GroupKDB()
|
||||
}
|
||||
@@ -285,7 +281,5 @@ class DatabaseKDB : DatabaseVersioned<Int, UUID, GroupKDB, EntryKDB>() {
|
||||
|
||||
const val BACKUP_FOLDER_TITLE = "Backup"
|
||||
private const val BACKUP_FOLDER_UNDEFINED_ID = -1
|
||||
|
||||
const val BUFFER_SIZE_BYTES = 3 * 128
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,12 @@ import com.kunzisoft.keepass.database.element.security.MemoryProtectionConfig
|
||||
import com.kunzisoft.keepass.database.exception.UnknownKDF
|
||||
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX.Companion.FILE_VERSION_32_3
|
||||
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX.Companion.FILE_VERSION_32_4
|
||||
import com.kunzisoft.keepass.utils.StringUtil.removeSpaceChars
|
||||
import com.kunzisoft.keepass.utils.StringUtil.toHexString
|
||||
import com.kunzisoft.keepass.utils.UnsignedInt
|
||||
import com.kunzisoft.keepass.utils.VariantDictionary
|
||||
import org.apache.commons.codec.binary.Hex
|
||||
import org.w3c.dom.Node
|
||||
import org.w3c.dom.Text
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
@@ -113,7 +115,8 @@ class DatabaseKDBX : DatabaseVersioned<UUID, UUID, GroupKDBX, EntryKDBX> {
|
||||
|
||||
init {
|
||||
kdfList.add(KdfFactory.aesKdf)
|
||||
kdfList.add(KdfFactory.argon2Kdf)
|
||||
kdfList.add(KdfFactory.argon2dKdf)
|
||||
kdfList.add(KdfFactory.argon2idKdf)
|
||||
}
|
||||
|
||||
constructor()
|
||||
@@ -123,6 +126,7 @@ class DatabaseKDBX : DatabaseVersioned<UUID, UUID, GroupKDBX, EntryKDBX> {
|
||||
*/
|
||||
constructor(databaseName: String, rootName: String) {
|
||||
name = databaseName
|
||||
kdbxVersion = FILE_VERSION_32_3
|
||||
val group = createGroup().apply {
|
||||
title = rootName
|
||||
icon = iconFactory.folderIcon
|
||||
@@ -179,7 +183,8 @@ class DatabaseKDBX : DatabaseVersioned<UUID, UUID, GroupKDBX, EntryKDBX> {
|
||||
when (oldCompression) {
|
||||
CompressionAlgorithm.None -> {
|
||||
when (newCompression) {
|
||||
CompressionAlgorithm.None -> {}
|
||||
CompressionAlgorithm.None -> {
|
||||
}
|
||||
CompressionAlgorithm.GZip -> {
|
||||
// Only in databaseV3.1, in databaseV4 the header is zipped during the save
|
||||
if (kdbxVersion.toKotlinLong() < FILE_VERSION_32_4.toKotlinLong()) {
|
||||
@@ -197,7 +202,8 @@ class DatabaseKDBX : DatabaseVersioned<UUID, UUID, GroupKDBX, EntryKDBX> {
|
||||
CompressionAlgorithm.None -> {
|
||||
decompressAllBinaries()
|
||||
}
|
||||
CompressionAlgorithm.GZip -> {}
|
||||
CompressionAlgorithm.GZip -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,8 +213,10 @@ class DatabaseKDBX : DatabaseVersioned<UUID, UUID, GroupKDBX, EntryKDBX> {
|
||||
private fun compressAllBinaries() {
|
||||
binaryPool.doForEachBinary { binary ->
|
||||
try {
|
||||
val cipherKey = loadedCipherKey
|
||||
?: throw IOException("Unable to retrieve cipher key to compress binaries")
|
||||
// To compress, create a new binary with file
|
||||
binary.compress(BUFFER_SIZE_BYTES)
|
||||
binary.compress(cipherKey)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to compress $binary", e)
|
||||
}
|
||||
@@ -218,7 +226,9 @@ class DatabaseKDBX : DatabaseVersioned<UUID, UUID, GroupKDBX, EntryKDBX> {
|
||||
private fun decompressAllBinaries() {
|
||||
binaryPool.doForEachBinary { binary ->
|
||||
try {
|
||||
binary.decompress(BUFFER_SIZE_BYTES)
|
||||
val cipherKey = loadedCipherKey
|
||||
?: throw IOException("Unable to retrieve cipher key to decompress binaries")
|
||||
binary.decompress(cipherKey)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to decompress $binary", e)
|
||||
}
|
||||
@@ -377,36 +387,82 @@ class DatabaseKDBX : DatabaseVersioned<UUID, UUID, GroupKDBX, EntryKDBX> {
|
||||
try {
|
||||
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true)
|
||||
} catch (e : ParserConfigurationException) {
|
||||
Log.e(TAG, "Unable to add FEATURE_SECURE_PROCESSING to prevent XML eXternal Entity injection (XXE)", e)
|
||||
Log.w(TAG, "Unable to add FEATURE_SECURE_PROCESSING to prevent XML eXternal Entity injection (XXE)")
|
||||
}
|
||||
|
||||
val documentBuilder = documentBuilderFactory.newDocumentBuilder()
|
||||
val doc = documentBuilder.parse(keyInputStream)
|
||||
|
||||
var xmlKeyFileVersion = 1F
|
||||
|
||||
val docElement = doc.documentElement
|
||||
if (docElement == null || !docElement.nodeName.equals(RootElementName, ignoreCase = true)) {
|
||||
val keyFileChildNodes = docElement.childNodes
|
||||
// <KeyFile> Root node
|
||||
if (docElement == null
|
||||
|| !docElement.nodeName.equals(XML_NODE_ROOT_NAME, ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val children = docElement.childNodes
|
||||
if (children.length < 2) {
|
||||
if (keyFileChildNodes.length < 2)
|
||||
return null
|
||||
}
|
||||
|
||||
for (i in 0 until children.length) {
|
||||
val child = children.item(i)
|
||||
|
||||
if (child.nodeName.equals(KeyElementName, ignoreCase = true)) {
|
||||
val keyChildren = child.childNodes
|
||||
for (j in 0 until keyChildren.length) {
|
||||
val keyChild = keyChildren.item(j)
|
||||
if (keyChild.nodeName.equals(KeyDataElementName, ignoreCase = true)) {
|
||||
val children2 = keyChild.childNodes
|
||||
for (k in 0 until children2.length) {
|
||||
val text = children2.item(k)
|
||||
if (text.nodeType == Node.TEXT_NODE) {
|
||||
val txt = text as Text
|
||||
return Base64.decode(txt.nodeValue, BASE_64_FLAG)
|
||||
for (keyFileChildPosition in 0 until keyFileChildNodes.length) {
|
||||
val keyFileChildNode = keyFileChildNodes.item(keyFileChildPosition)
|
||||
// <Meta>
|
||||
if (keyFileChildNode.nodeName.equals(XML_NODE_META_NAME, ignoreCase = true)) {
|
||||
val metaChildNodes = keyFileChildNode.childNodes
|
||||
for (metaChildPosition in 0 until metaChildNodes.length) {
|
||||
val metaChildNode = metaChildNodes.item(metaChildPosition)
|
||||
// <Version>
|
||||
if (metaChildNode.nodeName.equals(XML_NODE_VERSION_NAME, ignoreCase = true)) {
|
||||
val versionChildNodes = metaChildNode.childNodes
|
||||
for (versionChildPosition in 0 until versionChildNodes.length) {
|
||||
val versionChildNode = versionChildNodes.item(versionChildPosition)
|
||||
if (versionChildNode.nodeType == Node.TEXT_NODE) {
|
||||
val versionText = versionChildNode.textContent.removeSpaceChars()
|
||||
try {
|
||||
xmlKeyFileVersion = versionText.toFloat()
|
||||
Log.i(TAG, "Reading XML KeyFile version : $xmlKeyFileVersion")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "XML Keyfile version cannot be read : $versionText")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// <Key>
|
||||
if (keyFileChildNode.nodeName.equals(XML_NODE_KEY_NAME, ignoreCase = true)) {
|
||||
val keyChildNodes = keyFileChildNode.childNodes
|
||||
for (keyChildPosition in 0 until keyChildNodes.length) {
|
||||
val keyChildNode = keyChildNodes.item(keyChildPosition)
|
||||
// <Data>
|
||||
if (keyChildNode.nodeName.equals(XML_NODE_DATA_NAME, ignoreCase = true)) {
|
||||
var hashString : String? = null
|
||||
if (keyChildNode.hasAttributes()) {
|
||||
val dataNodeAttributes = keyChildNode.attributes
|
||||
hashString = dataNodeAttributes
|
||||
.getNamedItem(XML_ATTRIBUTE_DATA_HASH).nodeValue
|
||||
}
|
||||
val dataChildNodes = keyChildNode.childNodes
|
||||
for (dataChildPosition in 0 until dataChildNodes.length) {
|
||||
val dataChildNode = dataChildNodes.item(dataChildPosition)
|
||||
if (dataChildNode.nodeType == Node.TEXT_NODE) {
|
||||
val dataString = dataChildNode.textContent.removeSpaceChars()
|
||||
when (xmlKeyFileVersion) {
|
||||
1F -> {
|
||||
// No hash in KeyFile XML version 1
|
||||
return Base64.decode(dataString, BASE_64_FLAG)
|
||||
}
|
||||
2F -> {
|
||||
return if (hashString != null
|
||||
&& checkKeyFileHash(dataString, hashString)) {
|
||||
Log.i(TAG, "Successful key file hash check.")
|
||||
Hex.decodeHex(dataString.toCharArray())
|
||||
} else {
|
||||
Log.e(TAG, "Unable to check the hash of the key file.")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,10 +472,26 @@ class DatabaseKDBX : DatabaseVersioned<UUID, UUID, GroupKDBX, EntryKDBX> {
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun checkKeyFileHash(data: String, hash: String): Boolean {
|
||||
val digest: MessageDigest?
|
||||
var success = false
|
||||
try {
|
||||
digest = MessageDigest.getInstance("SHA-256")
|
||||
digest?.reset()
|
||||
// hexadecimal encoding of the first 4 bytes of the SHA-256 hash of the key.
|
||||
val dataDigest = digest.digest(Hex.decodeHex(data.toCharArray()))
|
||||
.copyOfRange(0, 4)
|
||||
.toHexString()
|
||||
success = dataDigest == hash
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
override fun newGroupId(): NodeIdUUID {
|
||||
var newId: NodeIdUUID
|
||||
do {
|
||||
@@ -633,14 +705,13 @@ class DatabaseKDBX : DatabaseVersioned<UUID, UUID, GroupKDBX, EntryKDBX> {
|
||||
private const val DEFAULT_HISTORY_MAX_ITEMS = 10 // -1 unlimited
|
||||
private const val DEFAULT_HISTORY_MAX_SIZE = (6 * 1024 * 1024).toLong() // -1 unlimited
|
||||
|
||||
private const val RootElementName = "KeyFile"
|
||||
//private const val MetaElementName = "Meta";
|
||||
//private const val VersionElementName = "Version";
|
||||
private const val KeyElementName = "Key"
|
||||
private const val KeyDataElementName = "Data"
|
||||
private const val XML_NODE_ROOT_NAME = "KeyFile"
|
||||
private const val XML_NODE_META_NAME = "Meta"
|
||||
private const val XML_NODE_VERSION_NAME = "Version"
|
||||
private const val XML_NODE_KEY_NAME = "Key"
|
||||
private const val XML_NODE_DATA_NAME = "Data"
|
||||
private const val XML_ATTRIBUTE_DATA_HASH = "Hash"
|
||||
|
||||
const val BASE_64_FLAG = Base64.NO_WRAP
|
||||
|
||||
const val BUFFER_SIZE_BYTES = 3 * 128
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
package com.kunzisoft.keepass.database.element.database
|
||||
|
||||
import com.kunzisoft.keepass.crypto.keyDerivation.KdfEngine
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.entry.EntryVersioned
|
||||
import com.kunzisoft.keepass.database.element.group.GroupVersioned
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImageFactory
|
||||
@@ -27,7 +28,11 @@ import com.kunzisoft.keepass.database.element.node.NodeId
|
||||
import com.kunzisoft.keepass.database.element.node.Type
|
||||
import com.kunzisoft.keepass.database.element.security.EncryptionAlgorithm
|
||||
import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException
|
||||
import java.io.*
|
||||
import org.apache.commons.codec.binary.Hex
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.UnsupportedEncodingException
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.*
|
||||
@@ -80,13 +85,13 @@ abstract class DatabaseVersioned<
|
||||
protected abstract fun getMasterKey(key: String?, keyInputStream: InputStream?): ByteArray
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun retrieveMasterKey(key: String?, keyInputStream: InputStream?) {
|
||||
masterKey = getMasterKey(key, keyInputStream)
|
||||
fun retrieveMasterKey(key: String?, keyfileInputStream: InputStream?) {
|
||||
masterKey = getMasterKey(key, keyfileInputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
protected fun getCompositeKey(key: String, keyInputStream: InputStream): ByteArray {
|
||||
val fileKey = getFileKey(keyInputStream)
|
||||
protected fun getCompositeKey(key: String, keyfileInputStream: InputStream): ByteArray {
|
||||
val fileKey = getFileKey(keyfileInputStream)
|
||||
val passwordKey = getPasswordKey(key)
|
||||
|
||||
val messageDigest: MessageDigest
|
||||
@@ -124,42 +129,35 @@ abstract class DatabaseVersioned<
|
||||
@Throws(IOException::class)
|
||||
protected fun getFileKey(keyInputStream: InputStream): ByteArray {
|
||||
|
||||
val keyByteArrayOutputStream = ByteArrayOutputStream()
|
||||
keyInputStream.copyTo(keyByteArrayOutputStream)
|
||||
val keyData = keyByteArrayOutputStream.toByteArray()
|
||||
val keyData = keyInputStream.readBytes()
|
||||
|
||||
val keyByteArrayInputStream = ByteArrayInputStream(keyData)
|
||||
val key = loadXmlKeyFile(keyByteArrayInputStream)
|
||||
if (key != null) {
|
||||
return key
|
||||
// Check XML key file
|
||||
val xmlKeyByteArray = loadXmlKeyFile(ByteArrayInputStream(keyData))
|
||||
if (xmlKeyByteArray != null) {
|
||||
return xmlKeyByteArray
|
||||
}
|
||||
|
||||
when (keyData.size.toLong()) {
|
||||
32L -> return keyData
|
||||
64L -> try {
|
||||
return hexStringToByteArray(String(keyData))
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
// Check 32 bytes key file
|
||||
when (keyData.size) {
|
||||
32 -> return keyData
|
||||
64 -> try {
|
||||
return Hex.decodeHex(String(keyData).toCharArray())
|
||||
} catch (ignoredException: Exception) {
|
||||
// Key is not base 64, treat it as binary data
|
||||
}
|
||||
}
|
||||
|
||||
val messageDigest: MessageDigest
|
||||
// Hash file as binary data
|
||||
try {
|
||||
messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
return MessageDigest.getInstance("SHA-256").digest(keyData)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
throw IOException("SHA-256 not supported")
|
||||
}
|
||||
|
||||
try {
|
||||
messageDigest.update(keyData)
|
||||
} catch (e: Exception) {
|
||||
println(e.toString())
|
||||
}
|
||||
|
||||
return messageDigest.digest()
|
||||
}
|
||||
|
||||
protected abstract fun loadXmlKeyFile(keyInputStream: InputStream): ByteArray?
|
||||
protected open fun loadXmlKeyFile(keyInputStream: InputStream): ByteArray? {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun validatePasswordEncoding(password: String?, containsKeyFile: Boolean): Boolean {
|
||||
if (password == null && !containsKeyFile)
|
||||
@@ -386,21 +384,16 @@ abstract class DatabaseVersioned<
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Cipher key generated when the database is loaded, and destroyed when the database is closed
|
||||
* Can be used to temporarily store database elements
|
||||
*/
|
||||
var loadedCipherKey: Database.LoadedKey? = null
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "DatabaseVersioned"
|
||||
|
||||
val UUID_ZERO = UUID(0, 0)
|
||||
|
||||
fun hexStringToByteArray(s: String): ByteArray {
|
||||
val len = s.length
|
||||
val data = ByteArray(len / 2)
|
||||
var i = 0
|
||||
while (i < len) {
|
||||
data[i / 2] = ((Character.digit(s[i], 16) shl 4) + Character.digit(s[i + 1], 16)).toByte()
|
||||
i += 2
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ class EntryKDBX : EntryVersioned<UUID, UUID, GroupKDBX, EntryKDBX>, NodeKDBXInte
|
||||
var size = 0L
|
||||
for ((label, poolId) in binaries) {
|
||||
size += label.length.toLong()
|
||||
size += binaryPool[poolId]?.length() ?: 0
|
||||
size += binaryPool[poolId]?.length ?: 0
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
@@ -43,20 +43,12 @@ abstract class DatabaseException : Exception {
|
||||
}
|
||||
|
||||
open class LoadDatabaseException : DatabaseException {
|
||||
|
||||
@StringRes
|
||||
override var errorId: Int = R.string.error_load_database
|
||||
constructor() : super()
|
||||
constructor(throwable: Throwable) : super(throwable)
|
||||
}
|
||||
|
||||
class ArcFourDatabaseException : LoadDatabaseException {
|
||||
@StringRes
|
||||
override var errorId: Int = R.string.error_arc4
|
||||
constructor() : super()
|
||||
constructor(exception: Throwable) : super(exception)
|
||||
}
|
||||
|
||||
class FileNotFoundDatabaseException : LoadDatabaseException {
|
||||
@StringRes
|
||||
override var errorId: Int = R.string.file_not_found_content
|
||||
@@ -67,7 +59,6 @@ class FileNotFoundDatabaseException : LoadDatabaseException {
|
||||
class InvalidAlgorithmDatabaseException : LoadDatabaseException {
|
||||
@StringRes
|
||||
override var errorId: Int = R.string.invalid_algorithm
|
||||
|
||||
constructor() : super()
|
||||
constructor(exception: Throwable) : super(exception)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.database.file.input
|
||||
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseVersioned
|
||||
import com.kunzisoft.keepass.database.exception.LoadDatabaseException
|
||||
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
|
||||
@@ -37,10 +38,20 @@ abstract class DatabaseInput<PwDb : DatabaseVersioned<*, *, *, *>>
|
||||
*
|
||||
* @throws LoadDatabaseException on database error (contains IO exceptions)
|
||||
*/
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
abstract fun openDatabase(databaseInputStream: InputStream,
|
||||
password: String?,
|
||||
keyInputStream: InputStream?,
|
||||
progressTaskUpdater: ProgressTaskUpdater?): PwDb
|
||||
keyfileInputStream: InputStream?,
|
||||
loadedCipherKey: Database.LoadedKey,
|
||||
progressTaskUpdater: ProgressTaskUpdater?,
|
||||
fixDuplicateUUID: Boolean = false): PwDb
|
||||
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
abstract fun openDatabase(databaseInputStream: InputStream,
|
||||
masterKey: ByteArray,
|
||||
loadedCipherKey: Database.LoadedKey,
|
||||
progressTaskUpdater: ProgressTaskUpdater?,
|
||||
fixDuplicateUUID: Boolean = false): PwDb
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.kunzisoft.keepass.database.file.input
|
||||
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.crypto.CipherFactory
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDB
|
||||
import com.kunzisoft.keepass.database.element.entry.EntryKDB
|
||||
import com.kunzisoft.keepass.database.element.group.GroupKDB
|
||||
@@ -45,17 +46,41 @@ import javax.crypto.spec.SecretKeySpec
|
||||
/**
|
||||
* Load a KDB database file.
|
||||
*/
|
||||
class DatabaseInputKDB(cacheDirectory: File,
|
||||
private val fixDuplicateUUID: Boolean = false)
|
||||
class DatabaseInputKDB(cacheDirectory: File)
|
||||
: DatabaseInput<DatabaseKDB>(cacheDirectory) {
|
||||
|
||||
private lateinit var mDatabaseToOpen: DatabaseKDB
|
||||
private lateinit var mDatabase: DatabaseKDB
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
override fun openDatabase(databaseInputStream: InputStream,
|
||||
password: String?,
|
||||
keyInputStream: InputStream?,
|
||||
progressTaskUpdater: ProgressTaskUpdater?): DatabaseKDB {
|
||||
keyfileInputStream: InputStream?,
|
||||
loadedCipherKey: Database.LoadedKey,
|
||||
progressTaskUpdater: ProgressTaskUpdater?,
|
||||
fixDuplicateUUID: Boolean): DatabaseKDB {
|
||||
return openDatabase(databaseInputStream, progressTaskUpdater, fixDuplicateUUID) {
|
||||
mDatabase.loadedCipherKey = loadedCipherKey
|
||||
mDatabase.retrieveMasterKey(password, keyfileInputStream)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
override fun openDatabase(databaseInputStream: InputStream,
|
||||
masterKey: ByteArray,
|
||||
loadedCipherKey: Database.LoadedKey,
|
||||
progressTaskUpdater: ProgressTaskUpdater?,
|
||||
fixDuplicateUUID: Boolean): DatabaseKDB {
|
||||
return openDatabase(databaseInputStream, progressTaskUpdater, fixDuplicateUUID) {
|
||||
mDatabase.loadedCipherKey = loadedCipherKey
|
||||
mDatabase.masterKey = masterKey
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
private fun openDatabase(databaseInputStream: InputStream,
|
||||
progressTaskUpdater: ProgressTaskUpdater?,
|
||||
fixDuplicateUUID: Boolean,
|
||||
assignMasterKey: (() -> Unit)? = null): DatabaseKDB {
|
||||
|
||||
try {
|
||||
// Load entire file, most of it's encrypted.
|
||||
@@ -81,38 +106,38 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
}
|
||||
|
||||
progressTaskUpdater?.updateMessage(R.string.retrieving_db_key)
|
||||
mDatabaseToOpen = DatabaseKDB()
|
||||
mDatabase = DatabaseKDB()
|
||||
|
||||
mDatabaseToOpen.changeDuplicateId = fixDuplicateUUID
|
||||
mDatabaseToOpen.retrieveMasterKey(password, keyInputStream)
|
||||
mDatabase.changeDuplicateId = fixDuplicateUUID
|
||||
assignMasterKey?.invoke()
|
||||
|
||||
// Select algorithm
|
||||
when {
|
||||
header.flags.toKotlinInt() and DatabaseHeaderKDB.FLAG_RIJNDAEL.toKotlinInt() != 0 -> {
|
||||
mDatabaseToOpen.encryptionAlgorithm = EncryptionAlgorithm.AESRijndael
|
||||
mDatabase.encryptionAlgorithm = EncryptionAlgorithm.AESRijndael
|
||||
}
|
||||
header.flags.toKotlinInt() and DatabaseHeaderKDB.FLAG_TWOFISH.toKotlinInt() != 0 -> {
|
||||
mDatabaseToOpen.encryptionAlgorithm = EncryptionAlgorithm.Twofish
|
||||
mDatabase.encryptionAlgorithm = EncryptionAlgorithm.Twofish
|
||||
}
|
||||
else -> throw InvalidAlgorithmDatabaseException()
|
||||
}
|
||||
|
||||
mDatabaseToOpen.numberKeyEncryptionRounds = header.numKeyEncRounds.toKotlinLong()
|
||||
mDatabase.numberKeyEncryptionRounds = header.numKeyEncRounds.toKotlinLong()
|
||||
|
||||
// Generate transformedMasterKey from masterKey
|
||||
mDatabaseToOpen.makeFinalKey(
|
||||
mDatabase.makeFinalKey(
|
||||
header.masterSeed,
|
||||
header.transformSeed,
|
||||
mDatabaseToOpen.numberKeyEncryptionRounds)
|
||||
mDatabase.numberKeyEncryptionRounds)
|
||||
|
||||
progressTaskUpdater?.updateMessage(R.string.decrypting_db)
|
||||
// Initialize Rijndael algorithm
|
||||
val cipher: Cipher = try {
|
||||
when {
|
||||
mDatabaseToOpen.encryptionAlgorithm === EncryptionAlgorithm.AESRijndael -> {
|
||||
mDatabase.encryptionAlgorithm === EncryptionAlgorithm.AESRijndael -> {
|
||||
CipherFactory.getInstance("AES/CBC/PKCS5Padding")
|
||||
}
|
||||
mDatabaseToOpen.encryptionAlgorithm === EncryptionAlgorithm.Twofish -> {
|
||||
mDatabase.encryptionAlgorithm === EncryptionAlgorithm.Twofish -> {
|
||||
CipherFactory.getInstance("Twofish/CBC/PKCS7PADDING")
|
||||
}
|
||||
else -> throw IOException("Encryption algorithm is not supported")
|
||||
@@ -125,7 +150,7 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
|
||||
try {
|
||||
cipher.init(Cipher.DECRYPT_MODE,
|
||||
SecretKeySpec(mDatabaseToOpen.finalKey, "AES"),
|
||||
SecretKeySpec(mDatabase.finalKey, "AES"),
|
||||
IvParameterSpec(header.encryptionIV))
|
||||
} catch (e1: InvalidKeyException) {
|
||||
throw IOException("Invalid key")
|
||||
@@ -149,9 +174,9 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
)
|
||||
|
||||
// New manual root because KDB contains multiple root groups (here available with getRootGroups())
|
||||
val newRoot = mDatabaseToOpen.createGroup()
|
||||
val newRoot = mDatabase.createGroup()
|
||||
newRoot.level = -1
|
||||
mDatabaseToOpen.rootGroup = newRoot
|
||||
mDatabase.rootGroup = newRoot
|
||||
|
||||
// Import all nodes
|
||||
var newGroup: GroupKDB? = null
|
||||
@@ -172,12 +197,12 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
// Create new node depending on byte number
|
||||
when (fieldSize) {
|
||||
4 -> {
|
||||
newGroup = mDatabaseToOpen.createGroup().apply {
|
||||
newGroup = mDatabase.createGroup().apply {
|
||||
setGroupId(cipherInputStream.readBytes4ToUInt().toKotlinInt())
|
||||
}
|
||||
}
|
||||
16 -> {
|
||||
newEntry = mDatabaseToOpen.createEntry().apply {
|
||||
newEntry = mDatabase.createEntry().apply {
|
||||
nodeId = NodeIdUUID(cipherInputStream.readBytes16ToUuid())
|
||||
}
|
||||
}
|
||||
@@ -191,7 +216,7 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
group.title = cipherInputStream.readBytesToString(fieldSize)
|
||||
} ?:
|
||||
newEntry?.let { entry ->
|
||||
val groupKDB = mDatabaseToOpen.createGroup()
|
||||
val groupKDB = mDatabase.createGroup()
|
||||
groupKDB.nodeId = NodeIdInt(cipherInputStream.readBytes4ToUInt().toKotlinInt())
|
||||
entry.parent = groupKDB
|
||||
}
|
||||
@@ -206,7 +231,7 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
if (iconId == -1) {
|
||||
iconId = 0
|
||||
}
|
||||
entry.icon = mDatabaseToOpen.iconFactory.getIcon(iconId)
|
||||
entry.icon = mDatabase.iconFactory.getIcon(iconId)
|
||||
}
|
||||
}
|
||||
0x0004 -> {
|
||||
@@ -235,7 +260,7 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
}
|
||||
0x0007 -> {
|
||||
newGroup?.let { group ->
|
||||
group.icon = mDatabaseToOpen.iconFactory.getIcon(cipherInputStream.readBytes4ToUInt().toKotlinInt())
|
||||
group.icon = mDatabase.iconFactory.getIcon(cipherInputStream.readBytes4ToUInt().toKotlinInt())
|
||||
} ?:
|
||||
newEntry?.let { entry ->
|
||||
entry.password = cipherInputStream.readBytesToString(fieldSize,false)
|
||||
@@ -280,11 +305,12 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
0x000E -> {
|
||||
newEntry?.let { entry ->
|
||||
if (fieldSize > 0) {
|
||||
val binaryAttachment = mDatabaseToOpen.buildNewBinary(cacheDirectory)
|
||||
val binaryAttachment = mDatabase.buildNewBinary(cacheDirectory)
|
||||
entry.binaryData = binaryAttachment
|
||||
BufferedOutputStream(binaryAttachment.getOutputDataStream()).use { outputStream ->
|
||||
cipherInputStream.readBytes(fieldSize,
|
||||
DatabaseKDB.BUFFER_SIZE_BYTES) { buffer ->
|
||||
val cipherKey = mDatabase.loadedCipherKey
|
||||
?: throw IOException("Unable to retrieve cipher key to load binaries")
|
||||
BufferedOutputStream(binaryAttachment.getOutputDataStream(cipherKey)).use { outputStream ->
|
||||
cipherInputStream.readBytes(fieldSize) { buffer ->
|
||||
outputStream.write(buffer)
|
||||
}
|
||||
}
|
||||
@@ -294,12 +320,12 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
0xFFFF -> {
|
||||
// End record. Save node and count it.
|
||||
newGroup?.let { group ->
|
||||
mDatabaseToOpen.addGroupIndex(group)
|
||||
mDatabase.addGroupIndex(group)
|
||||
currentGroupNumber++
|
||||
newGroup = null
|
||||
}
|
||||
newEntry?.let { entry ->
|
||||
mDatabaseToOpen.addEntryIndex(entry)
|
||||
mDatabase.addEntryIndex(entry)
|
||||
currentEntryNumber++
|
||||
newEntry = null
|
||||
}
|
||||
@@ -317,20 +343,20 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
constructTreeFromIndex()
|
||||
|
||||
} catch (e: LoadDatabaseException) {
|
||||
mDatabaseToOpen.clearCache()
|
||||
mDatabase.clearCache()
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
mDatabaseToOpen.clearCache()
|
||||
mDatabase.clearCache()
|
||||
throw IODatabaseException(e)
|
||||
} catch (e: OutOfMemoryError) {
|
||||
mDatabaseToOpen.clearCache()
|
||||
mDatabase.clearCache()
|
||||
throw NoMemoryDatabaseException(e)
|
||||
} catch (e: Exception) {
|
||||
mDatabaseToOpen.clearCache()
|
||||
mDatabase.clearCache()
|
||||
throw LoadDatabaseException(e)
|
||||
}
|
||||
|
||||
return mDatabaseToOpen
|
||||
return mDatabase
|
||||
}
|
||||
|
||||
private fun buildTreeGroups(previousGroup: GroupKDB, currentGroup: GroupKDB, groupIterator: Iterator<GroupKDB>) {
|
||||
@@ -355,18 +381,18 @@ class DatabaseInputKDB(cacheDirectory: File,
|
||||
}
|
||||
|
||||
private fun constructTreeFromIndex() {
|
||||
mDatabaseToOpen.rootGroup?.let {
|
||||
mDatabase.rootGroup?.let {
|
||||
|
||||
// add each group
|
||||
val groupIterator = mDatabaseToOpen.getGroupIndexes().iterator()
|
||||
val groupIterator = mDatabase.getGroupIndexes().iterator()
|
||||
if (groupIterator.hasNext())
|
||||
buildTreeGroups(it, groupIterator.next(), groupIterator)
|
||||
|
||||
// add each child
|
||||
for (currentEntry in mDatabaseToOpen.getEntryIndexes()) {
|
||||
for (currentEntry in mDatabase.getEntryIndexes()) {
|
||||
if (currentEntry.parent != null) {
|
||||
// Only the parent id is known so complete the info
|
||||
val parentGroupRetrieve = mDatabaseToOpen.getGroupById(currentEntry.parent!!.nodeId)
|
||||
val parentGroupRetrieve = mDatabase.getGroupById(currentEntry.parent!!.nodeId)
|
||||
parentGroupRetrieve?.addChildEntry(currentEntry)
|
||||
currentEntry.parent = parentGroupRetrieve
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.crypto.CipherFactory
|
||||
import com.kunzisoft.keepass.crypto.StreamCipherFactory
|
||||
import com.kunzisoft.keepass.crypto.engine.CipherEngine
|
||||
import com.kunzisoft.keepass.database.element.Attachment
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.DateInstant
|
||||
import com.kunzisoft.keepass.database.element.DeletedObject
|
||||
import com.kunzisoft.keepass.database.element.Attachment
|
||||
import com.kunzisoft.keepass.database.element.database.BinaryAttachment
|
||||
import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX.Companion.BASE_64_FLAG
|
||||
@@ -37,7 +39,6 @@ import com.kunzisoft.keepass.database.element.group.GroupKDBX
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImageCustom
|
||||
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
|
||||
import com.kunzisoft.keepass.database.element.node.NodeKDBXInterface
|
||||
import com.kunzisoft.keepass.database.element.database.BinaryAttachment
|
||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||
import com.kunzisoft.keepass.database.exception.*
|
||||
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX
|
||||
@@ -63,8 +64,7 @@ import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import kotlin.math.min
|
||||
|
||||
class DatabaseInputKDBX(cacheDirectory: File,
|
||||
private val fixDuplicateUUID: Boolean = false)
|
||||
class DatabaseInputKDBX(cacheDirectory: File)
|
||||
: DatabaseInput<DatabaseKDBX>(cacheDirectory) {
|
||||
|
||||
private var randomStream: StreamCipher? = null
|
||||
@@ -97,13 +97,35 @@ class DatabaseInputKDBX(cacheDirectory: File,
|
||||
@Throws(LoadDatabaseException::class)
|
||||
override fun openDatabase(databaseInputStream: InputStream,
|
||||
password: String?,
|
||||
keyInputStream: InputStream?,
|
||||
progressTaskUpdater: ProgressTaskUpdater?): DatabaseKDBX {
|
||||
keyfileInputStream: InputStream?,
|
||||
loadedCipherKey: Database.LoadedKey,
|
||||
progressTaskUpdater: ProgressTaskUpdater?,
|
||||
fixDuplicateUUID: Boolean): DatabaseKDBX {
|
||||
return openDatabase(databaseInputStream, progressTaskUpdater, fixDuplicateUUID) {
|
||||
mDatabase.loadedCipherKey = loadedCipherKey
|
||||
mDatabase.retrieveMasterKey(password, keyfileInputStream)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
override fun openDatabase(databaseInputStream: InputStream,
|
||||
masterKey: ByteArray,
|
||||
loadedCipherKey: Database.LoadedKey,
|
||||
progressTaskUpdater: ProgressTaskUpdater?,
|
||||
fixDuplicateUUID: Boolean): DatabaseKDBX {
|
||||
return openDatabase(databaseInputStream, progressTaskUpdater, fixDuplicateUUID) {
|
||||
mDatabase.loadedCipherKey = loadedCipherKey
|
||||
mDatabase.masterKey = masterKey
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(LoadDatabaseException::class)
|
||||
private fun openDatabase(databaseInputStream: InputStream,
|
||||
progressTaskUpdater: ProgressTaskUpdater?,
|
||||
fixDuplicateUUID: Boolean,
|
||||
assignMasterKey: (() -> Unit)? = null): DatabaseKDBX {
|
||||
try {
|
||||
// TODO performance
|
||||
progressTaskUpdater?.updateMessage(R.string.retrieving_db_key)
|
||||
|
||||
mDatabase = DatabaseKDBX()
|
||||
|
||||
mDatabase.changeDuplicateId = fixDuplicateUUID
|
||||
@@ -116,9 +138,8 @@ class DatabaseInputKDBX(cacheDirectory: File,
|
||||
hashOfHeader = headerAndHash.hash
|
||||
val pbHeader = headerAndHash.header
|
||||
|
||||
mDatabase.retrieveMasterKey(password, keyInputStream)
|
||||
assignMasterKey?.invoke()
|
||||
mDatabase.makeFinalKey(header.masterSeed)
|
||||
// TODO performance
|
||||
|
||||
progressTaskUpdater?.updateMessage(R.string.decrypting_db)
|
||||
val engine: CipherEngine
|
||||
@@ -185,10 +206,10 @@ class DatabaseInputKDBX(cacheDirectory: File,
|
||||
loadInnerHeader(inputStreamXml, header)
|
||||
}
|
||||
|
||||
randomStream = StreamCipherFactory.getInstance(header.innerRandomStream, header.innerRandomStreamKey)
|
||||
|
||||
if (randomStream == null) {
|
||||
throw ArcFourDatabaseException()
|
||||
try {
|
||||
randomStream = StreamCipherFactory.getInstance(header.innerRandomStream, header.innerRandomStreamKey)
|
||||
} catch (e: Exception) {
|
||||
throw LoadDatabaseException(e)
|
||||
}
|
||||
|
||||
readDocumentStreamed(createPullParser(inputStreamXml))
|
||||
@@ -257,8 +278,10 @@ class DatabaseInputKDBX(cacheDirectory: File,
|
||||
val byteLength = size - 1
|
||||
// No compression at this level
|
||||
val protectedBinary = mDatabase.buildNewBinary(cacheDirectory, false, protectedFlag)
|
||||
protectedBinary.getOutputDataStream().use { outputStream ->
|
||||
dataInputStream.readBytes(byteLength, DatabaseKDBX.BUFFER_SIZE_BYTES) { buffer ->
|
||||
val cipherKey = mDatabase.loadedCipherKey
|
||||
?: throw IOException("Unable to retrieve cipher key to load binaries")
|
||||
protectedBinary.getOutputDataStream(cipherKey).use { outputStream ->
|
||||
dataInputStream.readBytes(byteLength) { buffer ->
|
||||
outputStream.write(buffer)
|
||||
}
|
||||
}
|
||||
@@ -436,8 +459,6 @@ class DatabaseInputKDBX(cacheDirectory: File,
|
||||
val strData = readString(xpp)
|
||||
if (strData.isNotEmpty()) {
|
||||
customIconData = Base64.decode(strData, BASE_64_FLAG)
|
||||
} else {
|
||||
assert(false)
|
||||
}
|
||||
} else {
|
||||
readUnknown(xpp)
|
||||
@@ -958,7 +979,7 @@ class DatabaseInputKDBX(cacheDirectory: File,
|
||||
// Create empty binary if not retrieved in pool
|
||||
if (binaryRetrieve == null) {
|
||||
binaryRetrieve = mDatabase.buildNewBinary(cacheDirectory,
|
||||
compression = false, protection = true, binaryPoolId = id)
|
||||
compression = false, protection = false, binaryPoolId = id)
|
||||
}
|
||||
return binaryRetrieve
|
||||
}
|
||||
@@ -995,14 +1016,16 @@ class DatabaseInputKDBX(cacheDirectory: File,
|
||||
|
||||
// Build the new binary and compress
|
||||
val binaryAttachment = mDatabase.buildNewBinary(cacheDirectory, compressed, protected, binaryId)
|
||||
val binaryCipherKey = mDatabase.loadedCipherKey
|
||||
?: throw IOException("Unable to retrieve cipher key to load binaries")
|
||||
try {
|
||||
binaryAttachment.getOutputDataStream().use { outputStream ->
|
||||
binaryAttachment.getOutputDataStream(binaryCipherKey).use { outputStream ->
|
||||
outputStream.write(Base64.decode(base64, BASE_64_FLAG))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to read base 64 attachment", e)
|
||||
binaryAttachment.isCorrupted = true
|
||||
binaryAttachment.getOutputDataStream().use { outputStream ->
|
||||
binaryAttachment.getOutputDataStream(binaryCipherKey).use { outputStream ->
|
||||
outputStream.write(base64.toByteArray())
|
||||
}
|
||||
}
|
||||
@@ -1024,29 +1047,20 @@ class DatabaseInputKDBX(cacheDirectory: File,
|
||||
return xpp.safeNextText()
|
||||
}
|
||||
|
||||
@Throws(XmlPullParserException::class, IOException::class)
|
||||
private fun readBase64String(xpp: XmlPullParser): ByteArray {
|
||||
|
||||
//readNextNode = false;
|
||||
Base64.decode(xpp.safeNextText(), BASE_64_FLAG)?.let { data ->
|
||||
val plainText = ByteArray(data.size)
|
||||
randomStream?.processBytes(data, 0, data.size, plainText, 0)
|
||||
return plainText
|
||||
}
|
||||
return ByteArray(0)
|
||||
}
|
||||
|
||||
@Throws(XmlPullParserException::class, IOException::class)
|
||||
private fun readProtectedBase64String(xpp: XmlPullParser): ByteArray? {
|
||||
//(xpp.getEventType() == XmlPullParser.START_TAG);
|
||||
|
||||
if (xpp.attributeCount > 0) {
|
||||
val protect = xpp.getAttributeValue(null, DatabaseKDBXXML.AttrProtected)
|
||||
if (protect != null && protect.equals(DatabaseKDBXXML.ValTrue, ignoreCase = true)) {
|
||||
return readBase64String(xpp)
|
||||
Base64.decode(xpp.safeNextText(), BASE_64_FLAG)?.let { data ->
|
||||
val plainText = ByteArray(data.size)
|
||||
randomStream?.processBytes(data, 0, data.size, plainText, 0)
|
||||
return plainText
|
||||
}
|
||||
return ByteArray(0)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1078,6 +1092,7 @@ class DatabaseInputKDBX(cacheDirectory: File,
|
||||
return xpp
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Throws(IOException::class, XmlPullParserException::class)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDX is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDX is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.database.file.output
|
||||
|
||||
open class DatabaseHeaderOutput {
|
||||
var hashOfHeader: ByteArray? = null
|
||||
protected set
|
||||
}
|
||||
@@ -40,13 +40,16 @@ import javax.crypto.spec.SecretKeySpec
|
||||
class DatabaseHeaderOutputKDBX @Throws(DatabaseOutputException::class)
|
||||
constructor(private val databaseKDBX: DatabaseKDBX,
|
||||
private val header: DatabaseHeaderKDBX,
|
||||
outputStream: OutputStream) : DatabaseHeaderOutput() {
|
||||
outputStream: OutputStream) {
|
||||
|
||||
private val los: LittleEndianDataOutputStream
|
||||
private val mos: MacOutputStream
|
||||
private val dos: DigestOutputStream
|
||||
lateinit var headerHmac: ByteArray
|
||||
|
||||
var hashOfHeader: ByteArray? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
|
||||
val md: MessageDigest
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||
*
|
||||
* This file is part of KeePassDX.
|
||||
*
|
||||
* KeePassDroid is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* KeePassDroid is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with KeePassDroid. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.database.file.output
|
||||
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX.Companion.BUFFER_SIZE_BYTES
|
||||
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX
|
||||
import com.kunzisoft.keepass.stream.LittleEndianDataOutputStream
|
||||
import com.kunzisoft.keepass.stream.readBytes
|
||||
import com.kunzisoft.keepass.utils.UnsignedInt
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import kotlin.experimental.or
|
||||
|
||||
class DatabaseInnerHeaderOutputKDBX(private val database: DatabaseKDBX,
|
||||
private val header: DatabaseHeaderKDBX,
|
||||
outputStream: OutputStream) {
|
||||
|
||||
private val dataOutputStream: LittleEndianDataOutputStream = LittleEndianDataOutputStream(outputStream)
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun output() {
|
||||
dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.InnerRandomStreamID)
|
||||
dataOutputStream.writeInt(4)
|
||||
if (header.innerRandomStream == null)
|
||||
throw IOException("Can't write innerRandomStream")
|
||||
dataOutputStream.writeUInt(header.innerRandomStream!!.id)
|
||||
|
||||
val streamKeySize = header.innerRandomStreamKey.size
|
||||
dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.InnerRandomstreamKey)
|
||||
dataOutputStream.writeInt(streamKeySize)
|
||||
dataOutputStream.write(header.innerRandomStreamKey)
|
||||
|
||||
database.binaryPool.doForEachOrderedBinary { _, keyBinary ->
|
||||
val protectedBinary = keyBinary.binary
|
||||
// Force decompression to add binary in header
|
||||
protectedBinary.decompress()
|
||||
// Write type binary
|
||||
dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.Binary)
|
||||
// Write size
|
||||
dataOutputStream.writeUInt(UnsignedInt.fromKotlinLong(protectedBinary.length() + 1))
|
||||
// Write protected flag
|
||||
var flag = DatabaseHeaderKDBX.KdbxBinaryFlags.None
|
||||
if (protectedBinary.isProtected) {
|
||||
flag = flag or DatabaseHeaderKDBX.KdbxBinaryFlags.Protected
|
||||
}
|
||||
dataOutputStream.writeByte(flag)
|
||||
|
||||
protectedBinary.getInputDataStream().use { inputStream ->
|
||||
inputStream.readBytes(BUFFER_SIZE_BYTES) { buffer ->
|
||||
dataOutputStream.write(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.EndOfHeader)
|
||||
dataOutputStream.writeInt(0)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import java.io.OutputStream
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.security.SecureRandom
|
||||
|
||||
abstract class DatabaseOutput<Header : DatabaseHeader> protected constructor(protected var mOS: OutputStream) {
|
||||
abstract class DatabaseOutput<Header : DatabaseHeader> protected constructor(protected var mOutputStream: OutputStream) {
|
||||
|
||||
@Throws(DatabaseOutputException::class)
|
||||
protected open fun setIVs(header: Header): SecureRandom {
|
||||
|
||||
@@ -21,8 +21,8 @@ package com.kunzisoft.keepass.database.file.output
|
||||
|
||||
import com.kunzisoft.keepass.crypto.CipherFactory
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDB
|
||||
import com.kunzisoft.keepass.database.element.security.EncryptionAlgorithm
|
||||
import com.kunzisoft.keepass.database.element.group.GroupKDB
|
||||
import com.kunzisoft.keepass.database.element.security.EncryptionAlgorithm
|
||||
import com.kunzisoft.keepass.database.exception.DatabaseOutputException
|
||||
import com.kunzisoft.keepass.database.file.DatabaseHeader
|
||||
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDB
|
||||
@@ -63,7 +63,7 @@ class DatabaseOutputKDB(private val mDatabaseKDB: DatabaseKDB,
|
||||
// and remove any orphaned nodes that are no longer part of the tree hierarchy
|
||||
sortGroupsForOutput()
|
||||
|
||||
val header = outputHeader(mOS)
|
||||
val header = outputHeader(mOutputStream)
|
||||
|
||||
val finalKey = getFinalKey(header)
|
||||
|
||||
@@ -85,7 +85,7 @@ class DatabaseOutputKDB(private val mDatabaseKDB: DatabaseKDB,
|
||||
cipher.init(Cipher.ENCRYPT_MODE,
|
||||
SecretKeySpec(finalKey, "AES"),
|
||||
IvParameterSpec(header.encryptionIV))
|
||||
val cos = CipherOutputStream(mOS, cipher)
|
||||
val cos = CipherOutputStream(mOutputStream, cipher)
|
||||
val bos = BufferedOutputStream(cos)
|
||||
outputPlanGroupAndEntries(bos)
|
||||
bos.flush()
|
||||
@@ -197,15 +197,15 @@ class DatabaseOutputKDB(private val mDatabaseKDB: DatabaseKDB,
|
||||
|
||||
@Suppress("CAST_NEVER_SUCCEEDS")
|
||||
@Throws(DatabaseOutputException::class)
|
||||
fun outputPlanGroupAndEntries(os: OutputStream) {
|
||||
val los = LittleEndianDataOutputStream(os)
|
||||
fun outputPlanGroupAndEntries(outputStream: OutputStream) {
|
||||
val littleEndianDataOutputStream = LittleEndianDataOutputStream(outputStream)
|
||||
|
||||
// useHeaderHash
|
||||
if (headerHashBlock != null) {
|
||||
try {
|
||||
los.writeUShort(0x0000)
|
||||
los.writeInt(headerHashBlock!!.size)
|
||||
los.write(headerHashBlock!!)
|
||||
littleEndianDataOutputStream.writeUShort(0x0000)
|
||||
littleEndianDataOutputStream.writeInt(headerHashBlock!!.size)
|
||||
littleEndianDataOutputStream.write(headerHashBlock!!)
|
||||
} catch (e: IOException) {
|
||||
throw DatabaseOutputException("Failed to output header hash.", e)
|
||||
}
|
||||
@@ -213,20 +213,13 @@ class DatabaseOutputKDB(private val mDatabaseKDB: DatabaseKDB,
|
||||
|
||||
// Groups
|
||||
mDatabaseKDB.doForEachGroupInIndex { group ->
|
||||
val pgo = GroupOutputKDB(group, os)
|
||||
try {
|
||||
pgo.output()
|
||||
} catch (e: IOException) {
|
||||
throw DatabaseOutputException("Failed to output a tree", e)
|
||||
}
|
||||
GroupOutputKDB(group, outputStream).output()
|
||||
}
|
||||
// Entries
|
||||
val binaryCipherKey = mDatabaseKDB.loadedCipherKey
|
||||
?: throw DatabaseOutputException("Unable to retrieve cipher key to write binaries")
|
||||
mDatabaseKDB.doForEachEntryInIndex { entry ->
|
||||
val peo = EntryOutputKDB(entry, os)
|
||||
try {
|
||||
peo.output()
|
||||
} catch (e: IOException) {
|
||||
throw DatabaseOutputException("Failed to output an entry.", e)
|
||||
}
|
||||
EntryOutputKDB(entry, outputStream, binaryCipherKey).output()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,11 @@ import com.kunzisoft.keepass.database.element.DeletedObject
|
||||
import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX.Companion.BASE_64_FLAG
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX.Companion.BUFFER_SIZE_BYTES
|
||||
import com.kunzisoft.keepass.database.element.entry.AutoType
|
||||
import com.kunzisoft.keepass.database.element.entry.EntryKDBX
|
||||
import com.kunzisoft.keepass.database.element.group.GroupKDBX
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImageCustom
|
||||
import com.kunzisoft.keepass.database.element.node.NodeKDBXInterface
|
||||
import com.kunzisoft.keepass.database.element.database.BinaryAttachment
|
||||
import com.kunzisoft.keepass.database.element.security.MemoryProtectionConfig
|
||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||
import com.kunzisoft.keepass.database.exception.DatabaseOutputException
|
||||
@@ -47,6 +45,7 @@ import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX
|
||||
import com.kunzisoft.keepass.database.file.DatabaseKDBXXML
|
||||
import com.kunzisoft.keepass.database.file.DateKDBXUtil
|
||||
import com.kunzisoft.keepass.stream.*
|
||||
import com.kunzisoft.keepass.utils.UnsignedInt
|
||||
import org.bouncycastle.crypto.StreamCipher
|
||||
import org.joda.time.DateTime
|
||||
import org.xmlpull.v1.XmlSerializer
|
||||
@@ -58,6 +57,7 @@ import java.util.*
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherOutputStream
|
||||
import kotlin.experimental.or
|
||||
|
||||
|
||||
class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
|
||||
@@ -81,20 +81,19 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
|
||||
throw DatabaseOutputException("No such cipher", e)
|
||||
}
|
||||
|
||||
header = outputHeader(mOS)
|
||||
header = outputHeader(mOutputStream)
|
||||
|
||||
val osPlain: OutputStream
|
||||
osPlain = if (header!!.version.toKotlinLong() < DatabaseHeaderKDBX.FILE_VERSION_32_4.toKotlinLong()) {
|
||||
val cos = attachStreamEncryptor(header!!, mOS)
|
||||
val cos = attachStreamEncryptor(header!!, mOutputStream)
|
||||
cos.write(header!!.streamStartBytes)
|
||||
|
||||
HashedBlockOutputStream(cos)
|
||||
} else {
|
||||
mOS.write(hashOfHeader!!)
|
||||
mOS.write(headerHmac!!)
|
||||
mOutputStream.write(hashOfHeader!!)
|
||||
mOutputStream.write(headerHmac!!)
|
||||
|
||||
|
||||
attachStreamEncryptor(header!!, HmacBlockOutputStream(mOS, mDatabaseKDBX.hmacKey!!))
|
||||
attachStreamEncryptor(header!!, HmacBlockOutputStream(mOutputStream, mDatabaseKDBX.hmacKey!!))
|
||||
}
|
||||
|
||||
val osXml: OutputStream
|
||||
@@ -105,8 +104,7 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
|
||||
}
|
||||
|
||||
if (header!!.version.toKotlinLong() >= DatabaseHeaderKDBX.FILE_VERSION_32_4.toKotlinLong()) {
|
||||
val ihOut = DatabaseInnerHeaderOutputKDBX(mDatabaseKDBX, header!!, osXml)
|
||||
ihOut.output()
|
||||
outputInnerHeader(mDatabaseKDBX, header!!, osXml)
|
||||
}
|
||||
|
||||
outputDatabase(osXml)
|
||||
@@ -122,6 +120,51 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun outputInnerHeader(database: DatabaseKDBX,
|
||||
header: DatabaseHeaderKDBX,
|
||||
outputStream: OutputStream) {
|
||||
val dataOutputStream = LittleEndianDataOutputStream(outputStream)
|
||||
|
||||
dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.InnerRandomStreamID)
|
||||
dataOutputStream.writeInt(4)
|
||||
if (header.innerRandomStream == null)
|
||||
throw IOException("Can't write innerRandomStream")
|
||||
dataOutputStream.writeUInt(header.innerRandomStream!!.id)
|
||||
|
||||
val streamKeySize = header.innerRandomStreamKey.size
|
||||
dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.InnerRandomstreamKey)
|
||||
dataOutputStream.writeInt(streamKeySize)
|
||||
dataOutputStream.write(header.innerRandomStreamKey)
|
||||
|
||||
database.binaryPool.doForEachOrderedBinary { _, keyBinary ->
|
||||
val protectedBinary = keyBinary.binary
|
||||
val binaryCipherKey = database.loadedCipherKey
|
||||
?: throw IOException("Unable to retrieve cipher key to write binaries")
|
||||
// Force decompression to add binary in header
|
||||
protectedBinary.decompress(binaryCipherKey)
|
||||
// Write type binary
|
||||
dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.Binary)
|
||||
// Write size
|
||||
dataOutputStream.writeUInt(UnsignedInt.fromKotlinLong(protectedBinary.length + 1))
|
||||
// Write protected flag
|
||||
var flag = DatabaseHeaderKDBX.KdbxBinaryFlags.None
|
||||
if (protectedBinary.isProtected) {
|
||||
flag = flag or DatabaseHeaderKDBX.KdbxBinaryFlags.Protected
|
||||
}
|
||||
dataOutputStream.writeByte(flag)
|
||||
|
||||
protectedBinary.getInputDataStream(binaryCipherKey).use { inputStream ->
|
||||
inputStream.readAllBytes { buffer ->
|
||||
dataOutputStream.write(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.EndOfHeader)
|
||||
dataOutputStream.writeInt(0)
|
||||
}
|
||||
|
||||
@Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class)
|
||||
private fun outputDatabase(outputStream: OutputStream) {
|
||||
|
||||
@@ -282,9 +325,10 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
|
||||
}
|
||||
random.nextBytes(header.innerRandomStreamKey)
|
||||
|
||||
randomStream = StreamCipherFactory.getInstance(header.innerRandomStream, header.innerRandomStreamKey)
|
||||
if (randomStream == null) {
|
||||
throw DatabaseOutputException("Invalid random cipher")
|
||||
try {
|
||||
randomStream = StreamCipherFactory.getInstance(header.innerRandomStream, header.innerRandomStreamKey)
|
||||
} catch (e: Exception) {
|
||||
throw DatabaseOutputException(e)
|
||||
}
|
||||
|
||||
if (header.version.toKotlinLong() < DatabaseHeaderKDBX.FILE_VERSION_32_4.toKotlinLong()) {
|
||||
@@ -420,41 +464,58 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
|
||||
writeObject(name, String(Base64.encode(data, BASE_64_FLAG)))
|
||||
}
|
||||
|
||||
/*
|
||||
// Normally used by a single entry but obsolete because binaries are in meta tag with kdbx3.1-
|
||||
// or in file header with kdbx4
|
||||
// binary.isProtected attribute is not used to create the XML
|
||||
@Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class)
|
||||
private fun writeBinary(binary : BinaryAttachment) {
|
||||
val binaryLength = binary.length()
|
||||
if (binaryLength > 0) {
|
||||
private fun writeEntryBinary(binary : BinaryAttachment) {
|
||||
if (binary.length() > 0) {
|
||||
if (binary.isProtected) {
|
||||
xml.attribute(null, DatabaseKDBXXML.AttrProtected, DatabaseKDBXXML.ValTrue)
|
||||
|
||||
binary.getInputDataStream().readBytes(BUFFER_SIZE_BYTES) { buffer ->
|
||||
val encoded = ByteArray(buffer.size)
|
||||
randomStream!!.processBytes(buffer, 0, encoded.size, encoded, 0)
|
||||
val charArray = String(Base64.encode(encoded, BASE_64_FLAG)).toCharArray()
|
||||
xml.text(charArray, 0, charArray.size)
|
||||
binary.getInputDataStream().use { inputStream ->
|
||||
inputStream.readBytes { buffer ->
|
||||
val encoded = ByteArray(buffer.size)
|
||||
randomStream!!.processBytes(buffer, 0, encoded.size, encoded, 0)
|
||||
xml.text(String(Base64.encode(encoded, BASE_64_FLAG)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (binary.isCompressed) {
|
||||
xml.attribute(null, DatabaseKDBXXML.AttrCompressed, DatabaseKDBXXML.ValTrue)
|
||||
}
|
||||
// Write the XML
|
||||
binary.getInputDataStream().readBytes(BUFFER_SIZE_BYTES) { buffer ->
|
||||
val charArray = String(Base64.encode(buffer, BASE_64_FLAG)).toCharArray()
|
||||
xml.text(charArray, 0, charArray.size)
|
||||
binary.getInputDataStream().use { inputStream ->
|
||||
inputStream.readBytes { buffer ->
|
||||
xml.text(String(Base64.encode(buffer, BASE_64_FLAG)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Only uses with kdbx3.1 to write binaries in meta tag
|
||||
// With kdbx4, don't use this method because binaries are in header file
|
||||
@Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class)
|
||||
private fun writeMetaBinaries() {
|
||||
xml.startTag(null, DatabaseKDBXXML.ElemBinaries)
|
||||
|
||||
// Use indexes because necessarily in DatabaseV4 (binary header ref is the order)
|
||||
// Use indexes because necessarily (binary header ref is the order)
|
||||
mDatabaseKDBX.binaryPool.doForEachOrderedBinary { index, keyBinary ->
|
||||
xml.startTag(null, DatabaseKDBXXML.ElemBinary)
|
||||
xml.attribute(null, DatabaseKDBXXML.AttrId, index.toString())
|
||||
writeBinary(keyBinary.binary)
|
||||
val binary = keyBinary.binary
|
||||
if (binary.length > 0) {
|
||||
if (binary.isCompressed) {
|
||||
xml.attribute(null, DatabaseKDBXXML.AttrCompressed, DatabaseKDBXXML.ValTrue)
|
||||
}
|
||||
// Write the XML
|
||||
val binaryCipherKey = mDatabaseKDBX.loadedCipherKey
|
||||
?: throw IOException("Unable to retrieve cipher key to write binaries")
|
||||
binary.getInputDataStream(binaryCipherKey).use { inputStream ->
|
||||
inputStream.readAllBytes { buffer ->
|
||||
xml.text(String(Base64.encode(buffer, BASE_64_FLAG)))
|
||||
}
|
||||
}
|
||||
}
|
||||
xml.endTag(null, DatabaseKDBXXML.ElemBinary)
|
||||
}
|
||||
|
||||
@@ -523,17 +584,15 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
|
||||
|
||||
if (protect) {
|
||||
xml.attribute(null, DatabaseKDBXXML.AttrProtected, DatabaseKDBXXML.ValTrue)
|
||||
|
||||
val data = value.toString().toByteArray(charset("UTF-8"))
|
||||
val valLength = data.size
|
||||
|
||||
if (valLength > 0) {
|
||||
val encoded = ByteArray(valLength)
|
||||
randomStream!!.processBytes(data, 0, valLength, encoded, 0)
|
||||
val data = value.toString().toByteArray()
|
||||
val dataLength = data.size
|
||||
if (data.isNotEmpty()) {
|
||||
val encoded = ByteArray(dataLength)
|
||||
randomStream!!.processBytes(data, 0, dataLength, encoded, 0)
|
||||
xml.text(String(Base64.encode(encoded, BASE_64_FLAG)))
|
||||
}
|
||||
} else {
|
||||
xml.text(safeXmlString(value.toString()))
|
||||
xml.text(value.toString())
|
||||
}
|
||||
|
||||
xml.endTag(null, DatabaseKDBXXML.ElemValue)
|
||||
@@ -662,17 +721,19 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
|
||||
if (text.isEmpty()) {
|
||||
return text
|
||||
}
|
||||
|
||||
val stringBuilder = StringBuilder()
|
||||
var ch: Char
|
||||
var character: Char
|
||||
for (element in text) {
|
||||
ch = element
|
||||
character = element
|
||||
val hexChar = character.toInt()
|
||||
if (
|
||||
ch.toInt() in 0x20..0xD7FF ||
|
||||
ch.toInt() == 0x9 || ch.toInt() == 0xA || ch.toInt() == 0xD ||
|
||||
ch.toInt() in 0xE000..0xFFFD
|
||||
hexChar in 0x20..0xD7FF ||
|
||||
hexChar == 0x9 ||
|
||||
hexChar == 0xA ||
|
||||
hexChar == 0xD ||
|
||||
hexChar in 0xE000..0xFFFD
|
||||
) {
|
||||
stringBuilder.append(ch)
|
||||
stringBuilder.append(character)
|
||||
}
|
||||
}
|
||||
return stringBuilder.toString()
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.database.file.output
|
||||
|
||||
import com.kunzisoft.keepass.database.element.database.DatabaseKDB
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.entry.EntryKDB
|
||||
import com.kunzisoft.keepass.database.file.output.GroupOutputKDB.Companion.GROUPID_FIELD_SIZE
|
||||
import com.kunzisoft.keepass.database.exception.DatabaseOutputException
|
||||
import com.kunzisoft.keepass.stream.*
|
||||
import com.kunzisoft.keepass.utils.StringDatabaseKDBUtils
|
||||
import com.kunzisoft.keepass.utils.UnsignedInt
|
||||
@@ -29,96 +29,91 @@ import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.nio.charset.Charset
|
||||
|
||||
class EntryOutputKDB
|
||||
/**
|
||||
* Output the GroupKDB to the stream
|
||||
*/
|
||||
(private val mEntry: EntryKDB, private val mOutputStream: OutputStream) {
|
||||
/**
|
||||
* Returns the number of bytes written by the stream
|
||||
* @return Number of bytes written
|
||||
*/
|
||||
var length: Long = 0
|
||||
private set
|
||||
class EntryOutputKDB(private val mEntry: EntryKDB,
|
||||
private val mOutputStream: OutputStream,
|
||||
private val mCipherKey: Database.LoadedKey) {
|
||||
|
||||
//NOTE: Need be to careful about using ints. The actual type written to file is a unsigned int
|
||||
@Throws(IOException::class)
|
||||
@Throws(DatabaseOutputException::class)
|
||||
fun output() {
|
||||
try {
|
||||
// UUID
|
||||
mOutputStream.write(UUID_FIELD_TYPE)
|
||||
mOutputStream.write(UUID_FIELD_SIZE)
|
||||
mOutputStream.write(uuidTo16Bytes(mEntry.id))
|
||||
|
||||
length += 134 // Length of fixed size fields
|
||||
// Group ID
|
||||
mOutputStream.write(GROUPID_FIELD_TYPE)
|
||||
mOutputStream.write(GROUPID_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mEntry.parent!!.id)))
|
||||
|
||||
// UUID
|
||||
mOutputStream.write(UUID_FIELD_TYPE)
|
||||
mOutputStream.write(UUID_FIELD_SIZE)
|
||||
mOutputStream.write(uuidTo16Bytes(mEntry.id))
|
||||
// Image ID
|
||||
mOutputStream.write(IMAGEID_FIELD_TYPE)
|
||||
mOutputStream.write(IMAGEID_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mEntry.icon.iconId)))
|
||||
|
||||
// Group ID
|
||||
mOutputStream.write(GROUPID_FIELD_TYPE)
|
||||
mOutputStream.write(GROUPID_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mEntry.parent!!.id)))
|
||||
// Title
|
||||
//byte[] title = mEntry.title.getBytes("UTF-8");
|
||||
mOutputStream.write(TITLE_FIELD_TYPE)
|
||||
StringDatabaseKDBUtils.writeStringToStream(mOutputStream, mEntry.title)
|
||||
|
||||
// Image ID
|
||||
mOutputStream.write(IMAGEID_FIELD_TYPE)
|
||||
mOutputStream.write(IMAGEID_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mEntry.icon.iconId)))
|
||||
// URL
|
||||
mOutputStream.write(URL_FIELD_TYPE)
|
||||
StringDatabaseKDBUtils.writeStringToStream(mOutputStream, mEntry.url)
|
||||
|
||||
// Title
|
||||
//byte[] title = mEntry.title.getBytes("UTF-8");
|
||||
mOutputStream.write(TITLE_FIELD_TYPE)
|
||||
length += StringDatabaseKDBUtils.writeStringToBytes(mEntry.title, mOutputStream).toLong()
|
||||
// Username
|
||||
mOutputStream.write(USERNAME_FIELD_TYPE)
|
||||
StringDatabaseKDBUtils.writeStringToStream(mOutputStream, mEntry.username)
|
||||
|
||||
// URL
|
||||
mOutputStream.write(URL_FIELD_TYPE)
|
||||
length += StringDatabaseKDBUtils.writeStringToBytes(mEntry.url, mOutputStream).toLong()
|
||||
// Password
|
||||
mOutputStream.write(PASSWORD_FIELD_TYPE)
|
||||
writePassword(mEntry.password, mOutputStream)
|
||||
|
||||
// Username
|
||||
mOutputStream.write(USERNAME_FIELD_TYPE)
|
||||
length += StringDatabaseKDBUtils.writeStringToBytes(mEntry.username, mOutputStream).toLong()
|
||||
// Additional
|
||||
mOutputStream.write(ADDITIONAL_FIELD_TYPE)
|
||||
StringDatabaseKDBUtils.writeStringToStream(mOutputStream, mEntry.notes)
|
||||
|
||||
// Password
|
||||
mOutputStream.write(PASSWORD_FIELD_TYPE)
|
||||
length += writePassword(mEntry.password, mOutputStream).toLong()
|
||||
// Create date
|
||||
writeDate(CREATE_FIELD_TYPE, dateTo5Bytes(mEntry.creationTime.date))
|
||||
|
||||
// Additional
|
||||
mOutputStream.write(ADDITIONAL_FIELD_TYPE)
|
||||
length += StringDatabaseKDBUtils.writeStringToBytes(mEntry.notes, mOutputStream).toLong()
|
||||
// Modification date
|
||||
writeDate(MOD_FIELD_TYPE, dateTo5Bytes(mEntry.lastModificationTime.date))
|
||||
|
||||
// Create date
|
||||
writeDate(CREATE_FIELD_TYPE, dateTo5Bytes(mEntry.creationTime.date))
|
||||
// Access date
|
||||
writeDate(ACCESS_FIELD_TYPE, dateTo5Bytes(mEntry.lastAccessTime.date))
|
||||
|
||||
// Modification date
|
||||
writeDate(MOD_FIELD_TYPE, dateTo5Bytes(mEntry.lastModificationTime.date))
|
||||
// Expiration date
|
||||
writeDate(EXPIRE_FIELD_TYPE, dateTo5Bytes(mEntry.expiryTime.date))
|
||||
|
||||
// Access date
|
||||
writeDate(ACCESS_FIELD_TYPE, dateTo5Bytes(mEntry.lastAccessTime.date))
|
||||
// Binary description
|
||||
mOutputStream.write(BINARY_DESC_FIELD_TYPE)
|
||||
StringDatabaseKDBUtils.writeStringToStream(mOutputStream, mEntry.binaryDescription)
|
||||
|
||||
// Expiration date
|
||||
writeDate(EXPIRE_FIELD_TYPE, dateTo5Bytes(mEntry.expiryTime.date))
|
||||
|
||||
// Binary description
|
||||
mOutputStream.write(BINARY_DESC_FIELD_TYPE)
|
||||
length += StringDatabaseKDBUtils.writeStringToBytes(mEntry.binaryDescription, mOutputStream).toLong()
|
||||
|
||||
// Binary
|
||||
mOutputStream.write(BINARY_DATA_FIELD_TYPE)
|
||||
val binaryData = mEntry.binaryData
|
||||
val binaryDataLength = binaryData?.length() ?: 0L
|
||||
// Write data length
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt.fromKotlinLong(binaryDataLength)))
|
||||
// Write data
|
||||
if (binaryDataLength > 0) {
|
||||
binaryData?.getInputDataStream().use { inputStream ->
|
||||
inputStream?.readBytes(DatabaseKDB.BUFFER_SIZE_BYTES) { buffer ->
|
||||
length += buffer.size
|
||||
mOutputStream.write(buffer)
|
||||
// Binary
|
||||
mOutputStream.write(BINARY_DATA_FIELD_TYPE)
|
||||
val binaryData = mEntry.binaryData
|
||||
val binaryDataLength = binaryData?.length ?: 0L
|
||||
// Write data length
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt.fromKotlinLong(binaryDataLength)))
|
||||
// Write data
|
||||
if (binaryDataLength > 0) {
|
||||
binaryData?.getInputDataStream(mCipherKey).use { inputStream ->
|
||||
inputStream?.readAllBytes { buffer ->
|
||||
mOutputStream.write(buffer)
|
||||
}
|
||||
inputStream?.close()
|
||||
}
|
||||
inputStream?.close()
|
||||
}
|
||||
}
|
||||
|
||||
// End
|
||||
mOutputStream.write(END_FIELD_TYPE)
|
||||
mOutputStream.write(ZERO_FIELD_SIZE)
|
||||
// End
|
||||
mOutputStream.write(END_FIELD_TYPE)
|
||||
mOutputStream.write(ZERO_FIELD_SIZE)
|
||||
} catch (e: IOException) {
|
||||
throw DatabaseOutputException("Failed to output an entry.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
@@ -144,29 +139,27 @@ class EntryOutputKDB
|
||||
|
||||
companion object {
|
||||
// Constants
|
||||
val UUID_FIELD_TYPE:ByteArray = uShortTo2Bytes(1)
|
||||
val GROUPID_FIELD_TYPE:ByteArray = uShortTo2Bytes(2)
|
||||
val IMAGEID_FIELD_TYPE:ByteArray = uShortTo2Bytes(3)
|
||||
val TITLE_FIELD_TYPE:ByteArray = uShortTo2Bytes(4)
|
||||
val URL_FIELD_TYPE:ByteArray = uShortTo2Bytes(5)
|
||||
val USERNAME_FIELD_TYPE:ByteArray = uShortTo2Bytes(6)
|
||||
val PASSWORD_FIELD_TYPE:ByteArray = uShortTo2Bytes(7)
|
||||
val ADDITIONAL_FIELD_TYPE:ByteArray = uShortTo2Bytes(8)
|
||||
val CREATE_FIELD_TYPE:ByteArray = uShortTo2Bytes(9)
|
||||
val MOD_FIELD_TYPE:ByteArray = uShortTo2Bytes(10)
|
||||
val ACCESS_FIELD_TYPE:ByteArray = uShortTo2Bytes(11)
|
||||
val EXPIRE_FIELD_TYPE:ByteArray = uShortTo2Bytes(12)
|
||||
val BINARY_DESC_FIELD_TYPE:ByteArray = uShortTo2Bytes(13)
|
||||
val BINARY_DATA_FIELD_TYPE:ByteArray = uShortTo2Bytes(14)
|
||||
val END_FIELD_TYPE:ByteArray = uShortTo2Bytes(0xFFFF)
|
||||
private val UUID_FIELD_TYPE:ByteArray = uShortTo2Bytes(1)
|
||||
private val GROUPID_FIELD_TYPE:ByteArray = uShortTo2Bytes(2)
|
||||
private val IMAGEID_FIELD_TYPE:ByteArray = uShortTo2Bytes(3)
|
||||
private val TITLE_FIELD_TYPE:ByteArray = uShortTo2Bytes(4)
|
||||
private val URL_FIELD_TYPE:ByteArray = uShortTo2Bytes(5)
|
||||
private val USERNAME_FIELD_TYPE:ByteArray = uShortTo2Bytes(6)
|
||||
private val PASSWORD_FIELD_TYPE:ByteArray = uShortTo2Bytes(7)
|
||||
private val ADDITIONAL_FIELD_TYPE:ByteArray = uShortTo2Bytes(8)
|
||||
private val CREATE_FIELD_TYPE:ByteArray = uShortTo2Bytes(9)
|
||||
private val MOD_FIELD_TYPE:ByteArray = uShortTo2Bytes(10)
|
||||
private val ACCESS_FIELD_TYPE:ByteArray = uShortTo2Bytes(11)
|
||||
private val EXPIRE_FIELD_TYPE:ByteArray = uShortTo2Bytes(12)
|
||||
private val BINARY_DESC_FIELD_TYPE:ByteArray = uShortTo2Bytes(13)
|
||||
private val BINARY_DATA_FIELD_TYPE:ByteArray = uShortTo2Bytes(14)
|
||||
private val END_FIELD_TYPE:ByteArray = uShortTo2Bytes(0xFFFF)
|
||||
|
||||
val LONG_FOUR:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
val UUID_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(16))
|
||||
val DATE_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(5))
|
||||
val IMAGEID_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
val LEVEL_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
val FLAGS_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
val ZERO_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(0))
|
||||
val ZERO_FIVE:ByteArray = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00)
|
||||
private val UUID_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(16))
|
||||
private val GROUPID_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
private val DATE_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(5))
|
||||
private val IMAGEID_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
private val ZERO_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(0))
|
||||
private val ZERO_FIVE:ByteArray = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
package com.kunzisoft.keepass.database.file.output
|
||||
|
||||
import com.kunzisoft.keepass.database.element.group.GroupKDB
|
||||
import com.kunzisoft.keepass.database.exception.DatabaseOutputException
|
||||
import com.kunzisoft.keepass.stream.dateTo5Bytes
|
||||
import com.kunzisoft.keepass.stream.uIntTo4Bytes
|
||||
import com.kunzisoft.keepass.stream.uShortTo2Bytes
|
||||
@@ -31,79 +32,84 @@ import java.io.OutputStream
|
||||
/**
|
||||
* Output the GroupKDB to the stream
|
||||
*/
|
||||
class GroupOutputKDB (private val mGroup: GroupKDB, private val mOutputStream: OutputStream) {
|
||||
class GroupOutputKDB(private val mGroup: GroupKDB,
|
||||
private val mOutputStream: OutputStream) {
|
||||
|
||||
@Throws(IOException::class)
|
||||
@Throws(DatabaseOutputException::class)
|
||||
fun output() {
|
||||
//NOTE: Need be to careful about using ints. The actual type written to file is a unsigned int, but most values can't be greater than 2^31, so it probably doesn't matter.
|
||||
try {
|
||||
//NOTE: Need be to careful about using ints. The actual type written to file is a unsigned int, but most values can't be greater than 2^31, so it probably doesn't matter.
|
||||
|
||||
// Group ID
|
||||
mOutputStream.write(GROUPID_FIELD_TYPE)
|
||||
mOutputStream.write(GROUPID_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mGroup.id)))
|
||||
// Group ID
|
||||
mOutputStream.write(GROUPID_FIELD_TYPE)
|
||||
mOutputStream.write(GROUPID_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mGroup.id)))
|
||||
|
||||
// Name
|
||||
mOutputStream.write(NAME_FIELD_TYPE)
|
||||
StringDatabaseKDBUtils.writeStringToBytes(mGroup.title, mOutputStream)
|
||||
// Name
|
||||
mOutputStream.write(NAME_FIELD_TYPE)
|
||||
StringDatabaseKDBUtils.writeStringToStream(mOutputStream, mGroup.title)
|
||||
|
||||
// Create date
|
||||
mOutputStream.write(CREATE_FIELD_TYPE)
|
||||
mOutputStream.write(DATE_FIELD_SIZE)
|
||||
mOutputStream.write(dateTo5Bytes(mGroup.creationTime.date))
|
||||
// Create date
|
||||
mOutputStream.write(CREATE_FIELD_TYPE)
|
||||
mOutputStream.write(DATE_FIELD_SIZE)
|
||||
mOutputStream.write(dateTo5Bytes(mGroup.creationTime.date))
|
||||
|
||||
// Modification date
|
||||
mOutputStream.write(MOD_FIELD_TYPE)
|
||||
mOutputStream.write(DATE_FIELD_SIZE)
|
||||
mOutputStream.write(dateTo5Bytes(mGroup.lastModificationTime.date))
|
||||
// Modification date
|
||||
mOutputStream.write(MOD_FIELD_TYPE)
|
||||
mOutputStream.write(DATE_FIELD_SIZE)
|
||||
mOutputStream.write(dateTo5Bytes(mGroup.lastModificationTime.date))
|
||||
|
||||
// Access date
|
||||
mOutputStream.write(ACCESS_FIELD_TYPE)
|
||||
mOutputStream.write(DATE_FIELD_SIZE)
|
||||
mOutputStream.write(dateTo5Bytes(mGroup.lastAccessTime.date))
|
||||
// Access date
|
||||
mOutputStream.write(ACCESS_FIELD_TYPE)
|
||||
mOutputStream.write(DATE_FIELD_SIZE)
|
||||
mOutputStream.write(dateTo5Bytes(mGroup.lastAccessTime.date))
|
||||
|
||||
// Expiration date
|
||||
mOutputStream.write(EXPIRE_FIELD_TYPE)
|
||||
mOutputStream.write(DATE_FIELD_SIZE)
|
||||
mOutputStream.write(dateTo5Bytes(mGroup.expiryTime.date))
|
||||
// Expiration date
|
||||
mOutputStream.write(EXPIRE_FIELD_TYPE)
|
||||
mOutputStream.write(DATE_FIELD_SIZE)
|
||||
mOutputStream.write(dateTo5Bytes(mGroup.expiryTime.date))
|
||||
|
||||
// Image ID
|
||||
mOutputStream.write(IMAGEID_FIELD_TYPE)
|
||||
mOutputStream.write(IMAGEID_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mGroup.icon.iconId)))
|
||||
// Image ID
|
||||
mOutputStream.write(IMAGEID_FIELD_TYPE)
|
||||
mOutputStream.write(IMAGEID_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mGroup.icon.iconId)))
|
||||
|
||||
// Level
|
||||
mOutputStream.write(LEVEL_FIELD_TYPE)
|
||||
mOutputStream.write(LEVEL_FIELD_SIZE)
|
||||
mOutputStream.write(uShortTo2Bytes(mGroup.level))
|
||||
// Level
|
||||
mOutputStream.write(LEVEL_FIELD_TYPE)
|
||||
mOutputStream.write(LEVEL_FIELD_SIZE)
|
||||
mOutputStream.write(uShortTo2Bytes(mGroup.level))
|
||||
|
||||
// Flags
|
||||
mOutputStream.write(FLAGS_FIELD_TYPE)
|
||||
mOutputStream.write(FLAGS_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mGroup.groupFlags)))
|
||||
// Flags
|
||||
mOutputStream.write(FLAGS_FIELD_TYPE)
|
||||
mOutputStream.write(FLAGS_FIELD_SIZE)
|
||||
mOutputStream.write(uIntTo4Bytes(UnsignedInt(mGroup.groupFlags)))
|
||||
|
||||
// End
|
||||
mOutputStream.write(END_FIELD_TYPE)
|
||||
mOutputStream.write(ZERO_FIELD_SIZE)
|
||||
// End
|
||||
mOutputStream.write(END_FIELD_TYPE)
|
||||
mOutputStream.write(ZERO_FIELD_SIZE)
|
||||
} catch (e: IOException) {
|
||||
throw DatabaseOutputException("Failed to output a group.", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Constants
|
||||
val GROUPID_FIELD_TYPE: ByteArray = uShortTo2Bytes(1)
|
||||
val NAME_FIELD_TYPE:ByteArray = uShortTo2Bytes(2)
|
||||
val CREATE_FIELD_TYPE:ByteArray = uShortTo2Bytes(3)
|
||||
val MOD_FIELD_TYPE:ByteArray = uShortTo2Bytes(4)
|
||||
val ACCESS_FIELD_TYPE:ByteArray = uShortTo2Bytes(5)
|
||||
val EXPIRE_FIELD_TYPE:ByteArray = uShortTo2Bytes(6)
|
||||
val IMAGEID_FIELD_TYPE:ByteArray = uShortTo2Bytes(7)
|
||||
val LEVEL_FIELD_TYPE:ByteArray = uShortTo2Bytes(8)
|
||||
val FLAGS_FIELD_TYPE:ByteArray = uShortTo2Bytes(9)
|
||||
val END_FIELD_TYPE:ByteArray = uShortTo2Bytes(0xFFFF)
|
||||
private val GROUPID_FIELD_TYPE: ByteArray = uShortTo2Bytes(1)
|
||||
private val NAME_FIELD_TYPE:ByteArray = uShortTo2Bytes(2)
|
||||
private val CREATE_FIELD_TYPE:ByteArray = uShortTo2Bytes(3)
|
||||
private val MOD_FIELD_TYPE:ByteArray = uShortTo2Bytes(4)
|
||||
private val ACCESS_FIELD_TYPE:ByteArray = uShortTo2Bytes(5)
|
||||
private val EXPIRE_FIELD_TYPE:ByteArray = uShortTo2Bytes(6)
|
||||
private val IMAGEID_FIELD_TYPE:ByteArray = uShortTo2Bytes(7)
|
||||
private val LEVEL_FIELD_TYPE:ByteArray = uShortTo2Bytes(8)
|
||||
private val FLAGS_FIELD_TYPE:ByteArray = uShortTo2Bytes(9)
|
||||
private val END_FIELD_TYPE:ByteArray = uShortTo2Bytes(0xFFFF)
|
||||
|
||||
val GROUPID_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
val DATE_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(5))
|
||||
val IMAGEID_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
val LEVEL_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(2))
|
||||
val FLAGS_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
val ZERO_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(0))
|
||||
private val GROUPID_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
private val DATE_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(5))
|
||||
private val IMAGEID_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
private val LEVEL_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(2))
|
||||
private val FLAGS_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(4))
|
||||
private val ZERO_FIELD_SIZE:ByteArray = uIntTo4Bytes(UnsignedInt(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,8 +86,8 @@ class PasswordActivityEducation(activity: Activity)
|
||||
onOuterViewClick: ((TapTargetView?) -> Unit)? = null): Boolean {
|
||||
return checkAndPerformedEducation(!isEducationBiometricPerformed(activity),
|
||||
TapTarget.forView(educationView,
|
||||
activity.getString(R.string.education_biometric_title),
|
||||
activity.getString(R.string.education_biometric_summary))
|
||||
activity.getString(R.string.education_advanced_unlock_title),
|
||||
activity.getString(R.string.education_advanced_unlock_summary))
|
||||
.textColorInt(Color.WHITE)
|
||||
.tintTarget(false)
|
||||
.cancelable(true),
|
||||
|
||||
@@ -26,9 +26,12 @@ import android.graphics.*
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.ImageView
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
@@ -87,6 +90,22 @@ class IconDrawableFactory {
|
||||
remoteViews.setImageViewBitmap(imageId, bitmap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to assign a drawable to a icon and tint it
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun assignDrawableToIcon(superDrawable: SuperDrawable,
|
||||
tintColor: Int = Color.BLACK): Icon {
|
||||
val bitmap = superDrawable.drawable.toBitmap()
|
||||
// Tint bitmap if it's not a custom icon
|
||||
if (superDrawable.tintable && bitmap.isMutable) {
|
||||
Canvas(bitmap).drawBitmap(bitmap, 0.0F, 0.0F, Paint().apply {
|
||||
colorFilter = PorterDuffColorFilter(tintColor, PorterDuff.Mode.SRC_IN)
|
||||
})
|
||||
}
|
||||
return Icon.createWithBitmap(bitmap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the [SuperDrawable] [icon] (from cache, or build it and add it to the cache if not exists yet), then [tint] it with [tintColor] if needed
|
||||
*/
|
||||
@@ -309,3 +328,22 @@ fun RemoteViews.assignDatabaseIcon(context: Context,
|
||||
Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun createIconFromDatabaseIcon(context: Context,
|
||||
iconFactory: IconDrawableFactory,
|
||||
icon: IconImage,
|
||||
tintColor: Int = Color.BLACK): Icon? {
|
||||
try {
|
||||
return iconFactory.assignDrawableToIcon(
|
||||
iconFactory.getIconSuperDrawable(context,
|
||||
icon,
|
||||
24,
|
||||
true,
|
||||
tintColor),
|
||||
tintColor)
|
||||
} catch (e: Exception) {
|
||||
Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ import com.kunzisoft.keepass.adapters.FieldsAdapter
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.model.EntryInfo
|
||||
import com.kunzisoft.keepass.model.Field
|
||||
import com.kunzisoft.keepass.notifications.KeyboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.services.KeyboardEntryNotificationService
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.utils.*
|
||||
|
||||
@@ -243,6 +244,14 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
||||
if (entryInfoKey != null) {
|
||||
currentInputConnection.commitText(entryInfoKey!!.password, 1)
|
||||
}
|
||||
val otpFieldExists = entryInfoKey?.containsCustomField(OTP_TOKEN_FIELD) ?: false
|
||||
actionGoAutomatically(!otpFieldExists)
|
||||
}
|
||||
KEY_OTP -> {
|
||||
if (entryInfoKey != null) {
|
||||
currentInputConnection.commitText(
|
||||
entryInfoKey!!.getGeneratedFieldValue(OTP_TOKEN_FIELD), 1)
|
||||
}
|
||||
actionGoAutomatically()
|
||||
}
|
||||
KEY_URL -> {
|
||||
@@ -254,7 +263,7 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
||||
KEY_FIELDS -> {
|
||||
if (entryInfoKey != null) {
|
||||
fieldsAdapter?.apply {
|
||||
setFields(entryInfoKey!!.customFields)
|
||||
setFields(entryInfoKey!!.customFields.filter { it.name != OTP_TOKEN_FIELD})
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -272,10 +281,11 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
||||
currentInputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_TAB))
|
||||
}
|
||||
|
||||
private fun actionGoAutomatically() {
|
||||
private fun actionGoAutomatically(switchToPreviousKeyboardIfAllowed: Boolean = true) {
|
||||
if (PreferencesUtil.isAutoGoActionEnable(this)) {
|
||||
currentInputConnection.performEditorAction(EditorInfo.IME_ACTION_GO)
|
||||
if (PreferencesUtil.isKeyboardPreviousFillInEnable(this)) {
|
||||
if (switchToPreviousKeyboardIfAllowed
|
||||
&& PreferencesUtil.isKeyboardPreviousFillInEnable(this)) {
|
||||
switchToPreviousKeyboard()
|
||||
}
|
||||
}
|
||||
@@ -326,6 +336,7 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
||||
private const val KEY_ENTRY = 620
|
||||
private const val KEY_USERNAME = 500
|
||||
private const val KEY_PASSWORD = 510
|
||||
private const val KEY_OTP = 515
|
||||
private const val KEY_URL = 520
|
||||
private const val KEY_FIELDS = 530
|
||||
|
||||
|
||||
@@ -29,19 +29,22 @@ import com.kunzisoft.keepass.utils.writeEnum
|
||||
data class EntryAttachmentState(var attachment: Attachment,
|
||||
var streamDirection: StreamDirection,
|
||||
var downloadState: AttachmentState = AttachmentState.NULL,
|
||||
var downloadProgression: Int = 0) : Parcelable {
|
||||
var downloadProgression: Int = 0,
|
||||
var previewState: AttachmentState = AttachmentState.NULL) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readParcelable(Attachment::class.java.classLoader) ?: Attachment("", BinaryAttachment()),
|
||||
parcel.readEnum<StreamDirection>() ?: StreamDirection.DOWNLOAD,
|
||||
parcel.readEnum<AttachmentState>() ?: AttachmentState.NULL,
|
||||
parcel.readInt())
|
||||
parcel.readInt(),
|
||||
parcel.readEnum<AttachmentState>() ?: AttachmentState.NULL)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeParcelable(attachment, flags)
|
||||
parcel.writeEnum(streamDirection)
|
||||
parcel.writeEnum(downloadState)
|
||||
parcel.writeInt(downloadProgression)
|
||||
parcel.writeEnum(previewState)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
@@ -73,5 +76,5 @@ data class EntryAttachmentState(var attachment: Attachment,
|
||||
}
|
||||
|
||||
enum class AttachmentState {
|
||||
NULL, START, IN_PROGRESS, COMPLETE, ERROR
|
||||
NULL, START, IN_PROGRESS, COMPLETE, CANCELED, ERROR
|
||||
}
|
||||
@@ -23,40 +23,28 @@ import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.kunzisoft.keepass.database.element.Attachment
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.DateInstant
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImage
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImageStandard
|
||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||
import com.kunzisoft.keepass.otp.OtpElement
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class EntryInfo : Parcelable {
|
||||
class EntryInfo : NodeInfo {
|
||||
|
||||
var id: String = ""
|
||||
var title: String = ""
|
||||
var icon: IconImage = IconImageStandard()
|
||||
var username: String = ""
|
||||
var password: String = ""
|
||||
var expires: Boolean = false
|
||||
var expiryTime: DateInstant = DateInstant.IN_ONE_MONTH
|
||||
var url: String = ""
|
||||
var notes: String = ""
|
||||
var customFields: List<Field> = ArrayList()
|
||||
var attachments: List<Attachment> = ArrayList()
|
||||
var otpModel: OtpModel? = null
|
||||
|
||||
constructor()
|
||||
constructor(): super()
|
||||
|
||||
private constructor(parcel: Parcel) {
|
||||
constructor(parcel: Parcel): super(parcel) {
|
||||
id = parcel.readString() ?: id
|
||||
title = parcel.readString() ?: title
|
||||
icon = parcel.readParcelable(IconImage::class.java.classLoader) ?: icon
|
||||
username = parcel.readString() ?: username
|
||||
password = parcel.readString() ?: password
|
||||
expires = parcel.readInt() != 0
|
||||
expiryTime = parcel.readParcelable(DateInstant::class.java.classLoader) ?: expiryTime
|
||||
url = parcel.readString() ?: url
|
||||
notes = parcel.readString() ?: notes
|
||||
parcel.readList(customFields, Field::class.java.classLoader)
|
||||
@@ -69,13 +57,10 @@ class EntryInfo : Parcelable {
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
super.writeToParcel(parcel, flags)
|
||||
parcel.writeString(id)
|
||||
parcel.writeString(title)
|
||||
parcel.writeParcelable(icon, flags)
|
||||
parcel.writeString(username)
|
||||
parcel.writeString(password)
|
||||
parcel.writeInt(if (expires) 1 else 0)
|
||||
parcel.writeParcelable(expiryTime, flags)
|
||||
parcel.writeString(url)
|
||||
parcel.writeString(notes)
|
||||
parcel.writeArray(customFields.toTypedArray())
|
||||
@@ -91,13 +76,13 @@ class EntryInfo : Parcelable {
|
||||
return customFields.any { !it.protectedValue.isProtected }
|
||||
}
|
||||
|
||||
fun isAutoGeneratedField(field: Field): Boolean {
|
||||
return field.name == OTP_TOKEN_FIELD
|
||||
fun containsCustomField(label: String): Boolean {
|
||||
return customFields.lastOrNull { it.name == label } != null
|
||||
}
|
||||
|
||||
fun getGeneratedFieldValue(label: String): String {
|
||||
otpModel?.let {
|
||||
if (label == OTP_TOKEN_FIELD) {
|
||||
if (label == OTP_TOKEN_FIELD) {
|
||||
otpModel?.let {
|
||||
return OtpElement(it).token
|
||||
}
|
||||
}
|
||||
|
||||
36
app/src/main/java/com/kunzisoft/keepass/model/GroupInfo.kt
Normal file
36
app/src/main/java/com/kunzisoft/keepass/model/GroupInfo.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package com.kunzisoft.keepass.model
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImageStandard
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImageStandard.Companion.FOLDER
|
||||
|
||||
class GroupInfo : NodeInfo {
|
||||
|
||||
var notes: String? = null
|
||||
|
||||
init {
|
||||
icon = IconImageStandard(FOLDER)
|
||||
}
|
||||
|
||||
constructor(): super()
|
||||
|
||||
constructor(parcel: Parcel): super(parcel) {
|
||||
notes = parcel.readString()
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
super.writeToParcel(parcel, flags)
|
||||
parcel.writeString(notes)
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<GroupInfo> {
|
||||
override fun createFromParcel(parcel: Parcel): GroupInfo {
|
||||
return GroupInfo(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<GroupInfo?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.kunzisoft.keepass.model
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
|
||||
data class MainCredential(var masterPassword: String? = null, var keyFileUri: Uri? = null): Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readString(),
|
||||
parcel.readParcelable(Uri::class.java.classLoader)) {
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(masterPassword)
|
||||
parcel.writeParcelable(keyFileUri, flags)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<MainCredential> {
|
||||
override fun createFromParcel(parcel: Parcel): MainCredential {
|
||||
return MainCredential(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<MainCredential?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
49
app/src/main/java/com/kunzisoft/keepass/model/NodeInfo.kt
Normal file
49
app/src/main/java/com/kunzisoft/keepass/model/NodeInfo.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.kunzisoft.keepass.model
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.kunzisoft.keepass.database.element.DateInstant
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImage
|
||||
import com.kunzisoft.keepass.database.element.icon.IconImageStandard
|
||||
|
||||
open class NodeInfo() : Parcelable {
|
||||
|
||||
var title: String = ""
|
||||
var icon: IconImage = IconImageStandard()
|
||||
var creationTime: DateInstant = DateInstant()
|
||||
var lastModificationTime: DateInstant = DateInstant()
|
||||
var expires: Boolean = false
|
||||
var expiryTime: DateInstant = DateInstant.IN_ONE_MONTH
|
||||
|
||||
constructor(parcel: Parcel) : this() {
|
||||
title = parcel.readString() ?: title
|
||||
icon = parcel.readParcelable(IconImage::class.java.classLoader) ?: icon
|
||||
creationTime = parcel.readParcelable(DateInstant::class.java.classLoader) ?: creationTime
|
||||
lastModificationTime = parcel.readParcelable(DateInstant::class.java.classLoader) ?: lastModificationTime
|
||||
expires = parcel.readInt() != 0
|
||||
expiryTime = parcel.readParcelable(DateInstant::class.java.classLoader) ?: expiryTime
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(title)
|
||||
parcel.writeParcelable(icon, flags)
|
||||
parcel.writeParcelable(creationTime, flags)
|
||||
parcel.writeParcelable(lastModificationTime, flags)
|
||||
parcel.writeInt(if (expires) 1 else 0)
|
||||
parcel.writeParcelable(expiryTime, flags)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<NodeInfo> {
|
||||
override fun createFromParcel(parcel: Parcel): NodeInfo {
|
||||
return NodeInfo(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<NodeInfo?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.kunzisoft.keepass.model
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.text.format.Formatter
|
||||
import com.kunzisoft.keepass.viewmodels.FileDatabaseInfo
|
||||
import java.text.DateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Utility data class to get FileDatabaseInfo at a `t` time
|
||||
*/
|
||||
data class SnapFileDatabaseInfo(var fileUri: Uri?,
|
||||
var exists: Boolean,
|
||||
var lastModification: Long?,
|
||||
var size: Long?): Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readParcelable(Uri::class.java.classLoader),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readValue(Long::class.java.classLoader) as? Long,
|
||||
parcel.readValue(Long::class.java.classLoader) as? Long) {
|
||||
}
|
||||
|
||||
fun toString(context: Context): String {
|
||||
val lastModificationString = DateFormat.getDateTimeInstance()
|
||||
.format(Date(lastModification ?: 0))
|
||||
return "$lastModificationString, " +
|
||||
Formatter.formatFileSize(context, size ?: 0)
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeParcelable(fileUri, flags)
|
||||
parcel.writeByte(if (exists) 1 else 0)
|
||||
parcel.writeValue(lastModification)
|
||||
parcel.writeValue(size)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is SnapFileDatabaseInfo) return false
|
||||
|
||||
if (fileUri != other.fileUri) return false
|
||||
if (exists != other.exists) return false
|
||||
if (lastModification != other.lastModification) return false
|
||||
if (size != other.size) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = fileUri?.hashCode() ?: 0
|
||||
result = 31 * result + exists.hashCode()
|
||||
result = 31 * result + (lastModification?.hashCode() ?: 0)
|
||||
result = 31 * result + (size?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<SnapFileDatabaseInfo> {
|
||||
override fun createFromParcel(parcel: Parcel): SnapFileDatabaseInfo {
|
||||
return SnapFileDatabaseInfo(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<SnapFileDatabaseInfo?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
|
||||
fun fromFileDatabaseInfo(fileDatabaseInfo: FileDatabaseInfo): SnapFileDatabaseInfo {
|
||||
return SnapFileDatabaseInfo(
|
||||
fileDatabaseInfo.fileUri,
|
||||
fileDatabaseInfo.exists,
|
||||
fileDatabaseInfo.getLastModification(),
|
||||
fileDatabaseInfo.getSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
package com.kunzisoft.keepass.otp
|
||||
|
||||
import com.kunzisoft.keepass.model.OtpModel
|
||||
import com.kunzisoft.keepass.utils.StringUtil.removeSpaceChars
|
||||
import org.apache.commons.codec.binary.Base32
|
||||
import org.apache.commons.codec.binary.Base64
|
||||
import org.apache.commons.codec.binary.Hex
|
||||
@@ -137,7 +138,7 @@ data class OtpElement(var otpModel: OtpModel = OtpModel()) {
|
||||
@Throws(IllegalArgumentException::class)
|
||||
fun setHexSecret(secret: String) {
|
||||
if (secret.isNotEmpty())
|
||||
otpModel.secret = Hex.decodeHex(secret)
|
||||
otpModel.secret = Hex.decodeHex(secret.toCharArray())
|
||||
else
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
@@ -150,16 +151,16 @@ data class OtpElement(var otpModel: OtpModel = OtpModel()) {
|
||||
|
||||
@Throws(IllegalArgumentException::class)
|
||||
fun setBase32Secret(secret: String) {
|
||||
if (isValidBase32(secret))
|
||||
otpModel.secret = Base32().decode(replaceBase32Chars(secret).toByteArray())
|
||||
else
|
||||
if (isValidBase32(secret)) {
|
||||
otpModel.secret = Base32().decode(replaceBase32Chars(secret))
|
||||
} else
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
@Throws(IllegalArgumentException::class)
|
||||
fun setBase64Secret(secret: String) {
|
||||
if (isValidBase64(secret))
|
||||
otpModel.secret = Base64().decode(secret.toByteArray())
|
||||
otpModel.secret = Base64().decode(secret)
|
||||
else
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
@@ -208,38 +209,24 @@ data class OtpElement(var otpModel: OtpModel = OtpModel()) {
|
||||
|
||||
fun isValidBase32(secret: String): Boolean {
|
||||
val secretChars = replaceBase32Chars(secret)
|
||||
return secretChars.isNotEmpty() && checkBase32Secret(secretChars)
|
||||
return secret.isNotEmpty()
|
||||
&& (Pattern.matches("^(?:[A-Z2-7]{8})*(?:[A-Z2-7]{2}=*|[A-Z2-7]{4}=*|[A-Z2-7]{5}=*|[A-Z2-7]{7}=*)?$", secretChars))
|
||||
}
|
||||
|
||||
fun isValidBase64(secret: String): Boolean {
|
||||
// TODO replace base 64 chars
|
||||
return secret.isNotEmpty() && checkBase64Secret(secret)
|
||||
}
|
||||
|
||||
fun removeLineChars(parameter: String): String {
|
||||
return parameter.replace("[\\r|\\n|\\t|\\u00A0]+".toRegex(), "")
|
||||
}
|
||||
|
||||
fun removeSpaceChars(parameter: String): String {
|
||||
return parameter.replace("[\\r|\\n|\\t|\\s|\\u00A0]+".toRegex(), "")
|
||||
return secret.isNotEmpty()
|
||||
&& (Pattern.matches("^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}=*|[A-Za-z0-9+/]{3}=*)?$", secret))
|
||||
}
|
||||
|
||||
fun replaceBase32Chars(parameter: String): String {
|
||||
// Add 'A' at end if not Base32 length
|
||||
var parameterNewSize = removeSpaceChars(parameter.toUpperCase(Locale.ENGLISH))
|
||||
// Add padding '=' at end if not Base32 length
|
||||
var parameterNewSize = parameter.toUpperCase(Locale.ENGLISH).removeSpaceChars()
|
||||
while (parameterNewSize.length % 8 != 0) {
|
||||
parameterNewSize += 'A'
|
||||
parameterNewSize += '='
|
||||
}
|
||||
return parameterNewSize
|
||||
}
|
||||
|
||||
fun checkBase32Secret(secret: String): Boolean {
|
||||
return (Pattern.matches("^(?:[A-Z2-7]{8})*(?:[A-Z2-7]{2}={6}|[A-Z2-7]{4}={4}|[A-Z2-7]{5}={3}|[A-Z2-7]{7}=)?$", secret))
|
||||
}
|
||||
|
||||
fun checkBase64Secret(secret: String): Boolean {
|
||||
return (Pattern.matches("^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", secret))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||
import com.kunzisoft.keepass.model.Field
|
||||
import com.kunzisoft.keepass.otp.OtpElement.Companion.removeLineChars
|
||||
import com.kunzisoft.keepass.otp.OtpElement.Companion.removeSpaceChars
|
||||
import com.kunzisoft.keepass.otp.TokenCalculator.*
|
||||
import com.kunzisoft.keepass.utils.StringUtil.removeLineChars
|
||||
import com.kunzisoft.keepass.utils.StringUtil.removeSpaceChars
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@@ -57,13 +57,25 @@ object OtpEntryFields {
|
||||
private const val DIGITS_KEY = "size"
|
||||
private const val STEP_KEY = "step"
|
||||
|
||||
// HmacOtp KeePass2 values (https://keepass.info/help/base/placeholders.html#hmacotp)
|
||||
// HmacOtp KeePass2 values (https://keepass.info/help/base/placeholders.html#otp)
|
||||
private const val HMACOTP_SECRET_FIELD = "HmacOtp-Secret"
|
||||
private const val HMACOTP_SECRET_HEX_FIELD = "HmacOtp-Secret-Hex"
|
||||
private const val HMACOTP_SECRET_BASE32_FIELD = "HmacOtp-Secret-Base32"
|
||||
private const val HMACOTP_SECRET_BASE64_FIELD = "HmacOtp-Secret-Base64"
|
||||
private const val HMACOTP_SECRET_COUNTER_FIELD = "HmacOtp-Counter"
|
||||
|
||||
// TimeOtp KeePass2 values
|
||||
private const val TIMEOTP_SECRET_FIELD = "TimeOtp-Secret"
|
||||
private const val TIMEOTP_SECRET_HEX_FIELD = "TimeOtp-Secret-Hex"
|
||||
private const val TIMEOTP_SECRET_BASE32_FIELD = "TimeOtp-Secret-Base32"
|
||||
private const val TIMEOTP_SECRET_BASE64_FIELD = "TimeOtp-Secret-Base64"
|
||||
private const val TIMEOTP_LENGTH_FIELD = "TimeOtp-Length"
|
||||
private const val TIMEOTP_PERIOD_FIELD = "TimeOtp-Period"
|
||||
private const val TIMEOTP_ALGORITHM_FIELD = "TimeOtp-Algorithm"
|
||||
private const val TIMEOTP_ALGORITHM_SHA1_VALUE = "HMAC-SHA-1"
|
||||
private const val TIMEOTP_ALGORITHM_SHA256_VALUE = "HMAC-SHA-256"
|
||||
private const val TIMEOTP_ALGORITHM_SHA512_VALUE = "HMAC-SHA-512"
|
||||
|
||||
// Custom fields (maybe from plugin)
|
||||
private const val TOTP_SEED_FIELD = "TOTP Seed"
|
||||
private const val TOTP_SETTING_FIELD = "TOTP Settings"
|
||||
@@ -85,14 +97,17 @@ object OtpEntryFields {
|
||||
// OTP (HOTP/TOTP) from URL and field from KeePassXC
|
||||
if (parseOTPUri(getField, otpElement))
|
||||
return otpElement
|
||||
// TOTP from KeePass 2.47
|
||||
if (parseTOTPFromOfficialField(getField, otpElement))
|
||||
return otpElement
|
||||
// TOTP from key values (maybe plugin or old KeePassXC)
|
||||
if (parseTOTPKeyValues(getField, otpElement))
|
||||
return otpElement
|
||||
// TOTP from custom field
|
||||
if (parseTOTPFromField(getField, otpElement))
|
||||
if (parseTOTPFromPluginField(getField, otpElement))
|
||||
return otpElement
|
||||
// HOTP fields from KeePass 2
|
||||
if (parseHOTPFromField(getField, otpElement))
|
||||
if (parseHOTPFromOfficialField(getField, otpElement))
|
||||
return otpElement
|
||||
return null
|
||||
}
|
||||
@@ -126,7 +141,7 @@ object OtpEntryFields {
|
||||
private fun parseOTPUri(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
val otpPlainText = getField(OTP_FIELD)
|
||||
if (otpPlainText != null && otpPlainText.isNotEmpty() && isOTPUri(otpPlainText)) {
|
||||
val uri = Uri.parse(removeSpaceChars(otpPlainText))
|
||||
val uri = Uri.parse(otpPlainText.removeSpaceChars())
|
||||
|
||||
if (uri.scheme == null || OTP_SCHEME != uri.scheme!!.toLowerCase(Locale.ENGLISH)) {
|
||||
Log.e(TAG, "Invalid or missing scheme in uri")
|
||||
@@ -159,16 +174,16 @@ object OtpEntryFields {
|
||||
if (nameParam != null && nameParam.isNotEmpty()) {
|
||||
val userIdArray = nameParam.split(":", "%3A")
|
||||
if (userIdArray.size > 1) {
|
||||
otpElement.issuer = removeLineChars(userIdArray[0])
|
||||
otpElement.name = removeLineChars(userIdArray[1])
|
||||
otpElement.issuer = userIdArray[0].removeLineChars()
|
||||
otpElement.name = userIdArray[1].removeLineChars()
|
||||
} else {
|
||||
otpElement.name = removeLineChars(nameParam)
|
||||
otpElement.name = nameParam.removeLineChars()
|
||||
}
|
||||
}
|
||||
|
||||
val issuerParam = uri.getQueryParameter(ISSUER_URL_PARAM)
|
||||
if (issuerParam != null && issuerParam.isNotEmpty())
|
||||
otpElement.issuer = removeLineChars(issuerParam)
|
||||
otpElement.issuer = issuerParam.removeLineChars()
|
||||
|
||||
val secretParam = uri.getQueryParameter(SECRET_URL_PARAM)
|
||||
if (secretParam != null && secretParam.isNotEmpty()) {
|
||||
@@ -247,8 +262,9 @@ object OtpEntryFields {
|
||||
encodeParameter(username)
|
||||
else
|
||||
encodeParameter(otpElement.name)
|
||||
val secret = encodeParameter(otpElement.getBase32Secret())
|
||||
val uriString = StringBuilder("otpauth://$otpAuthority/$issuer%3A$accountName" +
|
||||
"?$SECRET_URL_PARAM=${otpElement.getBase32Secret()}" +
|
||||
"?$SECRET_URL_PARAM=${secret}" +
|
||||
"&$counterOrPeriodLabel=$counterOrPeriodValue" +
|
||||
"&$DIGITS_URL_PARAM=${otpElement.digits}" +
|
||||
"&$ISSUER_URL_PARAM=$issuer")
|
||||
@@ -262,7 +278,40 @@ object OtpEntryFields {
|
||||
}
|
||||
|
||||
private fun encodeParameter(parameter: String): String {
|
||||
return Uri.encode(OtpElement.removeLineChars(parameter))
|
||||
return Uri.encode(parameter.removeLineChars())
|
||||
}
|
||||
|
||||
private fun parseTOTPFromOfficialField(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
val secretField = getField(TIMEOTP_SECRET_FIELD)
|
||||
val secretHexField = getField(TIMEOTP_SECRET_HEX_FIELD)
|
||||
val secretBase32Field = getField(TIMEOTP_SECRET_BASE32_FIELD)
|
||||
val secretBase64Field = getField(TIMEOTP_SECRET_BASE64_FIELD)
|
||||
val lengthField = getField(TIMEOTP_LENGTH_FIELD)
|
||||
val periodField = getField(TIMEOTP_PERIOD_FIELD)
|
||||
val algorithmField = getField(TIMEOTP_ALGORITHM_FIELD)
|
||||
try {
|
||||
when {
|
||||
secretField != null -> otpElement.setUTF8Secret(secretField)
|
||||
secretHexField != null -> otpElement.setHexSecret(secretHexField)
|
||||
secretBase32Field != null -> otpElement.setBase32Secret(secretBase32Field)
|
||||
secretBase64Field != null -> otpElement.setBase64Secret(secretBase64Field)
|
||||
lengthField != null -> otpElement.digits = lengthField.toIntOrNull() ?: OTP_DEFAULT_DIGITS
|
||||
periodField != null -> otpElement.period = periodField.toIntOrNull() ?: TOTP_DEFAULT_PERIOD
|
||||
algorithmField != null -> otpElement.algorithm =
|
||||
when (algorithmField.toUpperCase(Locale.ENGLISH)) {
|
||||
TIMEOTP_ALGORITHM_SHA1_VALUE -> HashAlgorithm.SHA1
|
||||
TIMEOTP_ALGORITHM_SHA256_VALUE -> HashAlgorithm.SHA256
|
||||
TIMEOTP_ALGORITHM_SHA512_VALUE -> HashAlgorithm.SHA512
|
||||
else -> HashAlgorithm.SHA1
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
return false
|
||||
}
|
||||
|
||||
otpElement.type = OtpType.TOTP
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseTOTPKeyValues(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
@@ -290,7 +339,7 @@ object OtpEntryFields {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun parseTOTPFromField(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
private fun parseTOTPFromPluginField(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
val seedField = getField(TOTP_SEED_FIELD) ?: return false
|
||||
try {
|
||||
otpElement.setBase32Secret(seedField)
|
||||
@@ -305,9 +354,15 @@ object OtpEntryFields {
|
||||
return false
|
||||
}
|
||||
otpElement.period = matcher.group(1)?.toIntOrNull() ?: TOTP_DEFAULT_PERIOD
|
||||
otpElement.tokenType = matcher.group(2)?.let {
|
||||
OtpTokenType.getFromString(it)
|
||||
} ?: OtpTokenType.RFC6238
|
||||
matcher.group(2)?.let { secondMatcher ->
|
||||
try {
|
||||
otpElement.digits = secondMatcher.toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
otpElement.digits = OTP_DEFAULT_DIGITS
|
||||
otpElement.tokenType = OtpTokenType.getFromString(secondMatcher)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
return false
|
||||
@@ -316,7 +371,7 @@ object OtpEntryFields {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseHOTPFromField(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
private fun parseHOTPFromOfficialField(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||
val secretField = getField(HMACOTP_SECRET_FIELD)
|
||||
val secretHexField = getField(HMACOTP_SECRET_HEX_FIELD)
|
||||
val secretBase32Field = getField(HMACOTP_SECRET_BASE32_FIELD)
|
||||
@@ -382,25 +437,43 @@ object OtpEntryFields {
|
||||
val totpSeedField = Field(TOTP_SEED_FIELD)
|
||||
val totpSettingField = Field(TOTP_SETTING_FIELD)
|
||||
val hmacOtpSecretField = Field(HMACOTP_SECRET_FIELD)
|
||||
val hmacOtpSecretHewField = Field(HMACOTP_SECRET_HEX_FIELD)
|
||||
val hmacOtpSecretHexField = Field(HMACOTP_SECRET_HEX_FIELD)
|
||||
val hmacOtpSecretBase32Field = Field(HMACOTP_SECRET_BASE32_FIELD)
|
||||
val hmacOtpSecretBase64Field = Field(HMACOTP_SECRET_BASE64_FIELD)
|
||||
val hmacOtpSecretCounterField = Field(HMACOTP_SECRET_COUNTER_FIELD)
|
||||
val timeOtpSecretField = Field(TIMEOTP_SECRET_FIELD)
|
||||
val timeOtpSecretHexField = Field(TIMEOTP_SECRET_HEX_FIELD)
|
||||
val timeOtpSecretBase32Field = Field(TIMEOTP_SECRET_BASE32_FIELD)
|
||||
val timeOtpSecretBase64Field = Field(TIMEOTP_SECRET_BASE64_FIELD)
|
||||
val timeOtpLengthField = Field(TIMEOTP_LENGTH_FIELD)
|
||||
val timeOtpPeriodField = Field(TIMEOTP_PERIOD_FIELD)
|
||||
val timeOtpAlgorithmField = Field(TIMEOTP_ALGORITHM_FIELD)
|
||||
newCustomFields.remove(otpField)
|
||||
newCustomFields.remove(totpSeedField)
|
||||
newCustomFields.remove(totpSettingField)
|
||||
newCustomFields.remove(hmacOtpSecretField)
|
||||
newCustomFields.remove(hmacOtpSecretHewField)
|
||||
newCustomFields.remove(hmacOtpSecretHexField)
|
||||
newCustomFields.remove(hmacOtpSecretBase32Field)
|
||||
newCustomFields.remove(hmacOtpSecretBase64Field)
|
||||
newCustomFields.remove(hmacOtpSecretCounterField)
|
||||
newCustomFields.remove(timeOtpSecretField)
|
||||
newCustomFields.remove(timeOtpSecretHexField)
|
||||
newCustomFields.remove(timeOtpSecretBase32Field)
|
||||
newCustomFields.remove(timeOtpSecretBase64Field)
|
||||
newCustomFields.remove(timeOtpLengthField)
|
||||
newCustomFields.remove(timeOtpPeriodField)
|
||||
newCustomFields.remove(timeOtpAlgorithmField)
|
||||
// Empty auto generated OTP Token field
|
||||
if (fieldsToParse.contains(otpField)
|
||||
|| fieldsToParse.contains(totpSeedField)
|
||||
|| fieldsToParse.contains(hmacOtpSecretField)
|
||||
|| fieldsToParse.contains(hmacOtpSecretHewField)
|
||||
|| fieldsToParse.contains(hmacOtpSecretHexField)
|
||||
|| fieldsToParse.contains(hmacOtpSecretBase32Field)
|
||||
|| fieldsToParse.contains(hmacOtpSecretBase64Field)
|
||||
|| fieldsToParse.contains(timeOtpSecretField)
|
||||
|| fieldsToParse.contains(timeOtpSecretHexField)
|
||||
|| fieldsToParse.contains(timeOtpSecretBase32Field)
|
||||
|| fieldsToParse.contains(timeOtpSecretBase64Field)
|
||||
)
|
||||
newCustomFields.add(Field(OTP_TOKEN_FIELD))
|
||||
return newCustomFields
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.kunzisoft.keepass.services
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseEntity
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class AdvancedUnlockNotificationService : NotificationService() {
|
||||
|
||||
private lateinit var mTempCipherDao: ArrayList<CipherDatabaseEntity>
|
||||
|
||||
private var mActionTaskBinder = AdvancedUnlockBinder()
|
||||
|
||||
private var notificationTimeoutMilliSecs: Long = 0
|
||||
private var mTimerJob: Job? = null
|
||||
|
||||
inner class AdvancedUnlockBinder: Binder() {
|
||||
fun getCipherDatabase(databaseUri: Uri): CipherDatabaseEntity? {
|
||||
return mTempCipherDao.firstOrNull { it.databaseUri == databaseUri.toString()}
|
||||
}
|
||||
fun addOrUpdateCipherDatabase(cipherDatabaseEntity: CipherDatabaseEntity) {
|
||||
val cipherDatabaseRetrieve = mTempCipherDao.firstOrNull { it.databaseUri == cipherDatabaseEntity.databaseUri }
|
||||
cipherDatabaseRetrieve?.replaceContent(cipherDatabaseEntity)
|
||||
?: mTempCipherDao.add(cipherDatabaseEntity)
|
||||
}
|
||||
fun deleteByDatabaseUri(databaseUri: Uri) {
|
||||
mTempCipherDao.firstOrNull { it.databaseUri == databaseUri.toString() }?.let {
|
||||
mTempCipherDao.remove(it)
|
||||
}
|
||||
}
|
||||
fun deleteAll() {
|
||||
mTempCipherDao.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override val notificationId: Int = 593
|
||||
|
||||
override fun retrieveChannelId(): String {
|
||||
return CHANNEL_ADVANCED_UNLOCK_ID
|
||||
}
|
||||
|
||||
override fun retrieveChannelName(): String {
|
||||
return getString(R.string.advanced_unlock)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
super.onBind(intent)
|
||||
return mActionTaskBinder
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
|
||||
val deleteIntent = Intent(this, AdvancedUnlockNotificationService::class.java).apply {
|
||||
action = ACTION_REMOVE_KEYS
|
||||
}
|
||||
val pendingDeleteIntent = PendingIntent.getService(this, 0, deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val biometricUnlockEnabled = PreferencesUtil.isBiometricUnlockEnable(this)
|
||||
val notificationBuilder = buildNewNotification().apply {
|
||||
setSmallIcon(if (biometricUnlockEnabled) {
|
||||
R.drawable.notification_ic_fingerprint_unlock_24dp
|
||||
} else {
|
||||
R.drawable.notification_ic_device_unlock_24dp
|
||||
})
|
||||
intent?.let {
|
||||
setContentTitle(getString(R.string.advanced_unlock))
|
||||
}
|
||||
setContentText(getString(R.string.advanced_unlock_tap_delete))
|
||||
setContentIntent(pendingDeleteIntent)
|
||||
// Unfortunately swipe is disabled in lollipop+
|
||||
setDeleteIntent(pendingDeleteIntent)
|
||||
}
|
||||
|
||||
when (intent?.action) {
|
||||
ACTION_TIMEOUT -> {
|
||||
notificationTimeoutMilliSecs = PreferencesUtil.getAdvancedUnlockTimeout(this)
|
||||
// Not necessarily a foreground service
|
||||
if (mTimerJob == null && notificationTimeoutMilliSecs != TimeoutHelper.NEVER) {
|
||||
mTimerJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
val maxPos = 100
|
||||
val posDurationMills = notificationTimeoutMilliSecs / maxPos
|
||||
for (pos in maxPos downTo 0) {
|
||||
notificationBuilder.setProgress(maxPos, pos, false)
|
||||
startForeground(notificationId, notificationBuilder.build())
|
||||
delay(posDurationMills)
|
||||
if (pos <= 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
notificationManager?.cancel(notificationId)
|
||||
mTimerJob = null
|
||||
cancel()
|
||||
}
|
||||
} else {
|
||||
startForeground(notificationId, notificationBuilder.build())
|
||||
}
|
||||
}
|
||||
ACTION_REMOVE_KEYS -> {
|
||||
stopSelf()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
mTempCipherDao = ArrayList()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mTempCipherDao.clear()
|
||||
mTimerJob?.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ADVANCED_UNLOCK_ID = "com.kunzisoft.keepass.notification.channel.unlock"
|
||||
|
||||
private const val ACTION_TIMEOUT = "ACTION_TIMEOUT"
|
||||
private const val ACTION_REMOVE_KEYS = "ACTION_REMOVE_KEYS"
|
||||
|
||||
fun startServiceForTimeout(context: Context) {
|
||||
if (PreferencesUtil.isTempAdvancedUnlockEnable(context)) {
|
||||
context.startService(Intent(context, AdvancedUnlockNotificationService::class.java).apply {
|
||||
action = ACTION_TIMEOUT
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
context.stopService(Intent(context, AdvancedUnlockNotificationService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.notifications
|
||||
package com.kunzisoft.keepass.services
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentResolver
|
||||
@@ -29,15 +29,14 @@ import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.database.element.Attachment
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.database.BinaryAttachment
|
||||
import com.kunzisoft.keepass.model.AttachmentState
|
||||
import com.kunzisoft.keepass.model.EntryAttachmentState
|
||||
import com.kunzisoft.keepass.model.StreamDirection
|
||||
import com.kunzisoft.keepass.stream.readBytes
|
||||
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
||||
import com.kunzisoft.keepass.stream.readAllBytes
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.BufferedInputStream
|
||||
import java.util.*
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
@@ -100,12 +99,15 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
|
||||
when(intent?.action) {
|
||||
ACTION_ATTACHMENT_FILE_START_UPLOAD -> {
|
||||
actionUploadOrDownload(downloadFileUri,
|
||||
actionStartUploadOrDownload(downloadFileUri,
|
||||
intent,
|
||||
StreamDirection.UPLOAD)
|
||||
}
|
||||
ACTION_ATTACHMENT_FILE_STOP_UPLOAD -> {
|
||||
actionStopUpload()
|
||||
}
|
||||
ACTION_ATTACHMENT_FILE_START_DOWNLOAD -> {
|
||||
actionUploadOrDownload(downloadFileUri,
|
||||
actionStartUploadOrDownload(downloadFileUri,
|
||||
intent,
|
||||
StreamDirection.DOWNLOAD)
|
||||
}
|
||||
@@ -215,15 +217,22 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
setDeleteIntent(pendingDeleteIntent)
|
||||
setOngoing(false)
|
||||
}
|
||||
AttachmentState.CANCELED -> {
|
||||
setContentText(getString(R.string.download_canceled))
|
||||
setDeleteIntent(pendingDeleteIntent)
|
||||
setOngoing(false)
|
||||
}
|
||||
AttachmentState.ERROR -> {
|
||||
setContentText(getString(R.string.error_file_not_create))
|
||||
setDeleteIntent(pendingDeleteIntent)
|
||||
setOngoing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (attachmentNotification.entryAttachmentState.downloadState) {
|
||||
AttachmentState.ERROR,
|
||||
AttachmentState.COMPLETE -> {
|
||||
AttachmentState.COMPLETE,
|
||||
AttachmentState.CANCELED,
|
||||
AttachmentState.ERROR -> {
|
||||
stopForeground(false)
|
||||
notificationManager?.notify(attachmentNotification.notificationId, builder.build())
|
||||
} else -> {
|
||||
@@ -234,6 +243,7 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
|
||||
override fun onDestroy() {
|
||||
attachmentNotificationList.forEach { attachmentNotification ->
|
||||
attachmentNotification.attachmentFileAction?.cancel()
|
||||
attachmentNotification.attachmentFileAction?.listener = null
|
||||
notificationManager?.cancel(attachmentNotification.notificationId)
|
||||
}
|
||||
@@ -262,10 +272,10 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun actionUploadOrDownload(downloadFileUri: Uri?,
|
||||
intent: Intent,
|
||||
streamDirection: StreamDirection) {
|
||||
if (downloadFileUri != null
|
||||
private fun actionStartUploadOrDownload(fileUri: Uri?,
|
||||
intent: Intent,
|
||||
streamDirection: StreamDirection) {
|
||||
if (fileUri != null
|
||||
&& intent.hasExtra(ATTACHMENT_KEY)) {
|
||||
try {
|
||||
intent.getParcelableExtra<Attachment>(ATTACHMENT_KEY)?.let { entryAttachment ->
|
||||
@@ -273,7 +283,7 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
val nextNotificationId = (attachmentNotificationList.maxByOrNull { it.notificationId }
|
||||
?.notificationId ?: notificationId) + 1
|
||||
val entryAttachmentState = EntryAttachmentState(entryAttachment, streamDirection)
|
||||
val attachmentNotification = AttachmentNotification(downloadFileUri, nextNotificationId, entryAttachmentState)
|
||||
val attachmentNotification = AttachmentNotification(fileUri, nextNotificationId, entryAttachmentState)
|
||||
|
||||
// Add action to the list on start
|
||||
attachmentNotificationList.add(attachmentNotification)
|
||||
@@ -286,11 +296,24 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to upload/download $downloadFileUri", e)
|
||||
Log.e(TAG, "Unable to upload/download $fileUri", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun actionStopUpload() {
|
||||
try {
|
||||
// Stop each upload
|
||||
attachmentNotificationList.filter {
|
||||
it.entryAttachmentState.streamDirection == StreamDirection.UPLOAD
|
||||
}.forEach {
|
||||
it.attachmentFileAction?.cancel()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to stop upload", e)
|
||||
}
|
||||
}
|
||||
|
||||
private class AttachmentFileAction(
|
||||
private val attachmentNotification: AttachmentNotification,
|
||||
private val contentResolver: ContentResolver) {
|
||||
@@ -307,8 +330,6 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
|
||||
// on pre execute
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
TimeoutHelper.temporarilyDisableTimeout()
|
||||
|
||||
attachmentNotification.attachmentFileAction = this@AttachmentFileAction
|
||||
attachmentNotification.entryAttachmentState.apply {
|
||||
downloadState = AttachmentState.START
|
||||
@@ -319,70 +340,81 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
// on Progress with thread
|
||||
val asyncResult: Deferred<Boolean> = async {
|
||||
var progressResult = true
|
||||
try {
|
||||
attachmentNotification.entryAttachmentState.apply {
|
||||
downloadState = AttachmentState.IN_PROGRESS
|
||||
val asyncAction = launch {
|
||||
attachmentNotification.entryAttachmentState.apply {
|
||||
try {
|
||||
downloadState = AttachmentState.IN_PROGRESS
|
||||
|
||||
when (streamDirection) {
|
||||
StreamDirection.UPLOAD -> {
|
||||
uploadToDatabase(
|
||||
attachmentNotification.uri,
|
||||
attachment.binaryAttachment,
|
||||
contentResolver, 1024) { percent ->
|
||||
publishProgress(percent)
|
||||
when (streamDirection) {
|
||||
StreamDirection.UPLOAD -> {
|
||||
uploadToDatabase(
|
||||
attachmentNotification.uri,
|
||||
attachment.binaryAttachment,
|
||||
contentResolver, 1024,
|
||||
{ // Cancellation
|
||||
downloadState == AttachmentState.CANCELED
|
||||
}
|
||||
) { percent ->
|
||||
publishProgress(percent)
|
||||
}
|
||||
}
|
||||
StreamDirection.DOWNLOAD -> {
|
||||
downloadFromDatabase(
|
||||
attachmentNotification.uri,
|
||||
attachment.binaryAttachment,
|
||||
contentResolver, 1024) { percent ->
|
||||
publishProgress(percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
StreamDirection.DOWNLOAD -> {
|
||||
downloadFromDatabase(
|
||||
attachmentNotification.uri,
|
||||
attachment.binaryAttachment,
|
||||
contentResolver, 1024) { percent ->
|
||||
publishProgress(percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
downloadState = AttachmentState.ERROR
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to upload or download file", e)
|
||||
progressResult = false
|
||||
}
|
||||
progressResult
|
||||
attachmentNotification.entryAttachmentState.downloadState
|
||||
}
|
||||
|
||||
// on post execute
|
||||
withContext(Dispatchers.Main) {
|
||||
val result = asyncResult.await()
|
||||
attachmentNotification.attachmentFileAction = null
|
||||
asyncAction.join()
|
||||
attachmentNotification.entryAttachmentState.apply {
|
||||
downloadState = if (result) AttachmentState.COMPLETE else AttachmentState.ERROR
|
||||
downloadProgression = 100
|
||||
if (downloadState != AttachmentState.CANCELED
|
||||
&& downloadState != AttachmentState.ERROR) {
|
||||
downloadState = AttachmentState.COMPLETE
|
||||
downloadProgression = 100
|
||||
}
|
||||
}
|
||||
attachmentNotification.attachmentFileAction = null
|
||||
listener?.onUpdate(attachmentNotification)
|
||||
TimeoutHelper.releaseTemporarilyDisableTimeout()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
attachmentNotification.entryAttachmentState.downloadState = AttachmentState.CANCELED
|
||||
}
|
||||
|
||||
fun downloadFromDatabase(attachmentToUploadUri: Uri,
|
||||
binaryAttachment: BinaryAttachment,
|
||||
contentResolver: ContentResolver,
|
||||
bufferSize: Int = DEFAULT_BUFFER_SIZE,
|
||||
update: ((percent: Int)->Unit)? = null) {
|
||||
var dataDownloaded = 0L
|
||||
val fileSize = binaryAttachment.length()
|
||||
val fileSize = binaryAttachment.length
|
||||
UriUtil.getUriOutputStream(contentResolver, attachmentToUploadUri)?.use { outputStream ->
|
||||
binaryAttachment.getUnGzipInputDataStream().use { inputStream ->
|
||||
inputStream.readBytes(bufferSize) { buffer ->
|
||||
outputStream.write(buffer)
|
||||
dataDownloaded += buffer.size
|
||||
try {
|
||||
val percentDownload = (100 * dataDownloaded / fileSize).toInt()
|
||||
update?.invoke(percentDownload)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "", e)
|
||||
Database.getInstance().loadedCipherKey?.let { binaryCipherKey ->
|
||||
binaryAttachment.getUnGzipInputDataStream(binaryCipherKey).use { inputStream ->
|
||||
inputStream.readAllBytes(bufferSize) { buffer ->
|
||||
outputStream.write(buffer)
|
||||
dataDownloaded += buffer.size
|
||||
try {
|
||||
val percentDownload = (100 * dataDownloaded / fileSize).toInt()
|
||||
update?.invoke(percentDownload)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,13 +425,14 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
binaryAttachment: BinaryAttachment,
|
||||
contentResolver: ContentResolver,
|
||||
bufferSize: Int = DEFAULT_BUFFER_SIZE,
|
||||
canceled: ()-> Boolean = { false },
|
||||
update: ((percent: Int)->Unit)? = null) {
|
||||
var dataUploaded = 0L
|
||||
val fileSize = contentResolver.openFileDescriptor(attachmentFromDownloadUri, "r")?.statSize ?: 0
|
||||
UriUtil.getUriInputStream(contentResolver, attachmentFromDownloadUri)?.let { inputStream ->
|
||||
binaryAttachment.getGzipOutputDataStream().use { outputStream ->
|
||||
BufferedInputStream(inputStream).use { attachmentBufferedInputStream ->
|
||||
attachmentBufferedInputStream.readBytes(bufferSize) { buffer ->
|
||||
UriUtil.getUriInputStream(contentResolver, attachmentFromDownloadUri)?.use { inputStream ->
|
||||
Database.getInstance().loadedCipherKey?.let { binaryCipherKey ->
|
||||
binaryAttachment.getGzipOutputDataStream(binaryCipherKey).use { outputStream ->
|
||||
inputStream.readAllBytes(bufferSize, canceled) { buffer ->
|
||||
outputStream.write(buffer)
|
||||
dataUploaded += buffer.size
|
||||
try {
|
||||
@@ -419,7 +452,9 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (previousSaveTime + updateMinFrequency < currentTime) {
|
||||
attachmentNotification.entryAttachmentState.apply {
|
||||
downloadState = AttachmentState.IN_PROGRESS
|
||||
if (downloadState != AttachmentState.CANCELED) {
|
||||
downloadState = AttachmentState.IN_PROGRESS
|
||||
}
|
||||
downloadProgression = percent
|
||||
}
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
@@ -441,6 +476,7 @@ class AttachmentFileNotificationService: LockNotificationService() {
|
||||
private const val CHANNEL_ATTACHMENT_ID = "com.kunzisoft.keepass.notification.channel.attachment"
|
||||
|
||||
const val ACTION_ATTACHMENT_FILE_START_UPLOAD = "ACTION_ATTACHMENT_FILE_START_UPLOAD"
|
||||
const val ACTION_ATTACHMENT_FILE_STOP_UPLOAD = "ACTION_ATTACHMENT_FILE_STOP_UPLOAD"
|
||||
const val ACTION_ATTACHMENT_FILE_START_DOWNLOAD = "ACTION_ATTACHMENT_FILE_START_DOWNLOAD"
|
||||
const val ACTION_ATTACHMENT_REMOVE = "ACTION_ATTACHMENT_REMOVE"
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.notifications
|
||||
package com.kunzisoft.keepass.services
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import com.kunzisoft.keepass.model.EntryInfo
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
@@ -54,6 +55,7 @@ class ClipboardEntryNotificationField : Parcelable {
|
||||
NotificationFieldId.UNKNOWN -> ""
|
||||
NotificationFieldId.USERNAME -> entryInfo?.username ?: ""
|
||||
NotificationFieldId.PASSWORD -> entryInfo?.password ?: ""
|
||||
NotificationFieldId.OTP -> entryInfo?.getGeneratedFieldValue(OTP_TOKEN_FIELD) ?: ""
|
||||
NotificationFieldId.FIELD_A,
|
||||
NotificationFieldId.FIELD_B,
|
||||
NotificationFieldId.FIELD_C -> entryInfo?.getGeneratedFieldValue(label) ?: ""
|
||||
@@ -81,7 +83,7 @@ class ClipboardEntryNotificationField : Parcelable {
|
||||
}
|
||||
|
||||
enum class NotificationFieldId {
|
||||
UNKNOWN, USERNAME, PASSWORD, FIELD_A, FIELD_B, FIELD_C;
|
||||
UNKNOWN, USERNAME, PASSWORD, OTP, FIELD_A, FIELD_B, FIELD_C;
|
||||
|
||||
companion object {
|
||||
val anonymousFieldId: Array<NotificationFieldId>
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.notifications
|
||||
package com.kunzisoft.keepass.services
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
@@ -25,6 +25,7 @@ import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.model.EntryInfo
|
||||
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
|
||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||
import com.kunzisoft.keepass.timeout.ClipboardHelper
|
||||
import com.kunzisoft.keepass.timeout.TimeoutHelper.NEVER
|
||||
@@ -250,6 +251,7 @@ class ClipboardEntryNotificationService : LockNotificationService() {
|
||||
val containsUsernameToCopy = entry.username.isNotEmpty()
|
||||
val containsPasswordToCopy = entry.password.isNotEmpty()
|
||||
&& PreferencesUtil.allowCopyPasswordAndProtectedFields(context)
|
||||
val containsOTPToCopy = entry.containsCustomField(OTP_TOKEN_FIELD)
|
||||
val containsExtraFieldToCopy = entry.customFields.isNotEmpty()
|
||||
&& (entry.containsCustomFieldsNotProtected()
|
||||
||
|
||||
@@ -262,7 +264,10 @@ class ClipboardEntryNotificationService : LockNotificationService() {
|
||||
// If notifications enabled in settings
|
||||
// Don't if application timeout
|
||||
if (PreferencesUtil.isClipboardNotificationsEnable(context)) {
|
||||
if (containsUsernameToCopy || containsPasswordToCopy || containsExtraFieldToCopy) {
|
||||
if (containsUsernameToCopy
|
||||
|| containsPasswordToCopy
|
||||
|| containsOTPToCopy
|
||||
|| containsExtraFieldToCopy) {
|
||||
|
||||
// username already copied, waiting for user's action before copy password.
|
||||
intent.action = ACTION_NEW_NOTIFICATION
|
||||
@@ -282,14 +287,22 @@ class ClipboardEntryNotificationService : LockNotificationService() {
|
||||
ClipboardEntryNotificationField.NotificationFieldId.PASSWORD,
|
||||
context.getString(R.string.entry_password)))
|
||||
}
|
||||
// Add OTP
|
||||
if (containsOTPToCopy) {
|
||||
notificationFields.add(
|
||||
ClipboardEntryNotificationField(
|
||||
ClipboardEntryNotificationField.NotificationFieldId.OTP,
|
||||
OTP_TOKEN_FIELD))
|
||||
}
|
||||
// Add extra fields
|
||||
if (containsExtraFieldToCopy) {
|
||||
try {
|
||||
var anonymousFieldNumber = 0
|
||||
entry.customFields.forEach { field ->
|
||||
//If value is not protected or allowed
|
||||
if (!field.protectedValue.isProtected
|
||||
|| PreferencesUtil.allowCopyPasswordAndProtectedFields(context)) {
|
||||
if ((!field.protectedValue.isProtected
|
||||
|| PreferencesUtil.allowCopyPasswordAndProtectedFields(context))
|
||||
&& field.name != OTP_TOKEN_FIELD) {
|
||||
notificationFields.add(
|
||||
ClipboardEntryNotificationField(
|
||||
ClipboardEntryNotificationField.NotificationFieldId.anonymousFieldId[anonymousFieldNumber],
|
||||
@@ -17,14 +17,13 @@
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.notifications
|
||||
package com.kunzisoft.keepass.services
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.activities.GroupActivity
|
||||
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
|
||||
@@ -40,6 +39,8 @@ import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm
|
||||
import com.kunzisoft.keepass.database.element.node.Node
|
||||
import com.kunzisoft.keepass.database.element.node.NodeId
|
||||
import com.kunzisoft.keepass.database.element.node.Type
|
||||
import com.kunzisoft.keepass.model.MainCredential
|
||||
import com.kunzisoft.keepass.model.SnapFileDatabaseInfo
|
||||
import com.kunzisoft.keepass.tasks.ActionRunnable
|
||||
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
|
||||
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
||||
@@ -47,6 +48,7 @@ import com.kunzisoft.keepass.utils.DATABASE_START_TASK_ACTION
|
||||
import com.kunzisoft.keepass.utils.DATABASE_STOP_TASK_ACTION
|
||||
import com.kunzisoft.keepass.utils.LOCK_ACTION
|
||||
import com.kunzisoft.keepass.utils.closeDatabase
|
||||
import com.kunzisoft.keepass.viewmodels.FileDatabaseInfo
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -65,6 +67,8 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
private var mAllowFinishAction = AtomicBoolean()
|
||||
private var mActionRunning = false
|
||||
|
||||
private var mDatabaseInfoListeners = LinkedList<DatabaseInfoListener>()
|
||||
|
||||
private var mIconId: Int = R.drawable.notification_ic_database_load
|
||||
private var mTitleId: Int = R.string.database_opened
|
||||
private var mMessageId: Int? = null
|
||||
@@ -93,6 +97,14 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
mAllowFinishAction.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun addDatabaseFileInfoListener(databaseInfoListener: DatabaseInfoListener) {
|
||||
mDatabaseInfoListeners.add(databaseInfoListener)
|
||||
}
|
||||
|
||||
fun removeDatabaseFileInfoListener(databaseInfoListener: DatabaseInfoListener) {
|
||||
mDatabaseInfoListeners.remove(databaseInfoListener)
|
||||
}
|
||||
}
|
||||
|
||||
interface ActionTaskListener {
|
||||
@@ -101,6 +113,11 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
fun onStopAction(actionTask: String, result: ActionRunnable.Result)
|
||||
}
|
||||
|
||||
interface DatabaseInfoListener {
|
||||
fun onDatabaseInfoChanged(previousDatabaseInfo: SnapFileDatabaseInfo,
|
||||
newDatabaseInfo: SnapFileDatabaseInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Force to call [ActionTaskListener.onStartAction] if the action is still running
|
||||
*/
|
||||
@@ -112,6 +129,54 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
}
|
||||
}
|
||||
|
||||
fun checkDatabaseInfo() {
|
||||
try {
|
||||
mDatabase.fileUri?.let {
|
||||
val previousDatabaseInfo = mSnapFileDatabaseInfo
|
||||
val lastFileDatabaseInfo = SnapFileDatabaseInfo.fromFileDatabaseInfo(
|
||||
FileDatabaseInfo(applicationContext, it))
|
||||
|
||||
val oldDatabaseModification = previousDatabaseInfo?.lastModification
|
||||
val newDatabaseModification = lastFileDatabaseInfo.lastModification
|
||||
|
||||
val conditionExists = previousDatabaseInfo != null
|
||||
&& previousDatabaseInfo.exists != lastFileDatabaseInfo.exists
|
||||
// To prevent dialog opening too often
|
||||
// Add 10 seconds delta time to prevent spamming
|
||||
val conditionLastModification = (oldDatabaseModification != null && newDatabaseModification != null
|
||||
&& oldDatabaseModification < newDatabaseModification
|
||||
&& mLastLocalSaveTime + 10000 < newDatabaseModification)
|
||||
|
||||
if (conditionExists || conditionLastModification) {
|
||||
// Show the dialog only if it's real new info and not a delay after a save
|
||||
Log.i(TAG, "Database file modified " +
|
||||
"$previousDatabaseInfo != $lastFileDatabaseInfo ")
|
||||
// Call listener to indicate a change in database info
|
||||
if (previousDatabaseInfo != null) {
|
||||
mDatabaseInfoListeners.forEach { listener ->
|
||||
listener.onDatabaseInfoChanged(previousDatabaseInfo, lastFileDatabaseInfo)
|
||||
}
|
||||
}
|
||||
mSnapFileDatabaseInfo = lastFileDatabaseInfo
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to check database info", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveDatabaseInfo() {
|
||||
try {
|
||||
mDatabase.fileUri?.let {
|
||||
mSnapFileDatabaseInfo = SnapFileDatabaseInfo.fromFileDatabaseInfo(
|
||||
FileDatabaseInfo(applicationContext, it))
|
||||
Log.i(TAG, "Database file saved $mSnapFileDatabaseInfo")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to check database info", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
super.onBind(intent)
|
||||
return mActionTaskBinder
|
||||
@@ -138,6 +203,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
val actionRunnable: ActionRunnable? = when (intentAction) {
|
||||
ACTION_DATABASE_CREATE_TASK -> buildDatabaseCreateActionTask(intent)
|
||||
ACTION_DATABASE_LOAD_TASK -> buildDatabaseLoadActionTask(intent)
|
||||
ACTION_DATABASE_RELOAD_TASK -> buildDatabaseReloadActionTask()
|
||||
ACTION_DATABASE_ASSIGN_PASSWORD_TASK -> buildDatabaseAssignPasswordActionTask(intent)
|
||||
ACTION_DATABASE_CREATE_GROUP_TASK -> buildDatabaseCreateGroupActionTask(intent)
|
||||
ACTION_DATABASE_UPDATE_GROUP_TASK -> buildDatabaseUpdateGroupActionTask(intent)
|
||||
@@ -192,6 +258,20 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
actionTaskListener.onStopAction(intentAction!!, result)
|
||||
}
|
||||
} finally {
|
||||
// Save the database info before performing action
|
||||
if (intentAction == ACTION_DATABASE_LOAD_TASK) {
|
||||
saveDatabaseInfo()
|
||||
}
|
||||
// Save the database info after performing save action
|
||||
if (intentAction == ACTION_DATABASE_SAVE
|
||||
|| intent?.getBooleanExtra(SAVE_DATABASE_KEY, false) == true) {
|
||||
mDatabase.fileUri?.let {
|
||||
val newSnapFileDatabaseInfo = SnapFileDatabaseInfo.fromFileDatabaseInfo(
|
||||
FileDatabaseInfo(applicationContext, it))
|
||||
mLastLocalSaveTime = System.currentTimeMillis()
|
||||
mSnapFileDatabaseInfo = newSnapFileDatabaseInfo
|
||||
}
|
||||
}
|
||||
removeIntentData(intent)
|
||||
TimeoutHelper.releaseTemporarilyDisableTimeout()
|
||||
if (TimeoutHelper.checkTimeAndLockIfTimeout(this@DatabaseTaskNotificationService)) {
|
||||
@@ -214,7 +294,9 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
}
|
||||
|
||||
return when (intentAction) {
|
||||
ACTION_DATABASE_LOAD_TASK, null -> {
|
||||
ACTION_DATABASE_LOAD_TASK,
|
||||
ACTION_DATABASE_RELOAD_TASK,
|
||||
null -> {
|
||||
START_STICKY
|
||||
}
|
||||
else -> {
|
||||
@@ -248,7 +330,8 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
else -> {
|
||||
when (intentAction) {
|
||||
ACTION_DATABASE_CREATE_TASK -> R.string.creating_database
|
||||
ACTION_DATABASE_LOAD_TASK -> R.string.loading_database
|
||||
ACTION_DATABASE_LOAD_TASK,
|
||||
ACTION_DATABASE_RELOAD_TASK -> R.string.loading_database
|
||||
ACTION_DATABASE_SAVE -> R.string.saving_database
|
||||
else -> {
|
||||
R.string.command_execution
|
||||
@@ -258,13 +341,15 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
}
|
||||
|
||||
mMessageId = when (intentAction) {
|
||||
ACTION_DATABASE_LOAD_TASK -> null
|
||||
ACTION_DATABASE_LOAD_TASK,
|
||||
ACTION_DATABASE_RELOAD_TASK -> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
mWarningId =
|
||||
if (!saveAction
|
||||
|| intentAction == ACTION_DATABASE_LOAD_TASK)
|
||||
|| intentAction == ACTION_DATABASE_LOAD_TASK
|
||||
|| intentAction == ACTION_DATABASE_RELOAD_TASK)
|
||||
null
|
||||
else
|
||||
R.string.do_not_kill_app
|
||||
@@ -316,10 +401,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
intent?.removeExtra(DATABASE_TASK_WARNING_KEY)
|
||||
|
||||
intent?.removeExtra(DATABASE_URI_KEY)
|
||||
intent?.removeExtra(MASTER_PASSWORD_CHECKED_KEY)
|
||||
intent?.removeExtra(MASTER_PASSWORD_KEY)
|
||||
intent?.removeExtra(KEY_FILE_CHECKED_KEY)
|
||||
intent?.removeExtra(KEY_FILE_URI_KEY)
|
||||
intent?.removeExtra(MAIN_CREDENTIAL_KEY)
|
||||
intent?.removeExtra(READ_ONLY_KEY)
|
||||
intent?.removeExtra(CIPHER_ENTITY_KEY)
|
||||
intent?.removeExtra(FIX_DUPLICATE_UUID_KEY)
|
||||
@@ -342,9 +424,9 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
* Execute action with a coroutine
|
||||
*/
|
||||
private suspend fun executeAction(progressTaskUpdater: ProgressTaskUpdater,
|
||||
onPreExecute: () -> Unit,
|
||||
onExecute: (ProgressTaskUpdater?) -> ActionRunnable?,
|
||||
onPostExecute: (result: ActionRunnable.Result) -> Unit) {
|
||||
onPreExecute: () -> Unit,
|
||||
onExecute: (ProgressTaskUpdater?) -> ActionRunnable?,
|
||||
onPostExecute: (result: ActionRunnable.Result) -> Unit) {
|
||||
mAllowFinishAction.set(false)
|
||||
|
||||
TimeoutHelper.temporarilyDisableTimeout()
|
||||
@@ -391,13 +473,10 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
private fun buildDatabaseCreateActionTask(intent: Intent): ActionRunnable? {
|
||||
|
||||
if (intent.hasExtra(DATABASE_URI_KEY)
|
||||
&& intent.hasExtra(MASTER_PASSWORD_CHECKED_KEY)
|
||||
&& intent.hasExtra(MASTER_PASSWORD_KEY)
|
||||
&& intent.hasExtra(KEY_FILE_CHECKED_KEY)
|
||||
&& intent.hasExtra(KEY_FILE_URI_KEY)
|
||||
&& intent.hasExtra(MAIN_CREDENTIAL_KEY)
|
||||
) {
|
||||
val databaseUri: Uri? = intent.getParcelableExtra(DATABASE_URI_KEY)
|
||||
val keyFileUri: Uri? = intent.getParcelableExtra(KEY_FILE_URI_KEY)
|
||||
val mainCredential: MainCredential = intent.getParcelableExtra(MAIN_CREDENTIAL_KEY) ?: MainCredential()
|
||||
|
||||
if (databaseUri == null)
|
||||
return null
|
||||
@@ -407,14 +486,11 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
databaseUri,
|
||||
getString(R.string.database_default_name),
|
||||
getString(R.string.database),
|
||||
intent.getBooleanExtra(MASTER_PASSWORD_CHECKED_KEY, false),
|
||||
intent.getStringExtra(MASTER_PASSWORD_KEY),
|
||||
intent.getBooleanExtra(KEY_FILE_CHECKED_KEY, false),
|
||||
keyFileUri
|
||||
mainCredential
|
||||
) { result ->
|
||||
result.data = Bundle().apply {
|
||||
putParcelable(DATABASE_URI_KEY, databaseUri)
|
||||
putParcelable(KEY_FILE_URI_KEY, keyFileUri)
|
||||
putParcelable(MAIN_CREDENTIAL_KEY, mainCredential)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -425,15 +501,13 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
private fun buildDatabaseLoadActionTask(intent: Intent): ActionRunnable? {
|
||||
|
||||
if (intent.hasExtra(DATABASE_URI_KEY)
|
||||
&& intent.hasExtra(MASTER_PASSWORD_KEY)
|
||||
&& intent.hasExtra(KEY_FILE_URI_KEY)
|
||||
&& intent.hasExtra(MAIN_CREDENTIAL_KEY)
|
||||
&& intent.hasExtra(READ_ONLY_KEY)
|
||||
&& intent.hasExtra(CIPHER_ENTITY_KEY)
|
||||
&& intent.hasExtra(FIX_DUPLICATE_UUID_KEY)
|
||||
) {
|
||||
val databaseUri: Uri? = intent.getParcelableExtra(DATABASE_URI_KEY)
|
||||
val masterPassword: String? = intent.getStringExtra(MASTER_PASSWORD_KEY)
|
||||
val keyFileUri: Uri? = intent.getParcelableExtra(KEY_FILE_URI_KEY)
|
||||
val mainCredential: MainCredential = intent.getParcelableExtra(MAIN_CREDENTIAL_KEY) ?: MainCredential()
|
||||
val readOnly: Boolean = intent.getBooleanExtra(READ_ONLY_KEY, true)
|
||||
val cipherEntity: CipherDatabaseEntity? = intent.getParcelableExtra(CIPHER_ENTITY_KEY)
|
||||
|
||||
@@ -444,8 +518,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
this,
|
||||
mDatabase,
|
||||
databaseUri,
|
||||
masterPassword,
|
||||
keyFileUri,
|
||||
mainCredential,
|
||||
readOnly,
|
||||
cipherEntity,
|
||||
intent.getBooleanExtra(FIX_DUPLICATE_UUID_KEY, false),
|
||||
@@ -454,8 +527,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
// Add each info to reload database after thrown duplicate UUID exception
|
||||
result.data = Bundle().apply {
|
||||
putParcelable(DATABASE_URI_KEY, databaseUri)
|
||||
putString(MASTER_PASSWORD_KEY, masterPassword)
|
||||
putParcelable(KEY_FILE_URI_KEY, keyFileUri)
|
||||
putParcelable(MAIN_CREDENTIAL_KEY, mainCredential)
|
||||
putBoolean(READ_ONLY_KEY, readOnly)
|
||||
putParcelable(CIPHER_ENTITY_KEY, cipherEntity)
|
||||
}
|
||||
@@ -465,21 +537,26 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDatabaseReloadActionTask(): ActionRunnable {
|
||||
return ReloadDatabaseRunnable(
|
||||
this,
|
||||
mDatabase,
|
||||
this
|
||||
) { result ->
|
||||
// No need to add each info to reload database
|
||||
result.data = Bundle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDatabaseAssignPasswordActionTask(intent: Intent): ActionRunnable? {
|
||||
return if (intent.hasExtra(DATABASE_URI_KEY)
|
||||
&& intent.hasExtra(MASTER_PASSWORD_CHECKED_KEY)
|
||||
&& intent.hasExtra(MASTER_PASSWORD_KEY)
|
||||
&& intent.hasExtra(KEY_FILE_CHECKED_KEY)
|
||||
&& intent.hasExtra(KEY_FILE_URI_KEY)
|
||||
&& intent.hasExtra(MAIN_CREDENTIAL_KEY)
|
||||
) {
|
||||
val databaseUri: Uri = intent.getParcelableExtra(DATABASE_URI_KEY) ?: return null
|
||||
AssignPasswordInDatabaseRunnable(this,
|
||||
mDatabase,
|
||||
databaseUri,
|
||||
intent.getBooleanExtra(MASTER_PASSWORD_CHECKED_KEY, false),
|
||||
intent.getStringExtra(MASTER_PASSWORD_KEY),
|
||||
intent.getBooleanExtra(KEY_FILE_CHECKED_KEY, false),
|
||||
intent.getParcelableExtra(KEY_FILE_URI_KEY)
|
||||
intent.getParcelableExtra(MAIN_CREDENTIAL_KEY) ?: MainCredential()
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -770,6 +847,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
|
||||
const val ACTION_DATABASE_CREATE_TASK = "ACTION_DATABASE_CREATE_TASK"
|
||||
const val ACTION_DATABASE_LOAD_TASK = "ACTION_DATABASE_LOAD_TASK"
|
||||
const val ACTION_DATABASE_RELOAD_TASK = "ACTION_DATABASE_RELOAD_TASK"
|
||||
const val ACTION_DATABASE_ASSIGN_PASSWORD_TASK = "ACTION_DATABASE_ASSIGN_PASSWORD_TASK"
|
||||
const val ACTION_DATABASE_CREATE_GROUP_TASK = "ACTION_DATABASE_CREATE_GROUP_TASK"
|
||||
const val ACTION_DATABASE_UPDATE_GROUP_TASK = "ACTION_DATABASE_UPDATE_GROUP_TASK"
|
||||
@@ -801,10 +879,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
const val DATABASE_TASK_WARNING_KEY = "DATABASE_TASK_WARNING_KEY"
|
||||
|
||||
const val DATABASE_URI_KEY = "DATABASE_URI_KEY"
|
||||
const val MASTER_PASSWORD_CHECKED_KEY = "MASTER_PASSWORD_CHECKED_KEY"
|
||||
const val MASTER_PASSWORD_KEY = "MASTER_PASSWORD_KEY"
|
||||
const val KEY_FILE_CHECKED_KEY = "KEY_FILE_CHECKED_KEY"
|
||||
const val KEY_FILE_URI_KEY = "KEY_FILE_URI_KEY"
|
||||
const val MAIN_CREDENTIAL_KEY = "MAIN_CREDENTIAL_KEY"
|
||||
const val READ_ONLY_KEY = "READ_ONLY_KEY"
|
||||
const val CIPHER_ENTITY_KEY = "CIPHER_ENTITY_KEY"
|
||||
const val FIX_DUPLICATE_UUID_KEY = "FIX_DUPLICATE_UUID_KEY"
|
||||
@@ -822,6 +897,9 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
|
||||
const val OLD_ELEMENT_KEY = "OLD_ELEMENT_KEY" // Warning type of this thing change every time
|
||||
const val NEW_ELEMENT_KEY = "NEW_ELEMENT_KEY" // Warning type of this thing change every time
|
||||
|
||||
private var mSnapFileDatabaseInfo: SnapFileDatabaseInfo? = null
|
||||
private var mLastLocalSaveTime: Long = 0
|
||||
|
||||
fun getListNodesFromBundle(database: Database, bundle: Bundle): List<Node> {
|
||||
val nodesAction = ArrayList<Node>()
|
||||
bundle.getParcelableArrayList<NodeId<*>>(GROUPS_ID_KEY)?.forEach {
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.notifications
|
||||
package com.kunzisoft.keepass.services
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package com.kunzisoft.keepass.notifications
|
||||
package com.kunzisoft.keepass.services
|
||||
|
||||
import android.content.Intent
|
||||
import com.kunzisoft.keepass.utils.LockReceiver
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.kunzisoft.keepass.notifications
|
||||
package com.kunzisoft.keepass.services
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -19,10 +19,12 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.settings
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreference
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.settings.preferencedialogfragment.AutofillBlocklistAppIdPreferenceDialogFragmentCompat
|
||||
import com.kunzisoft.keepass.settings.preferencedialogfragment.AutofillBlocklistWebDomainPreferenceDialogFragmentCompat
|
||||
@@ -32,6 +34,11 @@ class AutofillSettingsFragment : PreferenceFragmentCompat() {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
// Load the preferences from an XML resource
|
||||
setPreferencesFromResource(R.xml.preferences_autofill, rootKey)
|
||||
|
||||
val autofillInlineSuggestionsPreference: SwitchPreference? = findPreference(getString(R.string.autofill_inline_suggestions_key))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
autofillInlineSuggestionsPreference?.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisplayPreferenceDialog(preference: Preference?) {
|
||||
|
||||
@@ -103,6 +103,6 @@ class MainPreferenceFragment : PreferenceFragmentCompat() {
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onNestedPreferenceSelected(key: NestedSettingsFragment.Screen)
|
||||
fun onNestedPreferenceSelected(key: NestedSettingsFragment.Screen, reload: Boolean = false)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import android.view.autofill.AutofillManager
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.SwitchPreference
|
||||
@@ -41,15 +41,18 @@ import com.kunzisoft.keepass.activities.dialogs.UnavailableFeatureDialogFragment
|
||||
import com.kunzisoft.keepass.activities.stylish.Stylish
|
||||
import com.kunzisoft.keepass.app.database.CipherDatabaseAction
|
||||
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
|
||||
import com.kunzisoft.keepass.biometric.BiometricUnlockDatabaseHelper
|
||||
import com.kunzisoft.keepass.biometric.AdvancedUnlockManager
|
||||
import com.kunzisoft.keepass.education.Education
|
||||
import com.kunzisoft.keepass.icons.IconPackChooser
|
||||
import com.kunzisoft.keepass.services.AdvancedUnlockNotificationService
|
||||
import com.kunzisoft.keepass.settings.preference.IconPackListPreference
|
||||
import com.kunzisoft.keepass.utils.UriUtil
|
||||
|
||||
|
||||
class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
|
||||
private var deleteKeysAlertDialog: AlertDialog? = null
|
||||
|
||||
override fun onCreateScreenPreference(screen: Screen, savedInstanceState: Bundle?, rootKey: String?) {
|
||||
|
||||
// Load the preferences from an XML resource
|
||||
@@ -208,15 +211,18 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
setPreferencesFromResource(R.xml.preferences_advanced_unlock, rootKey)
|
||||
|
||||
activity?.let { activity ->
|
||||
|
||||
val biometricUnlockEnablePreference: SwitchPreference? = findPreference(getString(R.string.biometric_unlock_enable_key))
|
||||
val deleteKeysFingerprints: Preference? = findPreference(getString(R.string.biometric_delete_all_key_key))
|
||||
// < M solve verifyError exception
|
||||
val deviceCredentialUnlockEnablePreference: SwitchPreference? = findPreference(getString(R.string.device_credential_unlock_enable_key))
|
||||
val autoOpenPromptPreference: SwitchPreference? = findPreference(getString(R.string.biometric_auto_open_prompt_key))
|
||||
val tempAdvancedUnlockPreference: SwitchPreference? = findPreference(getString(R.string.temp_advanced_unlock_enable_key))
|
||||
|
||||
val biometricUnlockSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
BiometricUnlockDatabaseHelper.unlockSupported(activity)
|
||||
AdvancedUnlockManager.biometricUnlockSupported(activity)
|
||||
} else false
|
||||
if (!biometricUnlockSupported) {
|
||||
biometricUnlockEnablePreference?.apply {
|
||||
// False if under Marshmallow
|
||||
biometricUnlockEnablePreference?.apply {
|
||||
if (!biometricUnlockSupported) {
|
||||
isChecked = false
|
||||
setOnPreferenceClickListener { preference ->
|
||||
(preference as SwitchPreference).isChecked = false
|
||||
@@ -224,42 +230,98 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
.show(parentFragmentManager, "unavailableFeatureDialog")
|
||||
false
|
||||
}
|
||||
}
|
||||
deleteKeysFingerprints?.isEnabled = false
|
||||
} else {
|
||||
deleteKeysFingerprints?.setOnPreferenceClickListener {
|
||||
context?.let { context ->
|
||||
AlertDialog.Builder(context)
|
||||
.setMessage(resources.getString(R.string.biometric_delete_all_key_warning))
|
||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setPositiveButton(resources.getString(android.R.string.ok)
|
||||
) { _, _ ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
BiometricUnlockDatabaseHelper.deleteEntryKeyInKeystoreForBiometric(
|
||||
activity,
|
||||
object : BiometricUnlockDatabaseHelper.BiometricUnlockErrorCallback {
|
||||
fun showException(e: Exception) {
|
||||
Toast.makeText(context,
|
||||
getString(R.string.biometric_scanning_error, e.localizedMessage),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onInvalidKeyException(e: Exception) {
|
||||
showException(e)
|
||||
}
|
||||
|
||||
override fun onBiometricException(e: Exception) {
|
||||
showException(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
CipherDatabaseAction.getInstance(context.applicationContext).deleteAll()
|
||||
} else {
|
||||
setOnPreferenceClickListener {
|
||||
val biometricChecked = biometricUnlockEnablePreference.isChecked
|
||||
val deviceCredentialChecked = deviceCredentialUnlockEnablePreference?.isChecked ?: false
|
||||
if (!biometricChecked) {
|
||||
biometricUnlockEnablePreference.isChecked = true
|
||||
deleteKeysMessage(activity) {
|
||||
biometricUnlockEnablePreference.isChecked = false
|
||||
autoOpenPromptPreference?.isEnabled = deviceCredentialChecked
|
||||
tempAdvancedUnlockPreference?.isEnabled = deviceCredentialChecked
|
||||
}
|
||||
} else {
|
||||
if (deviceCredentialChecked) {
|
||||
biometricUnlockEnablePreference.isChecked = false
|
||||
deleteKeysMessage(activity) {
|
||||
biometricUnlockEnablePreference.isChecked = true
|
||||
deviceCredentialUnlockEnablePreference?.isChecked = false
|
||||
}
|
||||
.setNegativeButton(resources.getString(android.R.string.cancel))
|
||||
{ _, _ -> }.show()
|
||||
} else {
|
||||
autoOpenPromptPreference?.isEnabled = true
|
||||
tempAdvancedUnlockPreference?.isEnabled = true
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deviceCredentialUnlockSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AdvancedUnlockManager.deviceCredentialUnlockSupported(activity)
|
||||
} else false
|
||||
deviceCredentialUnlockEnablePreference?.apply {
|
||||
// Biometric unlock already checked
|
||||
if (biometricUnlockEnablePreference?.isChecked == true)
|
||||
isChecked = false
|
||||
if (!deviceCredentialUnlockSupported) {
|
||||
isChecked = false
|
||||
setOnPreferenceClickListener { preference ->
|
||||
(preference as SwitchPreference).isChecked = false
|
||||
UnavailableFeatureDialogFragment.getInstance(Build.VERSION_CODES.M)
|
||||
.show(parentFragmentManager, "unavailableFeatureDialog")
|
||||
false
|
||||
}
|
||||
} else {
|
||||
setOnPreferenceClickListener {
|
||||
val deviceCredentialChecked = deviceCredentialUnlockEnablePreference.isChecked
|
||||
val biometricChecked = biometricUnlockEnablePreference?.isChecked ?: false
|
||||
if (!deviceCredentialChecked) {
|
||||
deviceCredentialUnlockEnablePreference.isChecked = true
|
||||
deleteKeysMessage(activity) {
|
||||
deviceCredentialUnlockEnablePreference.isChecked = false
|
||||
autoOpenPromptPreference?.isEnabled = biometricChecked
|
||||
tempAdvancedUnlockPreference?.isEnabled = biometricChecked
|
||||
}
|
||||
} else {
|
||||
if (biometricChecked) {
|
||||
deviceCredentialUnlockEnablePreference.isChecked = false
|
||||
deleteKeysMessage(activity) {
|
||||
deviceCredentialUnlockEnablePreference.isChecked = true
|
||||
biometricUnlockEnablePreference?.isChecked = false
|
||||
}
|
||||
} else {
|
||||
autoOpenPromptPreference?.isEnabled = true
|
||||
tempAdvancedUnlockPreference?.isEnabled = true
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
autoOpenPromptPreference?.isEnabled = biometricUnlockEnablePreference?.isChecked == true
|
||||
|| deviceCredentialUnlockEnablePreference?.isChecked == true
|
||||
tempAdvancedUnlockPreference?.isEnabled = biometricUnlockEnablePreference?.isChecked == true
|
||||
|| deviceCredentialUnlockEnablePreference?.isChecked == true
|
||||
|
||||
tempAdvancedUnlockPreference?.setOnPreferenceClickListener {
|
||||
tempAdvancedUnlockPreference.isChecked = !tempAdvancedUnlockPreference.isChecked
|
||||
deleteKeysMessage(activity) {
|
||||
tempAdvancedUnlockPreference.isChecked = !tempAdvancedUnlockPreference.isChecked
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
val deleteKeysFingerprints: Preference? = findPreference(getString(R.string.biometric_delete_all_key_key))
|
||||
if (biometricUnlockSupported || deviceCredentialUnlockSupported) {
|
||||
deleteKeysFingerprints?.setOnPreferenceClickListener {
|
||||
deleteKeysMessage(activity)
|
||||
false
|
||||
}
|
||||
} else {
|
||||
deleteKeysFingerprints?.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +331,42 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteKeysMessage(activity: FragmentActivity, validate: (()->Unit)? = null) {
|
||||
deleteKeysAlertDialog = AlertDialog.Builder(activity)
|
||||
.setMessage(resources.getString(R.string.advanced_unlock_delete_all_key_warning))
|
||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setPositiveButton(resources.getString(android.R.string.ok)
|
||||
) { _, _ ->
|
||||
validate?.invoke()
|
||||
deleteKeysAlertDialog?.setOnDismissListener(null)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AdvancedUnlockManager.deleteEntryKeyInKeystoreForBiometric(
|
||||
activity,
|
||||
object : AdvancedUnlockManager.AdvancedUnlockErrorCallback {
|
||||
fun showException(e: Exception) {
|
||||
Toast.makeText(context,
|
||||
getString(R.string.advanced_unlock_scanning_error, e.localizedMessage),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onInvalidKeyException(e: Exception) {
|
||||
showException(e)
|
||||
}
|
||||
|
||||
override fun onGenericException(e: Exception) {
|
||||
showException(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
AdvancedUnlockNotificationService.stopService(activity.applicationContext)
|
||||
CipherDatabaseAction.getInstance(activity.applicationContext).deleteAll()
|
||||
}
|
||||
.setNegativeButton(resources.getString(android.R.string.cancel)
|
||||
) { _, _ ->}
|
||||
.create()
|
||||
deleteKeysAlertDialog?.show()
|
||||
}
|
||||
|
||||
private fun onCreateAppearancePreferences(rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.preferences_appearance, rootKey)
|
||||
|
||||
@@ -288,7 +386,13 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
}
|
||||
if (styleEnabled) {
|
||||
Stylish.assignStyle(styleIdString)
|
||||
activity.recreate()
|
||||
// Relaunch the current activity to redraw theme
|
||||
(activity as? SettingsActivity?)?.apply {
|
||||
keepCurrentScreen()
|
||||
startActivity(intent)
|
||||
finish()
|
||||
activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
}
|
||||
}
|
||||
styleEnabled
|
||||
}
|
||||
@@ -328,7 +432,6 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
activity?.let { activity ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
findPreference<SwitchPreference?>(getString(R.string.settings_autofill_enable_key))?.let { autoFillEnablePreference ->
|
||||
@@ -340,6 +443,11 @@ class NestedAppSettingsFragment : NestedSettingsFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
deleteKeysAlertDialog?.dismiss()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private var mCount = 0
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
@@ -35,7 +35,7 @@ import com.kunzisoft.keepass.crypto.keyDerivation.KdfEngine
|
||||
import com.kunzisoft.keepass.database.element.Database
|
||||
import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm
|
||||
import com.kunzisoft.keepass.database.element.security.EncryptionAlgorithm
|
||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService
|
||||
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService
|
||||
import com.kunzisoft.keepass.settings.preference.*
|
||||
import com.kunzisoft.keepass.settings.preferencedialogfragment.*
|
||||
import com.kunzisoft.keepass.tasks.ActionRunnable
|
||||
@@ -58,7 +58,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment() {
|
||||
private var mEncryptionAlgorithmPref: DialogListExplanationPreference? = null
|
||||
private var mKeyDerivationPref: DialogListExplanationPreference? = null
|
||||
private var mRoundPref: InputKdfNumberPreference? = null
|
||||
private var mMemoryPref: InputKdfNumberPreference? = null
|
||||
private var mMemoryPref: InputKdfSizePreference? = null
|
||||
private var mParallelismPref: InputKdfNumberPreference? = null
|
||||
|
||||
override fun onCreateScreenPreference(screen: Screen, savedInstanceState: Bundle?, rootKey: String?) {
|
||||
@@ -231,7 +231,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment() {
|
||||
}
|
||||
|
||||
// Memory Usage
|
||||
mMemoryPref = findPreference<InputKdfNumberPreference>(getString(R.string.memory_usage_key))?.apply {
|
||||
mMemoryPref = findPreference<InputKdfSizePreference>(getString(R.string.memory_usage_key))?.apply {
|
||||
summary = mDatabase.memoryUsage.toString()
|
||||
}
|
||||
|
||||
@@ -552,6 +552,13 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment() {
|
||||
settingActivity?.mProgressDatabaseTaskProvider?.startDatabaseSave(!mDatabaseReadOnly)
|
||||
true
|
||||
}
|
||||
R.id.menu_reload_database -> {
|
||||
settingActivity?.apply {
|
||||
keepCurrentScreen()
|
||||
mProgressDatabaseTaskProvider?.startDatabaseReload(false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Check the time lock before launching settings
|
||||
|
||||
@@ -35,9 +35,13 @@ abstract class NestedSettingsFragment : PreferenceFragmentCompat() {
|
||||
APPLICATION, FORM_FILLING, ADVANCED_UNLOCK, APPEARANCE, DATABASE, DATABASE_SECURITY, DATABASE_MASTER_KEY
|
||||
}
|
||||
|
||||
fun getScreen(): Screen {
|
||||
return Screen.values()[requireArguments().getInt(TAG_KEY)]
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
onCreateScreenPreference(
|
||||
Screen.values()[requireArguments().getInt(TAG_KEY)],
|
||||
getScreen(),
|
||||
savedInstanceState,
|
||||
rootKey)
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
*/
|
||||
package com.kunzisoft.keepass.settings
|
||||
|
||||
import android.app.backup.BackupManager
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.kunzisoft.keepass.BuildConfig
|
||||
import com.kunzisoft.keepass.R
|
||||
import com.kunzisoft.keepass.biometric.AdvancedUnlockManager
|
||||
import com.kunzisoft.keepass.database.element.SortNodeEnum
|
||||
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
||||
import java.util.*
|
||||
@@ -43,6 +45,7 @@ object PreferencesUtil {
|
||||
}
|
||||
apply()
|
||||
}
|
||||
BackupManager(context).dataChanged()
|
||||
}
|
||||
|
||||
fun getDefaultDatabasePath(context: Context): String? {
|
||||
@@ -201,6 +204,13 @@ object PreferencesUtil {
|
||||
?: TimeoutHelper.DEFAULT_TIMEOUT
|
||||
}
|
||||
|
||||
fun getAdvancedUnlockTimeout(context: Context): Long {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getString(context.getString(R.string.temp_advanced_unlock_timeout_key),
|
||||
context.getString(R.string.temp_advanced_unlock_timeout_default))?.toLong()
|
||||
?: TimeoutHelper.DEFAULT_TIMEOUT
|
||||
}
|
||||
|
||||
fun isLockDatabaseWhenScreenShutOffEnable(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getBoolean(context.getString(R.string.lock_database_screen_off_key),
|
||||
@@ -225,13 +235,38 @@ object PreferencesUtil {
|
||||
context.resources.getBoolean(R.bool.enable_auto_save_database_default))
|
||||
}
|
||||
|
||||
fun isBiometricUnlockEnable(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getBoolean(context.getString(R.string.biometric_unlock_enable_key),
|
||||
context.resources.getBoolean(R.bool.biometric_unlock_enable_default))
|
||||
fun isAdvancedUnlockEnable(context: Context): Boolean {
|
||||
return isBiometricUnlockEnable(context) || isDeviceCredentialUnlockEnable(context)
|
||||
}
|
||||
|
||||
fun isBiometricPromptAutoOpenEnable(context: Context): Boolean {
|
||||
fun isBiometricUnlockEnable(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val biometricSupported = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
||||
AdvancedUnlockManager.biometricUnlockSupported(context)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
return prefs.getBoolean(context.getString(R.string.biometric_unlock_enable_key),
|
||||
context.resources.getBoolean(R.bool.biometric_unlock_enable_default))
|
||||
&& biometricSupported
|
||||
}
|
||||
|
||||
fun isDeviceCredentialUnlockEnable(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
// Priority to biometric unlock
|
||||
val biometricAlreadySupported = isBiometricUnlockEnable(context)
|
||||
return prefs.getBoolean(context.getString(R.string.device_credential_unlock_enable_key),
|
||||
context.resources.getBoolean(R.bool.device_credential_unlock_enable_default))
|
||||
&& !biometricAlreadySupported
|
||||
}
|
||||
|
||||
fun isTempAdvancedUnlockEnable(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getBoolean(context.getString(R.string.temp_advanced_unlock_enable_key),
|
||||
context.resources.getBoolean(R.bool.temp_advanced_unlock_enable_default))
|
||||
}
|
||||
|
||||
fun isAdvancedUnlockPromptAutoOpenEnable(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getBoolean(context.getString(R.string.biometric_auto_open_prompt_key),
|
||||
context.resources.getBoolean(R.bool.biometric_auto_open_prompt_default))
|
||||
@@ -401,13 +436,18 @@ object PreferencesUtil {
|
||||
context.resources.getBoolean(R.bool.autofill_close_database_default))
|
||||
}
|
||||
|
||||
|
||||
fun isAutofillAutoSearchEnable(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getBoolean(context.getString(R.string.autofill_auto_search_key),
|
||||
context.resources.getBoolean(R.bool.autofill_auto_search_default))
|
||||
}
|
||||
|
||||
fun isAutofillInlineSuggestionsEnable(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getBoolean(context.getString(R.string.autofill_inline_suggestions_key),
|
||||
context.resources.getBoolean(R.bool.autofill_inline_suggestions_default))
|
||||
}
|
||||
|
||||
fun isAutofillSaveSearchInfoEnable(context: Context): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getBoolean(context.getString(R.string.autofill_save_search_info_key),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user