diff --git a/.gitignore b/.gitignore index d02280737..3cd74fd62 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ bin/ gen/ out/ +# Kotlin folder +.kotlin/ + # Gradle files .gradle/ build/ diff --git a/CHANGELOG b/CHANGELOG index 89ffb7d36..4734503f2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,13 @@ +KeePassDX(4.2.0) + * Passkeys management #1421 #2097 (@cali-95) + * Confirm usage of passkey #2165 #2124 + * Dialog to manage missing signature #2152 #2155 #2161 #2160 + * Capture error #2159 #2215 + * Change Passkey Backup Eligibility & Backup State #2135 #2150 #2212 + * Search settings #2112 #2181 #2187 #2204 + * Autofill refactoring #765 #2196 + * Small fixes #2157 #2164 #2171 #2122 #2180 #2209 #2214 + KeePassDX(4.1.9) * Fix landscape UI #2198 #2200 (@chenxiaolong) * Fix start loop and flash screen #2201 diff --git a/app/build.gradle b/app/build.gradle index 81802b957..895d76061 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.kunzisoft.keepass" minSdkVersion 19 targetSdkVersion 35 - versionCode = 143 - versionName = "4.1.9" + versionCode = 145 + versionName = "4.2.0" multiDexEnabled true testApplicationId = "com.kunzisoft.keepass.tests" @@ -35,6 +35,10 @@ android { } } + buildFeatures { + buildConfig true + } + dependenciesInfo { // Disables dependency metadata when building APKs. includeInApk = false @@ -101,6 +105,11 @@ android { buildFeatures { buildConfig true } + + packaging { + // Bouncy castle bug https://github.com/bcgit/bc-java/issues/1685 + resources.pickFirsts.add('META-INF/versions/9/OSGI-INF/MANIFEST.MF') + } } def room_version = "2.5.1" @@ -140,7 +149,10 @@ dependencies { implementation 'commons-codec:commons-codec:1.15' // Password generator implementation 'me.gosimple:nbvcxz:1.5.0' - + + // Credentials Provider + implementation "androidx.credentials:credentials:1.2.2" + // Modules import implementation project(path: ':database') implementation project(path: ':icon-pack') diff --git a/app/src/free/assets/passkeys_privileged_apps_community.json b/app/src/free/assets/passkeys_privileged_apps_community.json new file mode 100644 index 000000000..27eb8b5bc --- /dev/null +++ b/app/src/free/assets/passkeys_privileged_apps_community.json @@ -0,0 +1,64 @@ +{ + "apps": [ + { + "type": "android", + "info": { + "package_name": "io.github.forkmaintainers.iceraven", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:0D:22:37:9F:48:7B:70:A4:F9:F8:BE:C0:17:3C:F9:1A:16:44:F0:8F:93:38:5B:5B:78:2C:E3:76:60:BA:81" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.chromium.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A8:56:48:50:79:BC:B3:57:BF:BE:69:BA:19:A9:BA:43:CD:0A:D9:AB:22:67:52:C7:80:B6:88:8A:FD:48:21:6B" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.cromite.cromite", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "63:3F:A4:1D:82:11:D6:D0:91:6A:81:9B:89:66:8C:6D:E9:2E:64:23:2D:A6:7F:9D:16:FD:81:C3:B7:E9:23:FF" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.ironfoxoss.ironfox", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C5:E2:91:B5:A5:71:F9:C8:CD:9A:97:99:C2:C9:4E:02:EC:97:03:94:88:93:F2:CA:75:6D:67:B9:42:04:F9:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_fdroid", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "06:66:53:58:EF:D8:BA:05:BE:23:6A:47:A1:2C:B0:95:8D:7D:75:DD:93:9D:77:C2:B3:1F:53:98:53:7E:BD:C5" + } + ] + } + } + ] +} diff --git a/app/src/free/assets/passkeys_privileged_apps_google.json b/app/src/free/assets/passkeys_privileged_apps_google.json new file mode 100644 index 000000000..47ada3cdf --- /dev/null +++ b/app/src/free/assets/passkeys_privileged_apps_google.json @@ -0,0 +1,820 @@ +{ + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.android.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "DA:63:3D:34:B6:9E:63:AE:21:03:B4:9D:53:CE:05:2F:C5:F7:F3:C5:3A:AB:94:FD:C2:A2:08:BD:FD:14:24:9C" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "90:44:EE:5F:EE:4B:BC:5E:21:DD:44:66:54:31:C4:EB:1F:1F:71:A3:27:16:A0:BC:92:7B:CB:B3:92:33:CA:BF" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "20:19:DF:A1:FB:23:EF:BF:70:C5:BC:D1:44:3C:5B:EA:B0:4F:3F:2F:F4:36:6E:9A:C1:E3:45:76:39:A2:4C:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.chromium.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.google.android.apps.chrome", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_webauthndebug", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "BD:AE:82:02:80:D2:AF:B7:74:94:EF:22:58:AA:78:A9:AE:A1:36:41:7E:8B:C2:3D:C9:87:75:2E:6F:48:E8:48" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.focus", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_aurora", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BC:04:88:83:8D:06:F4:CA:6B:F3:23:86:DA:AB:0D:D8:EB:CF:3E:77:30:78:74:59:F6:2F:B3:CD:14:A1:BA:AA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.rocket", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "86:3A:46:F0:97:39:32:B7:D0:19:9B:54:91:12:74:1C:2D:27:31:AC:72:EA:11:B7:52:3A:A9:0A:11:BF:56:91" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fenix", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "50:04:77:90:88:E7:F9:88:D5:BC:5C:C5:F8:79:8F:EB:F4:F8:CD:08:4A:1B:2A:46:EF:D4:C8:EE:4A:EA:F2:11" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fenix.debug", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "BD:AE:82:02:80:D2:AF:B7:74:94:EF:22:58:AA:78:A9:AE:A1:36:41:7E:8B:C2:3D:C9:87:75:2E:6F:48:E8:48" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.focus.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.focus.nightly", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.klar", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.reference.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "B0:09:90:E3:0F:9D:81:5D:2E:BC:7B:9B:B2:21:CE:47:E5:C9:D5:17:AA:C7:0E:7F:D5:95:B1:E5:3E:9A:4B:14" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.rolling", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.local", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_nightly", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "app.vanadium.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser.snapshot", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser.sopranos", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.citrix.Receiver", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "3D:D1:12:67:10:69:AB:36:4E:F9:BE:73:9A:B7:B5:EE:15:E1:CD:E9:D8:75:7B:1B:F0:64:F5:0C:55:68:9A:49" + }, + { + "build": "release", + "cert_fingerprint_sha256": "CE:B2:23:D7:77:09:F2:B6:BC:0B:3A:78:36:F5:A5:AF:4C:E1:D3:55:F4:A7:28:86:F7:9D:F8:0D:C9:D6:12:2E" + }, + { + "build": "release", + "cert_fingerprint_sha256": "AA:D0:D4:57:E6:33:C3:78:25:77:30:5B:C1:B2:D9:E3:81:41:C7:21:DF:0D:AA:6E:29:07:2F:C4:1D:34:F0:AB" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.android.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C9:00:9D:01:EB:F9:F5:D0:30:2B:C7:1B:2F:E9:AA:9A:47:A4:32:BB:A1:73:08:A3:11:1B:75:D7:B2:14:90:25" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + }, + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + }, + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.google.android.gms", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "7C:E8:3C:1B:71:F3:D5:72:FE:D0:4C:8D:40:C5:CB:10:FF:75:E6:D8:7D:9D:F6:FB:D5:3F:04:68:C2:90:50:53" + }, + { + "build": "release", + "cert_fingerprint_sha256": "D2:2C:C5:00:29:9F:B2:28:73:A0:1A:01:0D:E1:C8:2F:BE:4D:06:11:19:B9:48:14:DD:30:1D:AB:50:CB:76:78" + }, + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "release", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.alpha", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.corp", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.broteam", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.talonsec.talon", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A3:66:03:44:A6:F6:AF:CA:81:8C:BF:43:96:A2:3C:CF:D5:ED:7A:78:1B:B4:A3:D1:85:03:01:E2:F4:6D:23:83" + }, + { + "build": "release", + "cert_fingerprint_sha256": "E2:A5:64:74:EA:23:7B:06:67:B6:F5:2C:DC:E9:04:5E:24:88:3B:AE:D0:82:59:9A:A2:DF:0B:60:3A:CF:6A:3B" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.talonsec.talon_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "F5:86:62:7A:32:C8:9F:E6:7E:00:6D:B1:8C:34:31:9E:01:7F:B3:B2:BE:D6:9D:01:01:B7:F9:43:E7:7C:48:AE" + }, + { + "build": "release", + "cert_fingerprint_sha256": "9A:A1:25:D5:E5:5E:3F:B0:DE:96:72:D9:A9:5D:04:65:3F:49:4A:1E:C3:EE:76:1E:94:C4:4E:5D:2F:65:8E:2F" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.duckduckgo.mobile.android.debug", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C4:F0:9E:2B:D7:25:AD:F5:AD:92:0B:A2:80:27:66:AC:16:4A:C1:53:B3:EA:9E:08:48:B0:57:98:37:F7:6A:29" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.duckduckgo.mobile.android", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BB:7B:B3:1C:57:3C:46:A1:DA:7F:C5:C5:28:A6:AC:F4:32:10:84:56:FE:EC:50:81:0C:7F:33:69:4E:B3:D2:D4" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.naver.whale", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "0B:8B:85:23:BB:4A:EF:FA:34:6E:4B:DD:4F:BF:7D:19:34:50:56:9A:A1:4A:AA:D4:AD:FD:94:A3:F7:B2:27:BB" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.fido.fido2client", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "FC:98:DA:E6:3A:D3:96:26:C8:C6:7F:BE:83:F2:F0:6F:74:93:2A:9C:D1:46:B9:2C:EC:FC:6A:04:7A:90:43:86" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.heytap.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AF:F8:A7:49:CF:0E:7D:75:44:65:D0:FB:FA:7B:8D:0C:64:5E:22:5C:10:C6:E2:32:AD:A0:D9:74:88:36:B8:E5" + }, + { + "build": "release", + "cert_fingerprint_sha256": "A8:FE:A4:CA:FB:93:32:DA:26:B8:E6:81:08:17:C1:DA:90:A5:03:0E:35:A6:0A:79:E0:6C:90:97:AA:C6:A4:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.Island", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "D9:C3:39:AC:9C:3A:EE:E1:75:1D:85:8C:35:D9:BA:C5:CC:87:B3:CE:76:30:93:F0:F5:10:64:F5:A2:F6:9B:04" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.IslandCanary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "90:17:13:23:45:6E:6F:39:CB:FD:CF:B2:56:BE:1D:CF:F3:BC:1C:59:8A:15:93:30:E4:97:73:D0:4C:B9:C9:05" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.IslandBeta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "35:31:83:1A:9E:2B:21:1D:E6:AA:C3:69:4B:45:83:6E:56:09:B9:D7:D0:04:C3:1B:21:87:40:FB:77:17:38:D1" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.IslandDev", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.island.intune", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C2:38:24:15:41:20:A0:8F:C3:95:42:AC:D8:2A:E9:24:94:78:80:1E:47:FD:6C:66:2B:18:1C:28:CA:7E:59:4E" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.island.canary.intune", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1E:16:74:BB:79:EA:09:FB:37:CF:9F:1B:07:1B:1D:51:8D:46:03:0E:D3:EE:F2:C1:4E:AD:93:9E:C6:EE:3A:4C" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.island.beta.intune", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "D2:5E:AD:F6:1C:E6:36:6C:A4:23:A4:7F:C4:DB:9B:8C:9C:8A:35:B4:B0:19:E8:D9:82:FB:D0:8A:D9:DB:49:5A" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "io.island.island.dev.intune", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "6C:65:BD:B0:33:F5:CE:B1:74:09:EF:F9:99:48:D5:58:9F:55:63:9A:63:78:D5:A5:00:EB:95:FC:01:BC:6D:44" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "net.quetta.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BE:FE:E7:31:12:6A:A5:6E:7E:FD:AE:AF:5E:F3:FA:EA:44:1C:19:CC:E0:CA:EC:42:6B:65:BB:F8:2C:59:46:80" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "F1:38:00:4F:38:04:51:D4:8A:05:2B:B3:A3:EF:17:24:23:D4:B0:D0:C8:A3:AA:DD:FB:DB:66:30:31:48:EC:A4" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "cz.seznam.sbrowser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "DB:95:40:66:10:78:83:6E:4E:B1:66:F6:9E:F4:07:30:9E:8D:AE:33:34:68:5E:C8:F6:FA:2F:13:81:B9:AC:F6" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.opera.mini.native", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "57:AC:BC:52:5F:1B:2E:BD:19:19:6C:D6:F0:14:39:7C:C9:10:FD:18:84:1E:0A:E8:50:FE:BC:3E:1E:59:3F:F2" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.opera.mini.native.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "57:AC:BC:52:5F:1B:2E:BD:19:19:6C:D6:F0:14:39:7C:C9:10:FD:18:84:1E:0A:E8:50:FE:BC:3E:1E:59:3F:F2" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 900abdea7..66cb1d194 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,7 +45,8 @@ android:resizeableActivity="true" android:supportsRtl="true" android:theme="@style/KeepassDXStyle.Night" - tools:targetApi="s"> + tools:targetApi="s" + tools:ignore="CredentialDependency"> @@ -158,25 +159,41 @@ android:label="@string/about" /> - + android:name="com.kunzisoft.keepass.settings.MagikeyboardSettingsActivity" + android:label="@string/keyboard_setting_label" + android:exported="true"> + + + + + + + android:name="com.kunzisoft.keepass.credentialprovider.activity.HardwareKeyActivity" + android:theme="@style/Theme.Transparent" + android:exported="false" + android:excludeFromRecents="true" /> + + android:exported="true" + android:excludeFromRecents="true"> @@ -192,14 +209,12 @@ - - - - - + android:name="com.kunzisoft.keepass.credentialprovider.activity.PasskeyLauncherActivity" + android:theme="@style/Theme.Transparent" + android:configChanges="keyboardHidden" + android:exported="false" + android:excludeFromRecents="true" + tools:targetApi="upside_down_cake" /> @@ -249,6 +264,22 @@ + + + + + + + diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/AboutActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/AboutActivity.kt index d117418f4..c07cc8c4a 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/AboutActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/AboutActivity.kt @@ -20,7 +20,6 @@ package com.kunzisoft.keepass.activities import android.content.pm.PackageManager.NameNotFoundException -import android.os.Build import android.os.Bundle import android.text.method.LinkMovementMethod import android.util.Log @@ -32,7 +31,7 @@ import androidx.core.text.HtmlCompat import com.kunzisoft.keepass.BuildConfig import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.stylish.StylishActivity -import com.kunzisoft.keepass.utils.UriUtil.isContributingUser +import com.kunzisoft.keepass.utils.AppUtil.isContributingUser import com.kunzisoft.keepass.utils.getPackageInfoCompat import org.joda.time.DateTime diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt deleted file mode 100644 index f69d1475f..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt +++ /dev/null @@ -1,287 +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 . - * - */ -package com.kunzisoft.keepass.activities - -import android.app.Activity -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.widget.Toast -import androidx.activity.result.ActivityResultLauncher -import androidx.annotation.RequiresApi -import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode -import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper -import com.kunzisoft.keepass.autofill.CompatInlineSuggestionsRequest -import com.kunzisoft.keepass.autofill.KeeAutofillService -import com.kunzisoft.keepass.database.ContextualDatabase -import com.kunzisoft.keepass.database.helper.SearchHelper -import com.kunzisoft.keepass.model.RegisterInfo -import com.kunzisoft.keepass.model.SearchInfo -import com.kunzisoft.keepass.utils.WebDomain -import com.kunzisoft.keepass.utils.getParcelableCompat -import com.kunzisoft.keepass.utils.getParcelableExtraCompat - -@RequiresApi(api = Build.VERSION_CODES.O) -class AutofillLauncherActivity : DatabaseModeActivity() { - - private var mAutofillActivityResultLauncher: ActivityResultLauncher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - AutofillHelper.buildActivityResultLauncher(this, true) - else null - - override fun applyCustomStyle(): Boolean { - return false - } - - override fun finishActivityIfReloadRequested(): Boolean { - return true - } - - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - - // Retrieve selection mode - EntrySelectionHelper.retrieveSpecialModeFromIntent(intent).let { specialMode -> - when (specialMode) { - SpecialMode.SELECTION -> { - intent.getBundleExtra(KEY_SELECTION_BUNDLE)?.let { bundle -> - // To pass extra inline request - var compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest? = null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - compatInlineSuggestionsRequest = bundle.getParcelableCompat(KEY_INLINE_SUGGESTION) - } - // Build search param - bundle.getParcelableCompat(KEY_SEARCH_INFO)?.let { searchInfo -> - WebDomain.getConcreteWebDomain( - this, - searchInfo.webDomain - ) { concreteWebDomain -> - // Pass extra for Autofill (EXTRA_ASSIST_STRUCTURE) - val assistStructure = AutofillHelper - .retrieveAutofillComponent(intent) - ?.assistStructure - val newAutofillComponent = if (assistStructure != null) { - AutofillComponent( - assistStructure, - compatInlineSuggestionsRequest - ) - } else { - null - } - searchInfo.webDomain = concreteWebDomain - launchSelection(database, newAutofillComponent, searchInfo) - } - } - } - // Remove bundle - intent.removeExtra(KEY_SELECTION_BUNDLE) - } - SpecialMode.REGISTRATION -> { - // To register info - val registerInfo = intent.getParcelableExtraCompat(KEY_REGISTER_INFO) - val searchInfo = SearchInfo(registerInfo?.searchInfo) - WebDomain.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain -> - searchInfo.webDomain = concreteWebDomain - launchRegistration(database, searchInfo, registerInfo) - } - } - else -> { - // Not an autofill call - setResult(Activity.RESULT_CANCELED) - finish() - } - } - } - } - - private fun launchSelection(database: ContextualDatabase?, - autofillComponent: AutofillComponent?, - searchInfo: SearchInfo) { - if (autofillComponent == null) { - setResult(Activity.RESULT_CANCELED) - finish() - } else if (KeeAutofillService.autofillAllowedFor( - applicationId = searchInfo.applicationId, - webDomain = searchInfo.webDomain, - context = this - )) { - // If database is open - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, items -> - // Items found - AutofillHelper.buildResponseAndSetResult(this, openedDatabase, items) - finish() - }, - { openedDatabase -> - // Show the database UI to select the entry - GroupActivity.launchForAutofillResult(this, - openedDatabase, - mAutofillActivityResultLauncher, - autofillComponent, - searchInfo, - false) - }, - { - // If database not open - FileDatabaseSelectActivity.launchForAutofillResult(this, - mAutofillActivityResultLauncher, - autofillComponent, - searchInfo) - } - ) - } else { - showBlockRestartMessage() - setResult(Activity.RESULT_CANCELED) - finish() - } - } - - private fun launchRegistration(database: ContextualDatabase?, - searchInfo: SearchInfo, - registerInfo: RegisterInfo?) { - if (KeeAutofillService.autofillAllowedFor( - applicationId = searchInfo.applicationId, - webDomain = searchInfo.webDomain, - context = this - )) { - val readOnly = database?.isReadOnly != false - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, _ -> - if (!readOnly) { - // Show the database UI to select the entry - GroupActivity.launchForRegistration(this, - openedDatabase, - registerInfo) - } else { - showReadOnlySaveMessage() - } - }, - { openedDatabase -> - if (!readOnly) { - // Show the database UI to select the entry - GroupActivity.launchForRegistration(this, - openedDatabase, - registerInfo) - } else { - showReadOnlySaveMessage() - } - }, - { - // If database not open - FileDatabaseSelectActivity.launchForRegistration(this, - registerInfo) - } - ) - } else { - showBlockRestartMessage() - setResult(Activity.RESULT_CANCELED) - } - finish() - } - - private fun showBlockRestartMessage() { - // If item not allowed, show a toast - Toast.makeText(this.applicationContext, R.string.autofill_block_restart, Toast.LENGTH_LONG).show() - } - - private fun showReadOnlySaveMessage() { - Toast.makeText(this.applicationContext, R.string.autofill_read_only_save, Toast.LENGTH_LONG).show() - } - - companion object { - - private val TAG = AutofillLauncherActivity::class.java.name - - private const val KEY_SELECTION_BUNDLE = "KEY_SELECTION_BUNDLE" - private const val KEY_SEARCH_INFO = "KEY_SEARCH_INFO" - private const val KEY_INLINE_SUGGESTION = "KEY_INLINE_SUGGESTION" - - private const val KEY_REGISTER_INFO = "KEY_REGISTER_INFO" - - fun getPendingIntentForSelection(context: Context, - searchInfo: SearchInfo? = null, - compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest? = null): PendingIntent? { - try { - return PendingIntent.getActivity( - context, 0, - // Doesn't work with direct extra Parcelable (don't know why?) - // Wrap into a bundle to bypass the problem - Intent(context, AutofillLauncherActivity::class.java).apply { - putExtra(KEY_SELECTION_BUNDLE, Bundle().apply { - putParcelable(KEY_SEARCH_INFO, searchInfo) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - putParcelable(KEY_INLINE_SUGGESTION, compatInlineSuggestionsRequest) - } - }) - }, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT - } else { - PendingIntent.FLAG_CANCEL_CURRENT - } - ) - } catch (e: RuntimeException) { - Log.e(TAG, "Unable to create pending intent for selection", e) - return null - } - } - - fun getPendingIntentForRegistration(context: Context, - registerInfo: RegisterInfo): PendingIntent? { - try { - return PendingIntent.getActivity( - context, 0, - Intent(context, AutofillLauncherActivity::class.java).apply { - EntrySelectionHelper.addSpecialModeInIntent(this, SpecialMode.REGISTRATION) - putExtra(KEY_REGISTER_INFO, registerInfo) - }, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT - } else { - PendingIntent.FLAG_CANCEL_CURRENT - } - ) - } catch (e: RuntimeException) { - Log.e(TAG, "Unable to create pending intent for registration", e) - return null - } - } - - fun launchForRegistration(context: Context, - registerInfo: RegisterInfo) { - val intent = Intent(context, AutofillLauncherActivity::class.java) - EntrySelectionHelper.addSpecialModeInIntent(intent, SpecialMode.REGISTRATION) - intent.putExtra(KEY_REGISTER_INFO, registerInfo) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } - } -} diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt index 186288919..e061a27c2 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt @@ -50,15 +50,15 @@ import com.google.android.material.tabs.TabLayout import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.fragments.EntryFragment import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.adapters.TagsAdapter +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.Attachment import com.kunzisoft.keepass.database.element.icon.IconImage import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.education.EntryActivityEducation -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.model.EntryAttachmentState import com.kunzisoft.keepass.otp.OtpType import com.kunzisoft.keepass.services.AttachmentFileNotificationService @@ -69,7 +69,7 @@ import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.tasks.ActionRunnable import com.kunzisoft.keepass.tasks.AttachmentFileBinderManager import com.kunzisoft.keepass.timeout.TimeoutHelper -import com.kunzisoft.keepass.utils.UuidUtil +import com.kunzisoft.keepass.utils.UUIDUtils.asHexString import com.kunzisoft.keepass.utils.getParcelableExtraCompat import com.kunzisoft.keepass.view.WindowInsetPosition import com.kunzisoft.keepass.view.applyWindowInsets @@ -264,7 +264,7 @@ class EntryActivity : DatabaseLockActivity() { mIcon = entryInfo.icon // Assign title text val entryTitle = - if (entryInfo.title.isNotEmpty()) entryInfo.title else UuidUtil.toHexString(entryInfo.id) + entryInfo.title.ifEmpty { entryInfo.id.asHexString() } collapsingToolbarLayout?.title = entryTitle toolbar?.title = entryTitle // Assign tags @@ -312,11 +312,11 @@ class EntryActivity : DatabaseLockActivity() { mEntryViewModel.historySelected.observe(this) { historySelected -> mDatabase?.let { database -> launch( - this, - database, - historySelected.nodeId, - historySelected.historyPosition, - mEntryActivityResultLauncher + activity = this, + database = database, + entryId = historySelected.nodeId, + historyPosition = historySelected.historyPosition, + activityResultLauncher = mEntryActivityResultLauncher ) } } @@ -330,9 +330,8 @@ class EntryActivity : DatabaseLockActivity() { return coordinatorLayout } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { super.onDatabaseRetrieved(database) - mEntryViewModel.loadDatabase(database) } @@ -478,11 +477,12 @@ class EntryActivity : DatabaseLockActivity() { R.id.menu_edit -> { mDatabase?.let { database -> mMainEntryId?.let { entryId -> - EntryEditActivity.launchToUpdate( - this, - database, - entryId, - mEntryActivityResultLauncher + EntryEditActivity.launch( + activity = this, + database = database, + registrationType = EntryEditActivity.RegistrationType.UPDATE, + nodeId = entryId, + activityResultLauncher = mEntryActivityResultLauncher ) } } @@ -520,7 +520,7 @@ class EntryActivity : DatabaseLockActivity() { // Transit data in previous Activity after an update Intent().apply { putExtra(EntryEditActivity.ADD_OR_UPDATE_ENTRY_KEY, mMainEntryId) - setResult(Activity.RESULT_OK, this) + setResult(RESULT_OK, this) } super.finish() } @@ -534,34 +534,22 @@ class EntryActivity : DatabaseLockActivity() { const val ENTRY_FRAGMENT_TAG = "ENTRY_FRAGMENT_TAG" /** - * Open standard Entry activity + * Open standard or history Entry activity */ - fun launch(activity: Activity, - database: ContextualDatabase, - entryId: NodeId, - activityResultLauncher: ActivityResultLauncher) { + fun launch( + activity: Activity, + database: ContextualDatabase, + entryId: NodeId, + historyPosition: Int? = null, + activityResultLauncher: ActivityResultLauncher + ) { if (database.loaded) { if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) { val intent = Intent(activity, EntryActivity::class.java) intent.putExtra(KEY_ENTRY, entryId) - activityResultLauncher.launch(intent) - } - } - } - - /** - * Open history Entry activity - */ - fun launch(activity: Activity, - database: ContextualDatabase, - entryId: NodeId, - historyPosition: Int, - activityResultLauncher: ActivityResultLauncher) { - if (database.loaded) { - if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) { - val intent = Intent(activity, EntryActivity::class.java) - intent.putExtra(KEY_ENTRY, entryId) - intent.putExtra(KEY_ENTRY_HISTORY_POSITION, historyPosition) + historyPosition?.let { + intent.putExtra(KEY_ENTRY_HISTORY_POSITION, historyPosition) + } activityResultLauncher.launch(intent) } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt index f5b2d6bcb..f339459fa 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt @@ -36,14 +36,14 @@ import android.widget.Spinner import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView -import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.snackbar.Snackbar import com.google.android.material.timepicker.MaterialTimePicker @@ -55,22 +55,24 @@ import com.kunzisoft.keepass.activities.dialogs.FileTooBigDialogFragment.Compani import com.kunzisoft.keepass.activities.dialogs.ReplaceFileDialogFragment import com.kunzisoft.keepass.activities.dialogs.SetOTPDialogFragment import com.kunzisoft.keepass.activities.fragments.EntryEditFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.adapters.TemplatesSelectorAdapter -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildSpecialModeResponseAndSetResult +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveRegisterInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveSearchInfo +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyResponseAndSetResult import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.Attachment import com.kunzisoft.keepass.database.element.DateInstant import com.kunzisoft.keepass.database.element.Entry import com.kunzisoft.keepass.database.element.Field -import com.kunzisoft.keepass.database.element.node.Node import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.database.element.template.Template import com.kunzisoft.keepass.education.EntryEditActivityEducation -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.model.AttachmentState import com.kunzisoft.keepass.model.DataTime import com.kunzisoft.keepass.model.EntryAttachmentState @@ -79,9 +81,9 @@ import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.otp.OtpElement 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_UPDATE_ENTRY_TASK +import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getNewEntry import com.kunzisoft.keepass.services.KeyboardEntryNotificationService import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.tasks.ActionRunnable @@ -100,6 +102,7 @@ import com.kunzisoft.keepass.view.showActionErrorIfNeeded import com.kunzisoft.keepass.view.updateLockPaddingStart import com.kunzisoft.keepass.viewmodels.ColorPickerViewModel import com.kunzisoft.keepass.viewmodels.EntryEditViewModel +import kotlinx.coroutines.launch import java.util.EnumSet import java.util.UUID @@ -155,9 +158,6 @@ class EntryEditActivity : DatabaseLockActivity(), } } - // To ask data lost only one time - private var backPressedAlreadyApproved = false - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_entry_edit) @@ -210,8 +210,8 @@ class EntryEditActivity : DatabaseLockActivity(), mDatabase, entryId, parentId, - EntrySelectionHelper.retrieveRegisterInfoFromIntent(intent), - EntrySelectionHelper.retrieveSearchInfoFromIntent(intent) + intent.retrieveRegisterInfo() + ?: intent.retrieveSearchInfo()?.toRegisterInfo() ) // To retrieve attachment @@ -378,23 +378,30 @@ class EntryEditActivity : DatabaseLockActivity(), } ?: run { updateEntry(entrySave.oldEntry, entrySave.newEntry) } + } - // Don't wait for saving if it's to provide autofill - mDatabase?.let { database -> - EntrySelectionHelper.doSpecialAction(intent, - {}, - {}, - {}, - { - entryValidatedForKeyboardSelection(database, entrySave.newEntry) - }, - { _, _ -> - entryValidatedForAutofillSelection(database, entrySave.newEntry) - }, - { - entryValidatedForAutofillRegistration(entrySave.newEntry) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mEntryEditViewModel.uiState.collect { uiState -> + when (uiState) { + EntryEditViewModel.UIState.Loading -> {} + EntryEditViewModel.UIState.ShowOverwriteMessage -> { + if (mEntryEditViewModel.warningOverwriteDataAlreadyApproved.not()) { + AlertDialog.Builder(this@EntryEditActivity) + .setTitle(R.string.warning_overwrite_data_title) + .setMessage(R.string.warning_overwrite_data_description) + .setNegativeButton(android.R.string.cancel) { _, _ -> + mEntryEditViewModel.backPressedAlreadyApproved = true + onCancelSpecialMode() + } + .setPositiveButton(android.R.string.ok) { _, _ -> + mEntryEditViewModel.warningOverwriteDataAlreadyApproved = true + } + .create().show() + } + } } - ) + } } } } @@ -407,13 +414,13 @@ class EntryEditActivity : DatabaseLockActivity(), return true } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { super.onDatabaseRetrieved(database) - mAllowCustomFields = database?.allowEntryCustomFields() == true - mAllowOTP = database?.allowOTP == true - mEntryEditViewModel.loadDatabase(database) + mAllowCustomFields = database.allowEntryCustomFields() == true + mAllowOTP = database.allowOTP == true + mEntryEditViewModel.loadTemplateEntry(database) mTemplatesSelectorAdapter?.apply { - iconDrawableFactory = mDatabase?.iconDrawableFactory + iconDrawableFactory = database.iconDrawableFactory notifyDataSetChanged() } } @@ -424,39 +431,45 @@ class EntryEditActivity : DatabaseLockActivity(), result: ActionRunnable.Result ) { super.onDatabaseActionFinished(database, actionTask, result) + mEntryEditViewModel.unlockAction() when (actionTask) { ACTION_DATABASE_CREATE_ENTRY_TASK, ACTION_DATABASE_UPDATE_ENTRY_TASK -> { try { if (result.isSuccess) { - var newNodes: List = ArrayList() - result.data?.getBundle(DatabaseTaskNotificationService.NEW_NODES_KEY)?.let { newNodesBundle -> - newNodes = DatabaseTaskNotificationService.getListNodesFromBundle(database, newNodesBundle) - } - if (newNodes.size == 1) { - (newNodes[0] as? Entry?)?.let { entry -> - EntrySelectionHelper.doSpecialAction(intent, - { - // Finish naturally - finishForEntryResult(entry) - }, - { - // Nothing when search retrieved - }, - { - entryValidatedForSave(entry) - }, - { - entryValidatedForKeyboardSelection(database, entry) - }, - { _, _ -> - entryValidatedForAutofillSelection(database, entry) - }, - { - entryValidatedForAutofillRegistration(entry) + result.data?.getNewEntry(database)?.let { entry -> + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = { + // Finish naturally + finishForEntryResult(entry) + }, + searchAction = { + // Nothing when search retrieved + }, + selectionAction = { intentSender, typeMode, searchInfo -> + when(typeMode) { + TypeMode.DEFAULT -> {} + TypeMode.MAGIKEYBOARD -> + entryValidatedForKeyboardSelection(database, entry) + TypeMode.PASSKEY -> + entryValidatedForPasskey(database, entry) + TypeMode.AUTOFILL -> + entryValidatedForAutofill(database, entry) } - ) - } + }, + registrationAction = { _, typeMode, _ -> + when(typeMode) { + TypeMode.DEFAULT -> + entryValidatedForSave(entry) + TypeMode.MAGIKEYBOARD -> {} + TypeMode.PASSKEY -> + entryValidatedForPasskey(database, entry) + TypeMode.AUTOFILL -> + entryValidatedForAutofill(database, entry) + } + } + ) } } } catch (e: Exception) { @@ -483,19 +496,25 @@ class EntryEditActivity : DatabaseLockActivity(), finishForEntryResult(entry) } - private fun entryValidatedForAutofillSelection(database: ContextualDatabase, entry: Entry) { + private fun entryValidatedForAutofill(database: ContextualDatabase, entry: Entry) { // Build Autofill response with the entry selected if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AutofillHelper.buildResponseAndSetResult(this@EntryEditActivity, - database, - entry.getEntryInfo(database)) + this.buildSpecialModeResponseAndSetResult( + entryInfo = entry.getEntryInfo(database), + extras = buildEntryResult(entry) + ) } onValidateSpecialMode() } - private fun entryValidatedForAutofillRegistration(entry: Entry) { + private fun entryValidatedForPasskey(database: ContextualDatabase, entry: Entry) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + this.buildPasskeyResponseAndSetResult( + entryInfo = entry.getEntryInfo(database), + extras = buildEntryResult(entry) // To update the previous screen + ) + } onValidateSpecialMode() - finishForEntryResult(entry) } override fun onResume() { @@ -729,13 +748,13 @@ class EntryEditActivity : DatabaseLockActivity(), } private fun onApprovedBackPressed(approved: () -> Unit) { - if (!backPressedAlreadyApproved) { + if (mEntryEditViewModel.backPressedAlreadyApproved.not()) { AlertDialog.Builder(this) .setMessage(R.string.discard_changes) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.discard) { _, _ -> mAttachmentFileBinderManager?.stopUploadAllAttachments() - backPressedAlreadyApproved = true + mEntryEditViewModel.backPressedAlreadyApproved = true approved.invoke() }.create().show() } else { @@ -743,14 +762,19 @@ class EntryEditActivity : DatabaseLockActivity(), } } + private fun buildEntryResult(entry: Entry): Bundle { + return Bundle().apply { + putParcelable(ADD_OR_UPDATE_ENTRY_KEY, entry.nodeId) + } + } + private fun finishForEntryResult(entry: Entry) { // Assign entry callback as a result try { - val bundle = Bundle() + val bundle = buildEntryResult(entry) val intentEntry = Intent() - bundle.putParcelable(ADD_OR_UPDATE_ENTRY_KEY, entry.nodeId) intentEntry.putExtras(bundle) - setResult(Activity.RESULT_OK, intentEntry) + setResult(RESULT_OK, intentEntry) super.finish() } catch (e: Exception) { // Exception when parcelable can't be done @@ -758,6 +782,10 @@ class EntryEditActivity : DatabaseLockActivity(), } } + enum class RegistrationType { + UPDATE, CREATE + } + companion object { private val TAG = EntryEditActivity::class.java.name @@ -767,23 +795,12 @@ class EntryEditActivity : DatabaseLockActivity(), const val KEY_PARENT = "parent" const val ADD_OR_UPDATE_ENTRY_KEY = "ADD_OR_UPDATE_ENTRY_KEY" - fun registerForEntryResult(fragment: Fragment, - entryAddedOrUpdatedListener: (NodeId?) -> Unit): ActivityResultLauncher { - return fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - entryAddedOrUpdatedListener.invoke( - result.data?.getParcelableExtraCompat(ADD_OR_UPDATE_ENTRY_KEY) - ) - } else { - entryAddedOrUpdatedListener.invoke(null) - } - } - } - - fun registerForEntryResult(activity: FragmentActivity, - entryAddedOrUpdatedListener: (NodeId?) -> Unit): ActivityResultLauncher { + fun registerForEntryResult( + activity: FragmentActivity, + entryAddedOrUpdatedListener: (NodeId?) -> Unit + ): ActivityResultLauncher { return activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { + if (result.resultCode == RESULT_OK) { entryAddedOrUpdatedListener.invoke( result.data?.getParcelableExtraCompat(ADD_OR_UPDATE_ENTRY_KEY) ) @@ -794,151 +811,78 @@ class EntryEditActivity : DatabaseLockActivity(), } /** - * Launch EntryEditActivity to update an existing entry by his [entryId] + * Launch EntryEditActivity to update an existing entry or to add a new entry in an existing group */ - fun launchToUpdate(activity: Activity, - database: ContextualDatabase, - entryId: NodeId, - activityResultLauncher: ActivityResultLauncher) { + fun launch( + activity: Activity, + database: ContextualDatabase, + registrationType: RegistrationType, + nodeId: NodeId<*>, + activityResultLauncher: ActivityResultLauncher + ) { if (database.loaded && !database.isReadOnly) { if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) { val intent = Intent(activity, EntryEditActivity::class.java) - intent.putExtra(KEY_ENTRY, entryId) + when (registrationType) { + RegistrationType.UPDATE -> intent.putExtra(KEY_ENTRY, nodeId) + RegistrationType.CREATE -> intent.putExtra(KEY_PARENT, nodeId) + } activityResultLauncher.launch(intent) } } } /** - * Launch EntryEditActivity to add a new entry in an existent group + * Launch EntryEditActivity to add a new entry in special selection */ - fun launchToCreate(activity: Activity, - database: ContextualDatabase, - groupId: NodeId<*>, - activityResultLauncher: ActivityResultLauncher) { - if (database.loaded && !database.isReadOnly) { - if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) { - val intent = Intent(activity, EntryEditActivity::class.java) - intent.putExtra(KEY_PARENT, groupId) - activityResultLauncher.launch(intent) - } - } - } - - fun launchToUpdateForSave(context: Context, - database: ContextualDatabase, - entryId: NodeId, - searchInfo: SearchInfo) { - if (database.loaded && !database.isReadOnly) { - if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { - val intent = Intent(context, EntryEditActivity::class.java) - intent.putExtra(KEY_ENTRY, entryId) - EntrySelectionHelper.startActivityForSaveModeResult( - context, - intent, - searchInfo - ) - } - } - } - - fun launchToCreateForSave(context: Context, - database: ContextualDatabase, - groupId: NodeId<*>, - searchInfo: SearchInfo) { + fun launchForSelection( + context: Context, + database: ContextualDatabase, + typeMode: TypeMode, + groupId: NodeId<*>, + searchInfo: SearchInfo? = null, + activityResultLauncher: ActivityResultLauncher? = null, + ) { if (database.loaded && !database.isReadOnly) { if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { val intent = Intent(context, EntryEditActivity::class.java) intent.putExtra(KEY_PARENT, groupId) - EntrySelectionHelper.startActivityForSaveModeResult( - context, - intent, - searchInfo + EntrySelectionHelper.startActivityForSelectionModeResult( + context = context, + intent = intent, + typeMode = typeMode, + searchInfo = searchInfo, + activityResultLauncher = activityResultLauncher ) } } } /** - * Launch EntryEditActivity to add a new entry in keyboard selection + * Launch EntryEditActivity to update an updated entry or register a new entry (from autofill) */ - fun launchForKeyboardSelectionResult(context: Context, - database: ContextualDatabase, - groupId: NodeId<*>, - searchInfo: SearchInfo? = null) { + fun launchForRegistration( + context: Context, + database: ContextualDatabase, + nodeId: NodeId<*>, + registerInfo: RegisterInfo? = null, + typeMode: TypeMode, + registrationType: RegistrationType, + activityResultLauncher: ActivityResultLauncher? = null, + ) { if (database.loaded && !database.isReadOnly) { if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { val intent = Intent(context, EntryEditActivity::class.java) - intent.putExtra(KEY_PARENT, groupId) - EntrySelectionHelper.startActivityForKeyboardSelectionModeResult( + when (registrationType) { + RegistrationType.UPDATE -> intent.putExtra(KEY_ENTRY, nodeId) + RegistrationType.CREATE -> intent.putExtra(KEY_PARENT, nodeId) + } + EntrySelectionHelper.startActivityForRegistrationModeResult( context, - intent, - searchInfo - ) - } - } - } - - /** - * Launch EntryEditActivity to add a new entry in autofill selection - */ - @RequiresApi(api = Build.VERSION_CODES.O) - fun launchForAutofillResult(activity: AppCompatActivity, - database: ContextualDatabase, - activityResultLauncher: ActivityResultLauncher?, - autofillComponent: AutofillComponent, - groupId: NodeId<*>, - searchInfo: SearchInfo? = null) { - if (database.loaded && !database.isReadOnly) { - if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) { - val intent = Intent(activity, EntryEditActivity::class.java) - intent.putExtra(KEY_PARENT, groupId) - AutofillHelper.startActivityForAutofillResult( - activity, - intent, activityResultLauncher, - autofillComponent, - searchInfo - ) - } - } - } - - /** - * Launch EntryEditActivity to register an updated entry (from autofill) - */ - fun launchToUpdateForRegistration(context: Context, - database: ContextualDatabase, - entryId: NodeId, - registerInfo: RegisterInfo? = null) { - if (database.loaded && !database.isReadOnly) { - if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { - val intent = Intent(context, EntryEditActivity::class.java) - intent.putExtra(KEY_ENTRY, entryId) - EntrySelectionHelper.startActivityForRegistrationModeResult( - context, intent, - registerInfo - ) - } - } - } - - /** - * Launch EntryEditActivity to register a new entry (from autofill) - */ - fun launchToCreateForRegistration(context: Context, - database: ContextualDatabase, - groupId: NodeId<*>, - registerInfo: RegisterInfo? = null) { - if (database.loaded && !database.isReadOnly) { - if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { - val intent = Intent(context, EntryEditActivity::class.java) - intent.putExtra(KEY_PARENT, groupId) - EntrySelectionHelper.startActivityForRegistrationModeResult( - context, - intent, - registerInfo + registerInfo, + typeMode ) } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt index dffc3c586..3c8ebc179 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt @@ -19,7 +19,6 @@ */ package com.kunzisoft.keepass.activities -import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri @@ -33,8 +32,6 @@ import android.view.MenuItem import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.activity.viewModels -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.isVisible @@ -44,15 +41,14 @@ import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.snackbar.Snackbar import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.dialogs.SetMainCredentialDialogFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.activities.helpers.setOpenDocumentClickListener import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity 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.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.education.FileDatabaseSelectActivityEducation @@ -65,10 +61,10 @@ import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion. import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.DATABASE_URI_KEY import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.tasks.ActionRunnable +import com.kunzisoft.keepass.utils.AppUtil.isContributingUser import com.kunzisoft.keepass.utils.DexUtil import com.kunzisoft.keepass.utils.MagikeyboardUtil import com.kunzisoft.keepass.utils.MenuUtil -import com.kunzisoft.keepass.utils.UriUtil.isContributingUser import com.kunzisoft.keepass.utils.UriUtil.openUrl import com.kunzisoft.keepass.utils.getParcelableCompat import com.kunzisoft.keepass.view.asError @@ -98,11 +94,6 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), private var mExternalFileHelper: ExternalFileHelper? = null - private var mAutofillActivityResultLauncher: ActivityResultLauncher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - AutofillHelper.buildActivityResultLauncher(this) - else null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -132,7 +123,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), mExternalFileHelper = ExternalFileHelper(this) mExternalFileHelper?.buildOpenDocument { uri -> uri?.let { - launchPasswordActivityWithPath(uri) + launchMainCredentialActivityWithPath(uri) } } mExternalFileHelper?.buildCreateDocument("application/x-keepass") { databaseFileCreatedUri -> @@ -161,7 +152,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), } mAdapterDatabaseHistory?.setOnFileDatabaseHistoryOpenListener { fileDatabaseHistoryEntityToOpen -> fileDatabaseHistoryEntityToOpen.databaseUri?.let { databaseFileUri -> - launchPasswordActivity( + launchMainCredentialActivity( databaseFileUri, fileDatabaseHistoryEntityToOpen.keyFileUri, fileDatabaseHistoryEntityToOpen.hardwareKey @@ -180,7 +171,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), // Load default database the first time databaseFilesViewModel.doForDefaultDatabase { databaseFileUri -> - launchPasswordActivityWithPath(databaseFileUri) + launchMainCredentialActivityWithPath(databaseFileUri) } // Retrieve the database URI provided by file manager after an orientation change @@ -225,11 +216,8 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - if (database != null) { - launchGroupActivityIfLoaded(database) - } + override fun onDatabaseRetrieved(database: ContextualDatabase) { + launchGroupActivityIfLoaded(database) } override fun onDatabaseActionFinished( @@ -237,8 +225,6 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), actionTask: String, result: ActionRunnable.Result ) { - super.onDatabaseActionFinished(database, actionTask, result) - if (result.isSuccess) { // Update list when (actionTask) { @@ -288,17 +274,58 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), Snackbar.make(coordinatorLayout, error, Snackbar.LENGTH_LONG).asError().show() } - private fun launchPasswordActivity(databaseUri: Uri, keyFile: Uri?, hardwareKey: HardwareKey?) { - MainCredentialActivity.launch(this, - databaseUri, - keyFile, - hardwareKey, - { exception -> - fileNoFoundAction(exception) + private fun launchMainCredentialActivity(databaseUri: Uri, keyFile: Uri?, hardwareKey: HardwareKey?) { + try { + EntrySelectionHelper.doSpecialAction( + intent = this.intent, + defaultAction = { + MainCredentialActivity.launch( + activity = this, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey + ) }, - { onCancelSpecialMode() }, - { onLaunchActivitySpecialMode() }, - mAutofillActivityResultLauncher) + searchAction = { searchInfo -> + MainCredentialActivity.launchForSearchResult( + activity = this, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + }, + selectionAction = { intentSenderMode, typeMode, searchInfo -> + MainCredentialActivity.launchForSelection( + activity = this, + activityResultLauncher = if (intentSenderMode) + mCredentialActivityResultLauncher else null, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + typeMode = typeMode, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + }, + registrationAction = { intentSenderMode, typeMode, registerInfo -> + MainCredentialActivity.launchForRegistration( + activity = this, + activityResultLauncher = if (intentSenderMode) + mCredentialActivityResultLauncher else null, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + typeMode = typeMode, + registerInfo = registerInfo + ) + onLaunchActivitySpecialMode() + } + ) + } catch (e: FileNotFoundException) { + fileNoFoundAction(e) + } } private fun launchGroupActivityIfLoaded(database: ContextualDatabase) { @@ -308,12 +335,13 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), { onValidateSpecialMode() }, { onCancelSpecialMode() }, { onLaunchActivitySpecialMode() }, - mAutofillActivityResultLauncher) + mCredentialActivityResultLauncher + ) } } - private fun launchPasswordActivityWithPath(databaseUri: Uri) { - launchPasswordActivity(databaseUri, null, null) + private fun launchMainCredentialActivityWithPath(databaseUri: Uri) { + launchMainCredentialActivity(databaseUri, null, null) // Delete flickering for kitkat <= @Suppress("DEPRECATION") if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) @@ -337,10 +365,6 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), } } - mDatabase?.let { database -> - launchGroupActivityIfLoaded(database) - } - // Show recent files if allowed if (PreferencesUtil.showRecentFiles(this@FileDatabaseSelectActivity)) { databaseFilesViewModel.loadListOfDatabases() @@ -359,7 +383,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), try { mDatabaseFileUri?.let { databaseUri -> // Create the new database - createDatabase(databaseUri, mainCredential) + mDatabaseViewModel.createDatabase(databaseUri, mainCredential) } } catch (e: Exception) { val error = getString(R.string.error_create_database_file) @@ -443,55 +467,36 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), * ------------------------- */ - fun launchForSearchResult(context: Context, - searchInfo: SearchInfo) { - EntrySelectionHelper.startActivityForSearchModeResult(context, - Intent(context, FileDatabaseSelectActivity::class.java), - searchInfo) + fun launchForSearchResult( + context: Context, + searchInfo: SearchInfo + ) { + EntrySelectionHelper.startActivityForSearchModeResult( + context = context, + intent = Intent(context, FileDatabaseSelectActivity::class.java), + searchInfo = searchInfo + ) } /* * ------------------------- - * Save Launch + * Selection Launch * ------------------------- */ - fun launchForSaveResult(context: Context, - searchInfo: SearchInfo) { - EntrySelectionHelper.startActivityForSaveModeResult(context, - Intent(context, FileDatabaseSelectActivity::class.java), - searchInfo) - } - - /* - * ------------------------- - * Keyboard Launch - * ------------------------- - */ - - fun launchForKeyboardSelectionResult(activity: Activity, - searchInfo: SearchInfo? = null) { - EntrySelectionHelper.startActivityForKeyboardSelectionModeResult(activity, - Intent(activity, FileDatabaseSelectActivity::class.java), - searchInfo) - } - - /* - * ------------------------- - * Autofill Launch - * ------------------------- - */ - - @RequiresApi(api = Build.VERSION_CODES.O) - fun launchForAutofillResult(activity: AppCompatActivity, - activityResultLauncher: ActivityResultLauncher?, - autofillComponent: AutofillComponent, - searchInfo: SearchInfo? = null) { - AutofillHelper.startActivityForAutofillResult(activity, - Intent(activity, FileDatabaseSelectActivity::class.java), - activityResultLauncher, - autofillComponent, - searchInfo) + fun launchForSelection( + context: Context, + typeMode: TypeMode, + searchInfo: SearchInfo? = null, + activityResultLauncher: ActivityResultLauncher? = null, + ) { + EntrySelectionHelper.startActivityForSelectionModeResult( + context = context, + intent = Intent(context, FileDatabaseSelectActivity::class.java), + searchInfo = searchInfo, + typeMode = typeMode, + activityResultLauncher = activityResultLauncher + ) } /* @@ -499,11 +504,19 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), * Registration Launch * ------------------------- */ - fun launchForRegistration(context: Context, - registerInfo: RegisterInfo? = null) { - EntrySelectionHelper.startActivityForRegistrationModeResult(context, - Intent(context, FileDatabaseSelectActivity::class.java), - registerInfo) + fun launchForRegistration( + context: Context, + typeMode: TypeMode, + registerInfo: RegisterInfo? = null, + activityResultLauncher: ActivityResultLauncher?, + ) { + EntrySelectionHelper.startActivityForRegistrationModeResult( + context = context, + activityResultLauncher = activityResultLauncher, + intent = Intent(context, FileDatabaseSelectActivity::class.java), + registerInfo = registerInfo, + typeMode = typeMode + ) } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt index c82207b3e..f0bba2b56 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt @@ -18,7 +18,6 @@ */ package com.kunzisoft.keepass.activities -import android.app.Activity import android.app.SearchManager import android.content.ComponentName import android.content.Context @@ -39,10 +38,8 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView -import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.viewModels -import androidx.annotation.RequiresApi import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode @@ -64,13 +61,19 @@ import com.kunzisoft.keepass.activities.dialogs.GroupEditDialogFragment import com.kunzisoft.keepass.activities.dialogs.MainCredentialDialogFragment import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment import com.kunzisoft.keepass.activities.fragments.GroupFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.adapters.BreadcrumbAdapter -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSearchInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildSpecialModeResponseAndSetResult +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.removeInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.removeModes +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveSearchInfo +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyResponseAndSetResult import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.element.DateInstant @@ -81,17 +84,17 @@ import com.kunzisoft.keepass.database.element.node.Node import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.database.element.node.NodeIdUUID import com.kunzisoft.keepass.database.element.node.Type +import com.kunzisoft.keepass.database.exception.RegisterInReadOnlyDatabaseException import com.kunzisoft.keepass.database.helper.SearchHelper +import com.kunzisoft.keepass.database.helper.SearchHelper.getSearchParametersFromSearchInfo import com.kunzisoft.keepass.database.search.SearchParameters import com.kunzisoft.keepass.education.GroupActivityEducation -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.model.DataTime import com.kunzisoft.keepass.model.GroupInfo import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK -import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.NEW_NODES_KEY -import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getListNodesFromBundle +import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getNewEntry import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.settings.SettingsActivity import com.kunzisoft.keepass.tasks.ActionRunnable @@ -114,6 +117,7 @@ import com.kunzisoft.keepass.view.applyWindowInsets import com.kunzisoft.keepass.view.hideByFading import com.kunzisoft.keepass.view.setTransparentNavigationBar import com.kunzisoft.keepass.view.showActionErrorIfNeeded +import com.kunzisoft.keepass.view.toastError import com.kunzisoft.keepass.view.updateLockPaddingStart import com.kunzisoft.keepass.viewmodels.GroupEditViewModel import com.kunzisoft.keepass.viewmodels.GroupViewModel @@ -170,6 +174,7 @@ class GroupActivity : DatabaseLockActivity(), // Manage group private var mSearchState: SearchState? = null private var mAutoSearch: Boolean = false // To mainly manage keyboard + private var mTempSearchInfo: Boolean = false // To manage temp search private var mMainGroupState: GroupState? = null // Group state, not a search private var mRootGroup: Group? = null // Root group in the tree private var mMainGroup: Group? = null // Main group currently in memory @@ -211,6 +216,7 @@ class GroupActivity : DatabaseLockActivity(), private val mOnSearchActionExpandListener = object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(p0: MenuItem): Boolean { searchFiltersView?.visibility = View.VISIBLE + searchFiltersView?.showSearchExpandButton(!mTempSearchInfo) searchView?.setOnQueryTextListener(mOnSearchQueryTextListener) searchFiltersView?.onParametersChangeListener = mOnSearchFiltersChangeListener @@ -255,6 +261,7 @@ class GroupActivity : DatabaseLockActivity(), private fun removeSearch() { mSearchState = null + mTempSearchInfo = false intent.removeExtra(AUTO_SEARCH_KEY) if (Intent.ACTION_SEARCH == intent.action) { intent.action = Intent.ACTION_DEFAULT @@ -267,11 +274,6 @@ class GroupActivity : DatabaseLockActivity(), mGroupEditViewModel.selectIcon(icon) } - private var mAutofillActivityResultLauncher: ActivityResultLauncher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - AutofillHelper.buildActivityResultLauncher(this) - else null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -499,59 +501,44 @@ class GroupActivity : DatabaseLockActivity(), addNodeButtonView?.setAddEntryClickListener { mDatabase?.let { database -> mMainGroup?.let { currentGroup -> - EntrySelectionHelper.doSpecialAction(intent, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = { mMainGroup?.nodeId?.let { currentParentGroupId -> - EntryEditActivity.launchToCreate( - this@GroupActivity, - database, - currentParentGroupId, - mEntryActivityResultLauncher + EntryEditActivity.launch( + activity = this@GroupActivity, + database = database, + registrationType = EntryEditActivity.RegistrationType.CREATE, + nodeId = currentParentGroupId, + activityResultLauncher = mEntryActivityResultLauncher ) } }, - { + searchAction = { // Search not used }, - { searchInfo -> - EntryEditActivity.launchToCreateForSave( - this@GroupActivity, - database, - currentGroup.nodeId, - searchInfo + selectionAction = { intentSenderMode, typeMode, searchInfo -> + EntryEditActivity.launchForSelection( + context = this@GroupActivity, + database = database, + typeMode = typeMode, + groupId = currentGroup.nodeId, + searchInfo = searchInfo, + activityResultLauncher = if (intentSenderMode) + mCredentialActivityResultLauncher else null ) onLaunchActivitySpecialMode() }, - { searchInfo -> - EntryEditActivity.launchForKeyboardSelectionResult( - this@GroupActivity, - database, - currentGroup.nodeId, - searchInfo - ) - onLaunchActivitySpecialMode() - }, - { searchInfo, autofillComponent -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - EntryEditActivity.launchForAutofillResult( - this@GroupActivity, - database, - mAutofillActivityResultLauncher, - autofillComponent, - currentGroup.nodeId, - searchInfo - ) - onLaunchActivitySpecialMode() - } else { - onCancelSpecialMode() - } - }, - { searchInfo -> - EntryEditActivity.launchToCreateForRegistration( - this@GroupActivity, - database, - currentGroup.nodeId, - searchInfo + registrationAction = { intentSenderMode, typeMode, registerInfo -> + EntryEditActivity.launchForRegistration( + context = this@GroupActivity, + database = database, + nodeId = currentGroup.nodeId, + registerInfo = registerInfo, + typeMode = typeMode, + registrationType = EntryEditActivity.RegistrationType.CREATE, + activityResultLauncher = if (intentSenderMode) + mCredentialActivityResultLauncher else null ) onLaunchActivitySpecialMode() } @@ -595,7 +582,7 @@ class GroupActivity : DatabaseLockActivity(), } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { super.onDatabaseRetrieved(database) mBreadcrumbAdapter = BreadcrumbAdapter(this, database).apply { @@ -635,18 +622,16 @@ class GroupActivity : DatabaseLockActivity(), adapter = mBreadcrumbAdapter } - mGroupEditViewModel.setGroupNamesNotAllowed(database?.groupNamesNotAllowed) + mGroupEditViewModel.setGroupNamesNotAllowed(database.groupNamesNotAllowed) mRecyclingBinEnabled = !mDatabaseReadOnly - && database?.isRecycleBinEnabled == true + && database.isRecycleBinEnabled == true - mRootGroup = database?.rootGroup + mRootGroup = database.rootGroup loadGroup() // Update view - database?.let { - mBreadcrumbAdapter?.iconDrawableFactory = it.iconDrawableFactory - } + mBreadcrumbAdapter?.iconDrawableFactory = database.iconDrawableFactory refreshDatabaseViews() invalidateOptionsMenu() } @@ -684,9 +669,7 @@ class GroupActivity : DatabaseLockActivity(), var entry: Entry? = null try { - result.data?.getBundle(NEW_NODES_KEY)?.let { newNodesBundle -> - entry = getListNodesFromBundle(database, newNodesBundle)[0] as Entry - } + entry = result.data?.getNewEntry(database) } catch (e: Exception) { Log.e(TAG, "Unable to retrieve entry action for selection", e) } @@ -694,30 +677,30 @@ class GroupActivity : DatabaseLockActivity(), when (actionTask) { ACTION_DATABASE_UPDATE_ENTRY_TASK -> { if (result.isSuccess) { - EntrySelectionHelper.doSpecialAction(intent, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = { // Standard not used after task }, - { + searchAction = { // Search not used }, - { + selectionAction = { intentSenderMode, typeMode, searchInfo -> + when (typeMode) { + TypeMode.DEFAULT -> {} + TypeMode.MAGIKEYBOARD -> entry?.let { + entrySelectedForKeyboardSelection(database, it) + } + TypeMode.PASSKEY -> entry?.let { + entrySelectedForPasskeySelection(database, it) + } + TypeMode.AUTOFILL -> entry?.let { + entrySelectedForAutofillSelection(database, it) + } + } + }, + registrationAction = { intentSenderMode, typeMode, searchInfo -> // Save not used - }, - { - // Keyboard selection - entry?.let { - entrySelectedForKeyboardSelection(database, it) - } - }, - { _, _ -> - // Autofill selection - entry?.let { - entrySelectedForAutofillSelection(database, it) - } - }, - { - // Not use } ) } @@ -731,37 +714,34 @@ class GroupActivity : DatabaseLockActivity(), finishNodeAction() } - /** - * Transform the AUTO_SEARCH_KEY in ACTION_SEARCH, return true if AUTO_SEARCH_KEY was present - */ - private fun transformSearchInfoIntent(intent: Intent) { - // To relaunch the activity as ACTION_SEARCH - val searchInfo: SearchInfo? = EntrySelectionHelper.retrieveSearchInfoFromIntent(intent) - val autoSearch = intent.getBooleanExtra(AUTO_SEARCH_KEY, false) - intent.removeExtra(AUTO_SEARCH_KEY) - if (searchInfo != null && autoSearch) { - intent.action = Intent.ACTION_SEARCH - intent.putExtra(SearchManager.QUERY, searchInfo.toString()) - } - } - private fun manageIntent(intent: Intent?) { intent?.let { if (intent.extras?.containsKey(GROUP_STATE_KEY) == true) { mMainGroupState = intent.getParcelableExtraCompat(GROUP_STATE_KEY) intent.removeExtra(GROUP_STATE_KEY) } - // To transform KEY_SEARCH_INFO in ACTION_SEARCH - transformSearchInfoIntent(intent) + // To get the form filling search as temp search + val searchInfo: SearchInfo? = intent.retrieveSearchInfo() + val autoSearch = intent.getBooleanExtra(AUTO_SEARCH_KEY, false) // Get search query - if (intent.action == Intent.ACTION_SEARCH) { + if (searchInfo != null && autoSearch) { mAutoSearch = true - val stringQuery = intent.getStringExtra(SearchManager.QUERY)?.trim { it <= ' ' } ?: "" - intent.action = Intent.ACTION_DEFAULT - intent.removeExtra(SearchManager.QUERY) - mSearchState = SearchState(PreferencesUtil.getDefaultSearchParameters(this).apply { - searchQuery = stringQuery - }, mSearchState?.firstVisibleItem ?: 0) + mTempSearchInfo = true + searchInfo.getSearchParametersFromSearchInfo(this) { + mSearchState = SearchState( + searchParameters = it, + firstVisibleItem = mSearchState?.firstVisibleItem ?: 0 + ) + } + } else if (intent.action == Intent.ACTION_SEARCH) { + mAutoSearch = true + mSearchState = SearchState( + searchParameters = PreferencesUtil.getDefaultSearchParameters(this).apply { + searchQuery = intent.getStringExtra(SearchManager.QUERY) + ?.trim { it <= ' ' } ?: "" + }, + firstVisibleItem = mSearchState?.firstVisibleItem ?: 0 + ) } else if (mRequestStartupSearch && PreferencesUtil.automaticallyFocusSearch(this@GroupActivity)) { // Expand the search view if defined in settings @@ -769,6 +749,8 @@ class GroupActivity : DatabaseLockActivity(), mRequestStartupSearch = false addSearch() } + intent.action = Intent.ACTION_DEFAULT + intent.removeExtra(SearchManager.QUERY) } } @@ -793,8 +775,9 @@ class GroupActivity : DatabaseLockActivity(), // Assign title if (group?.isVirtual == true) { searchFiltersView?.setNumbers(group.numberOfChildEntries) - searchFiltersView?.setCurrentGroupText(mMainGroup?.title ?: "") + searchFiltersView?.setCurrentGroupText(mMainGroup?.title ?: getString(R.string.search)) searchFiltersView?.availableOther(mDatabase?.allowEntryCustomFields() ?: false) + searchFiltersView?.availableApplicationIds(mDatabase?.allowEntryCustomFields() ?: false) searchFiltersView?.availableTags(mDatabase?.allowTags() ?: false) searchFiltersView?.enableTags(mDatabase?.tagPool?.isNotEmpty() ?: false) searchFiltersView?.availableSearchableGroup(mDatabase?.allowCustomSearchableGroup() ?: false) @@ -861,49 +844,65 @@ class GroupActivity : DatabaseLockActivity(), Type.ENTRY -> try { val entryVersioned = node as Entry - EntrySelectionHelper.doSpecialAction(intent, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = { EntryActivity.launch( - this@GroupActivity, - database, - entryVersioned.nodeId, - mEntryActivityResultLauncher + activity = this@GroupActivity, + database = database, + entryId = entryVersioned.nodeId, + activityResultLauncher = mEntryActivityResultLauncher ) // Do not reload group here }, - { + searchAction = { // Nothing here, a search is simply performed }, - { searchInfo -> - if (!database.isReadOnly) { - entrySelectedForSave(database, entryVersioned, searchInfo) - loadGroup() - } else - finish() - }, - { searchInfo -> - if (!database.isReadOnly - && searchInfo != null - && PreferencesUtil.isKeyboardSaveSearchInfoEnable(this@GroupActivity) - ) { - updateEntryWithSearchInfo(database, entryVersioned, searchInfo) + selectionAction = { intentSenderMode, typeMode, searchInfo -> + when (typeMode) { + TypeMode.DEFAULT -> {} + TypeMode.MAGIKEYBOARD -> { + if (!database.isReadOnly + && searchInfo != null + && PreferencesUtil.isKeyboardSaveSearchInfoEnable(this@GroupActivity) + ) { + updateEntryWithRegisterInfo( + database, + entryVersioned, + searchInfo.toRegisterInfo() + ) + } + entrySelectedForKeyboardSelection(database, entryVersioned) + } + TypeMode.PASSKEY -> { + entrySelectedForPasskeySelection(database, entryVersioned) + } + TypeMode.AUTOFILL -> { + if (!database.isReadOnly + && searchInfo != null + && PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity) + ) { + updateEntryWithRegisterInfo( + database, + entryVersioned, + searchInfo.toRegisterInfo() + ) + } + entrySelectedForAutofillSelection(database, entryVersioned) + } } - entrySelectedForKeyboardSelection(database, entryVersioned) loadGroup() }, - { searchInfo, _ -> - if (!database.isReadOnly - && searchInfo != null - && PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity) - ) { - updateEntryWithSearchInfo(database, entryVersioned, searchInfo) - } - entrySelectedForAutofillSelection(database, entryVersioned) - loadGroup() - }, - { registerInfo -> + registrationAction = { intentSenderMode, typeMode, registerInfo -> if (!database.isReadOnly) { - entrySelectedForRegistration(database, entryVersioned, registerInfo) + entrySelectedForRegistration( + database = database, + entry = entryVersioned, + registerInfo = registerInfo, + typeMode = typeMode, + activityResultLauncher = if (intentSenderMode) + mCredentialActivityResultLauncher else null + ) loadGroup() } else finish() @@ -914,18 +913,6 @@ class GroupActivity : DatabaseLockActivity(), } } - private fun entrySelectedForSave(database: ContextualDatabase, entry: Entry, searchInfo: SearchInfo) { - removeSearch() - // Save to update the entry - EntryEditActivity.launchToUpdateForSave( - this@GroupActivity, - database, - entry.nodeId, - searchInfo - ) - onLaunchActivitySpecialMode() - } - private fun entrySelectedForKeyboardSelection(database: ContextualDatabase, entry: Entry) { removeSearch() // Populate Magikeyboard with entry @@ -940,10 +927,17 @@ class GroupActivity : DatabaseLockActivity(), removeSearch() // Build response with the entry selected if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AutofillHelper.buildResponseAndSetResult( - this, - database, - entry.getEntryInfo(database) + this.buildSpecialModeResponseAndSetResult(entry.getEntryInfo(database)) + } + onValidateSpecialMode() + } + + private fun entrySelectedForPasskeySelection(database: ContextualDatabase, entry: Entry) { + removeSearch() + // Build response with the entry selected + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + buildPasskeyResponseAndSetResult( + entryInfo = entry.getEntryInfo(database) ) } onValidateSpecialMode() @@ -952,23 +946,28 @@ class GroupActivity : DatabaseLockActivity(), private fun entrySelectedForRegistration( database: ContextualDatabase, entry: Entry, - registerInfo: RegisterInfo? + activityResultLauncher: ActivityResultLauncher?, + registerInfo: RegisterInfo?, + typeMode: TypeMode ) { removeSearch() // Registration to update the entry - EntryEditActivity.launchToUpdateForRegistration( - this@GroupActivity, - database, - entry.nodeId, - registerInfo + EntryEditActivity.launchForRegistration( + context = this@GroupActivity, + database = database, + activityResultLauncher = activityResultLauncher, + nodeId = entry.nodeId, + registerInfo = registerInfo, + typeMode = typeMode, + registrationType = EntryEditActivity.RegistrationType.UPDATE ) onLaunchActivitySpecialMode() } - private fun updateEntryWithSearchInfo( + private fun updateEntryWithRegisterInfo( database: ContextualDatabase, entry: Entry, - searchInfo: SearchInfo + registerInfo: RegisterInfo ) { val newEntry = Entry(entry) val entryInfo = newEntry.getEntryInfo( @@ -976,11 +975,9 @@ class GroupActivity : DatabaseLockActivity(), raw = true, removeTemplateConfiguration = false ) - val modification = entryInfo.saveSearchInfo(database, searchInfo) + entryInfo.saveRegisterInfo(database, registerInfo) newEntry.setEntryInfo(database, entryInfo) - if (modification) { - updateEntry(entry, newEntry) - } + updateEntry(entry, newEntry) } private fun finishNodeAction() { @@ -1032,11 +1029,12 @@ class GroupActivity : DatabaseLockActivity(), launchDialogForGroupUpdate(node as Group) } Type.ENTRY -> { - EntryEditActivity.launchToUpdate( - this@GroupActivity, - database, - (node as Entry).nodeId, - mEntryActivityResultLauncher + EntryEditActivity.launch( + activity = this@GroupActivity, + database = database, + registrationType = EntryEditActivity.RegistrationType.UPDATE, + nodeId = (node as Entry).nodeId, + activityResultLauncher = mEntryActivityResultLauncher ) } } @@ -1155,7 +1153,9 @@ class GroupActivity : DatabaseLockActivity(), finishNodeAction() searchView?.setOnQueryTextListener(null) - searchFiltersView?.saveSearchParameters() + if (!mTempSearchInfo) { + searchFiltersView?.saveSearchParameters() + } } private fun addSearchQueryInSearchView(searchQuery: String) { @@ -1209,7 +1209,7 @@ class GroupActivity : DatabaseLockActivity(), searchView = it.actionView as SearchView? searchView?.apply { setOnQueryTextFocusChangeListener(mOnSearchTextFocusChangeListener) - val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager? + val searchManager = getSystemService(SEARCH_SERVICE) as SearchManager? (searchManager?.getSearchableInfo( ComponentName(this@GroupActivity, GroupActivity::class.java) ))?.let { searchableInfo -> @@ -1220,7 +1220,9 @@ class GroupActivity : DatabaseLockActivity(), if (searchState != null) { it.expandActionView() addSearchQueryInSearchView(searchState.searchParameters.searchQuery) - searchFiltersView?.searchParameters = searchState.searchParameters + if (mTempSearchInfo.not()) { + searchFiltersView?.searchParameters = searchState.searchParameters + } } } if (it.isActionViewExpanded) { @@ -1396,8 +1398,8 @@ class GroupActivity : DatabaseLockActivity(), // Else in root, lock if needed else { removeSearch() - EntrySelectionHelper.removeModesFromIntent(intent) - EntrySelectionHelper.removeInfoFromIntent(intent) + intent.removeModes() + intent.removeInfo() if (PreferencesUtil.isLockDatabaseWhenBackButtonOnRootClicked(this)) { lockAndExit() } else { @@ -1474,9 +1476,11 @@ class GroupActivity : DatabaseLockActivity(), private const val OLD_GROUP_TO_UPDATE_KEY = "OLD_GROUP_TO_UPDATE_KEY" private const val AUTO_SEARCH_KEY = "AUTO_SEARCH_KEY" - private fun buildIntent(context: Context, - groupState: GroupState?, - intentBuildLauncher: (Intent) -> Unit) { + private fun buildIntent( + context: Context, + groupState: GroupState?, + intentBuildLauncher: (Intent) -> Unit + ) { val intent = Intent(context, GroupActivity::class.java) if (groupState != null) { intent.putExtra(GROUP_STATE_KEY, groupState) @@ -1484,18 +1488,12 @@ class GroupActivity : DatabaseLockActivity(), intentBuildLauncher.invoke(intent) } - private fun checkTimeAndBuildIntent(activity: Activity, - groupState: GroupState?, - intentBuildLauncher: (Intent) -> Unit) { - if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) { - buildIntent(activity, groupState, intentBuildLauncher) - } - } - - private fun checkTimeAndBuildIntent(context: Context, - groupState: GroupState?, - intentBuildLauncher: (Intent) -> Unit) { - if (TimeoutHelper.checkTime(context)) { + private fun checkTimeAndBuildIntent( + context: Context, + groupState: GroupState?, + intentBuildLauncher: (Intent) -> Unit + ) { + if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { buildIntent(context, groupState, intentBuildLauncher) } } @@ -1505,9 +1503,11 @@ class GroupActivity : DatabaseLockActivity(), * Standard Launch * ------------------------- */ - fun launch(context: Context, - database: ContextualDatabase, - autoSearch: Boolean = false) { + fun launch( + context: Context, + database: ContextualDatabase, + autoSearch: Boolean = false + ) { if (database.loaded) { checkTimeAndBuildIntent(context, null) { intent -> intent.putExtra(AUTO_SEARCH_KEY, autoSearch) @@ -1521,85 +1521,38 @@ class GroupActivity : DatabaseLockActivity(), * Search Launch * ------------------------- */ - fun launchForSearchResult(context: Context, - database: ContextualDatabase, - searchInfo: SearchInfo, - autoSearch: Boolean = false) { + fun launchForSearchResult( + context: Context, + database: ContextualDatabase, + searchInfo: SearchInfo, + autoSearch: Boolean = false + ) { if (database.loaded) { checkTimeAndBuildIntent(context, null) { intent -> intent.putExtra(AUTO_SEARCH_KEY, autoSearch) - EntrySelectionHelper.addSearchInfoInIntent( - intent, - searchInfo - ) + intent.addSearchInfo(searchInfo) context.startActivity(intent) } } } - /* - * ------------------------- - * Search save Launch - * ------------------------- - */ - fun launchForSaveResult(context: Context, - database: ContextualDatabase, - searchInfo: SearchInfo, - autoSearch: Boolean = false) { - if (database.loaded && !database.isReadOnly) { - checkTimeAndBuildIntent(context, null) { intent -> - intent.putExtra(AUTO_SEARCH_KEY, autoSearch) - EntrySelectionHelper.startActivityForSaveModeResult( - context, - intent, - searchInfo - ) - } - } - } - - /* - * ------------------------- - * Keyboard Launch - * ------------------------- - */ - fun launchForKeyboardSelectionResult(context: Context, - database: ContextualDatabase, - searchInfo: SearchInfo? = null, - autoSearch: Boolean = false) { + fun launchForSelection( + context: Context, + database: ContextualDatabase, + typeMode: TypeMode, + searchInfo: SearchInfo? = null, + autoSearch: Boolean = false, + activityResultLauncher: ActivityResultLauncher? = null, + ) { if (database.loaded) { checkTimeAndBuildIntent(context, null) { intent -> intent.putExtra(AUTO_SEARCH_KEY, autoSearch) - EntrySelectionHelper.startActivityForKeyboardSelectionModeResult( - context, - intent, - searchInfo - ) - } - } - } - - /* - * ------------------------- - * Autofill Launch - * ------------------------- - */ - @RequiresApi(api = Build.VERSION_CODES.O) - fun launchForAutofillResult(activity: AppCompatActivity, - database: ContextualDatabase, - activityResultLaunch: ActivityResultLauncher?, - autofillComponent: AutofillComponent, - searchInfo: SearchInfo? = null, - autoSearch: Boolean = false) { - if (database.loaded) { - checkTimeAndBuildIntent(activity, null) { intent -> - intent.putExtra(AUTO_SEARCH_KEY, autoSearch) - AutofillHelper.startActivityForAutofillResult( - activity, - intent, - activityResultLaunch, - autofillComponent, - searchInfo + EntrySelectionHelper.startActivityForSelectionModeResult( + context = context, + intent = intent, + typeMode = typeMode, + searchInfo = searchInfo, + activityResultLauncher = activityResultLauncher ) } } @@ -1610,16 +1563,22 @@ class GroupActivity : DatabaseLockActivity(), * Registration Launch * ------------------------- */ - fun launchForRegistration(context: Context, - database: ContextualDatabase, - registerInfo: RegisterInfo? = null) { + fun launchForRegistration( + context: Context, + database: ContextualDatabase, + typeMode: TypeMode, + registerInfo: RegisterInfo? = null, + activityResultLauncher: ActivityResultLauncher?, + ) { if (database.loaded && !database.isReadOnly) { checkTimeAndBuildIntent(context, null) { intent -> intent.putExtra(AUTO_SEARCH_KEY, false) EntrySelectionHelper.startActivityForRegistrationModeResult( context, + activityResultLauncher, intent, - registerInfo + registerInfo, + typeMode ) } } @@ -1630,65 +1589,49 @@ class GroupActivity : DatabaseLockActivity(), * Global Launch * ------------------------- */ - fun launch(activity: AppCompatActivity, - database: ContextualDatabase, - onValidateSpecialMode: () -> Unit, - onCancelSpecialMode: () -> Unit, - onLaunchActivitySpecialMode: () -> Unit, - autofillActivityResultLauncher: ActivityResultLauncher?) { - EntrySelectionHelper.doSpecialAction(activity.intent, - { - // Default action - launch( - activity, + fun launch( + activity: AppCompatActivity, + database: ContextualDatabase, + onValidateSpecialMode: () -> Unit, + onCancelSpecialMode: () -> Unit, + onLaunchActivitySpecialMode: () -> Unit, + activityResultLauncher: ActivityResultLauncher? + ) { + EntrySelectionHelper.doSpecialAction( + intent = activity.intent, + defaultAction = { + // Default action + launch( + activity, + database, + true + ) + }, + searchAction = { searchInfo -> + // Search action + if (database.loaded) { + launchForSearchResult(activity, database, - true - ) - }, - { searchInfo -> - // Search action - if (database.loaded) { - launchForSearchResult(activity, - database, - searchInfo, - true) - onLaunchActivitySpecialMode() - } else { - // Simply close if database not opened - onCancelSpecialMode() - } - }, - { searchInfo -> - // Save info - if (database.loaded) { - if (!database.isReadOnly) { - launchForSaveResult( - activity, - database, - searchInfo, - false - ) - onLaunchActivitySpecialMode() - } else { - Toast.makeText( - activity.applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG - ) - .show() - onCancelSpecialMode() - } - } - }, - { searchInfo -> - // Keyboard selection - SearchHelper.checkAutoSearchInfo(activity, - database, - searchInfo, - { _, items -> + searchInfo, + true) + onLaunchActivitySpecialMode() + } else { + // Simply close if database not opened + onCancelSpecialMode() + } + }, + selectionAction = { intentSenderMode, typeMode, searchInfo -> + SearchHelper.checkAutoSearchInfo( + context = activity, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> + when (typeMode) { + TypeMode.DEFAULT -> {} + TypeMode.MAGIKEYBOARD -> { MagikeyboardService.performSelection( - items, - { entryInfo -> + items = items, + actionPopulateKeyboard = { entryInfo -> // Keyboard populated MagikeyboardService.populateKeyboardAndMoveAppToBackground( activity, @@ -1696,92 +1639,89 @@ class GroupActivity : DatabaseLockActivity(), ) onValidateSpecialMode() }, - { autoSearch -> - launchForKeyboardSelectionResult(activity, - database, - searchInfo, - autoSearch) + actionEntrySelection = { autoSearch -> + launchForSelection( + context = activity, + database = database, + typeMode = TypeMode.MAGIKEYBOARD, + searchInfo = searchInfo, + autoSearch = autoSearch + ) onLaunchActivitySpecialMode() } ) - }, - { - // Here no search info found, disable auto search - launchForKeyboardSelectionResult(activity, - database, - searchInfo, - false) - onLaunchActivitySpecialMode() - }, - { - // Simply close if database not opened, normally not happened - onCancelSpecialMode() } - ) - }, - { searchInfo, autofillComponent -> - // Autofill selection - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - SearchHelper.checkAutoSearchInfo(activity, - database, - searchInfo, - { openedDatabase, items -> - // Response is build - AutofillHelper.buildResponseAndSetResult(activity, openedDatabase, items) - onValidateSpecialMode() - }, - { - // Here no search info found, disable auto search - launchForAutofillResult(activity, - database, - autofillActivityResultLauncher, - autofillComponent, - searchInfo, - false) - onLaunchActivitySpecialMode() - }, - { - // Simply close if database not opened, normally not happened - onCancelSpecialMode() - } + TypeMode.PASSKEY -> { + // Response is build + EntrySelectionHelper.performSelection( + items = items, + actionPopulateCredentialProvider = { entryInfo -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + activity.buildPasskeyResponseAndSetResult(entryInfo) + } + onValidateSpecialMode() + }, + actionEntrySelection = { + launchForSelection( + context = activity, + database = database, + typeMode = TypeMode.PASSKEY, + searchInfo = searchInfo, + activityResultLauncher = activityResultLauncher, + autoSearch = true + ) + onLaunchActivitySpecialMode() + } + ) + } + TypeMode.AUTOFILL -> { + // Response is build + activity.buildSpecialModeResponseAndSetResult(items) + onValidateSpecialMode() + } + } + }, + onItemNotFound = { + // Here no search info found, disable auto search + launchForSelection( + context = activity, + database = database, + typeMode = typeMode, + searchInfo = searchInfo, + autoSearch = false, + activityResultLauncher = if (intentSenderMode) + activityResultLauncher else null ) - } else { + onLaunchActivitySpecialMode() + }, + onDatabaseClosed = { + // Simply close if database not opened, normally not happened onCancelSpecialMode() } - }, - { registerInfo -> - // Autofill registration + ) + }, + registrationAction = { intentSenderMode, typeMode, registerInfo -> + // Save info + if (database.loaded) { if (!database.isReadOnly) { - SearchHelper.checkAutoSearchInfo(activity, - database, - registerInfo?.searchInfo, - { _, _ -> - // No auto search, it's a registration - launchForRegistration(activity, - database, - registerInfo) - onLaunchActivitySpecialMode() - }, - { - // Here no search info found, disable auto search - launchForRegistration(activity, - database, - registerInfo) - onLaunchActivitySpecialMode() - }, - { - // Simply close if database not opened, normally not happened - onCancelSpecialMode() - } + launchForRegistration( + context = activity, + database = database, + registerInfo = registerInfo, + typeMode = typeMode, + activityResultLauncher = if (intentSenderMode) + activityResultLauncher else null ) + onLaunchActivitySpecialMode() } else { - Toast.makeText(activity.applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG) - .show() + activity.toastError(RegisterInReadOnlyDatabaseException()) onCancelSpecialMode() } - }) + } else { + onCancelSpecialMode() + } + } + ) } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/IconPickerActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/IconPickerActivity.kt index d471b5e6d..9ff62a512 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/IconPickerActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/IconPickerActivity.kt @@ -174,10 +174,10 @@ class IconPickerActivity : DatabaseLockActivity() { return true } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { super.onDatabaseRetrieved(database) - if (database?.allowCustomIcons == true) { + if (database.allowCustomIcons) { uploadButton.setOpenDocumentClickListener(mExternalFileHelper) } else { uploadButton.visibility = View.GONE diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/ImageViewerActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/ImageViewerActivity.kt index 6cd495a17..af258b61d 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/ImageViewerActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/ImageViewerActivity.kt @@ -101,7 +101,7 @@ class ImageViewerActivity : DatabaseLockActivity() { return true } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { super.onDatabaseRetrieved(database) try { @@ -119,18 +119,16 @@ class ImageViewerActivity : DatabaseLockActivity() { resources.displayMetrics.heightPixels * 2 ) - database?.let { database -> - BinaryDatabaseManager.loadBitmap( - database, - attachment.binaryData, - mImagePreviewMaxWidth - ) { bitmapLoaded -> - if (bitmapLoaded == null) { - finish() - } else { - progressView.visibility = View.GONE - imageView.setImageBitmap(bitmapLoaded) - } + BinaryDatabaseManager.loadBitmap( + database, + attachment.binaryData, + mImagePreviewMaxWidth + ) { bitmapLoaded -> + if (bitmapLoaded == null) { + finish() + } else { + progressView.visibility = View.GONE + imageView.setImageBitmap(bitmapLoaded) } } } ?: finish() diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt index e9d8cb02c..95ce84881 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt @@ -36,7 +36,6 @@ import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.viewModels -import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar import androidx.biometric.BiometricManager @@ -49,16 +48,15 @@ import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.snackbar.Snackbar import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.dialogs.DuplicateUuidDialog -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper import com.kunzisoft.keepass.biometric.DeviceUnlockFragment import com.kunzisoft.keepass.biometric.DeviceUnlockManager import com.kunzisoft.keepass.biometric.deviceUnlockError +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException @@ -127,11 +125,6 @@ class MainCredentialActivity : DatabaseModeActivity() { private var mReadOnly: Boolean = false private var mForceReadOnly: Boolean = false - private var mAutofillActivityResultLauncher: ActivityResultLauncher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - AutofillHelper.buildActivityResultLauncher(this) - else null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -313,20 +306,18 @@ class MainCredentialActivity : DatabaseModeActivity() { } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { super.onDatabaseRetrieved(database) - if (database != null) { - // Trying to load another database - if (mDatabaseFileUri != null - && database.fileUri != null - && mDatabaseFileUri != database.fileUri) { - Toast.makeText(this, - R.string.warning_database_already_opened, - Toast.LENGTH_LONG - ).show() - } - launchGroupActivityIfLoaded(database) + // Trying to load another database + if (mDatabaseFileUri != null + && database.fileUri != null + && mDatabaseFileUri != database.fileUri) { + Toast.makeText(this, + R.string.warning_database_already_opened, + Toast.LENGTH_LONG + ).show() } + launchGroupActivityIfLoaded(database) } override fun onDatabaseActionFinished( @@ -433,7 +424,7 @@ class MainCredentialActivity : DatabaseModeActivity() { { onValidateSpecialMode() }, { onCancelSpecialMode() }, { onLaunchActivitySpecialMode() }, - mAutofillActivityResultLauncher + mCredentialActivityResultLauncher ) } } @@ -511,10 +502,11 @@ class MainCredentialActivity : DatabaseModeActivity() { val password = intent.getStringExtra(KEY_PASSWORD) // Consume the intent extra password intent.removeExtra(KEY_PASSWORD) - val launchImmediately = intent.getBooleanExtra(KEY_LAUNCH_IMMEDIATELY, false) if (password != null) { mainCredentialView?.populatePasswordTextView(password) } + val launchImmediately = intent.getBooleanExtra(KEY_LAUNCH_IMMEDIATELY, false) + intent.removeExtra(KEY_LAUNCH_IMMEDIATELY) if (launchImmediately) { loadDatabase() } else { @@ -569,13 +561,10 @@ class MainCredentialActivity : DatabaseModeActivity() { clearCredentialsViews() } - if (mReadOnly && ( - mSpecialMode == SpecialMode.SAVE - || mSpecialMode == SpecialMode.REGISTRATION) - ) { - Log.e(TAG, getString(R.string.autofill_read_only_save)) + if (mReadOnly && mSpecialMode == SpecialMode.REGISTRATION) { + Log.e(TAG, getString(R.string.error_save_read_only)) Snackbar.make(coordinatorLayout, - R.string.autofill_read_only_save, + R.string.error_save_read_only, Snackbar.LENGTH_LONG).asError().show() } else { databaseFileUri?.let { databaseUri -> @@ -596,7 +585,7 @@ class MainCredentialActivity : DatabaseModeActivity() { readOnly: Boolean, cipherEncryptDatabase: CipherEncryptDatabase?, fixDuplicateUUID: Boolean) { - loadDatabase( + mDatabaseViewModel.loadDatabase( databaseUri, mainCredential, readOnly, @@ -749,11 +738,13 @@ class MainCredentialActivity : DatabaseModeActivity() { private const val KEY_PASSWORD = "password" private const val KEY_LAUNCH_IMMEDIATELY = "launchImmediately" - private fun buildAndLaunchIntent(activity: Activity, - databaseFile: Uri, - keyFile: Uri?, - hardwareKey: HardwareKey?, - intentBuildLauncher: (Intent) -> Unit) { + private fun buildAndLaunchIntent( + activity: Activity, + databaseFile: Uri, + keyFile: Uri?, + hardwareKey: HardwareKey?, + intentBuildLauncher: (Intent) -> Unit + ) { val intent = Intent(activity, MainCredentialActivity::class.java) intent.putExtra(KEY_FILENAME, databaseFile) if (keyFile != null) @@ -770,10 +761,12 @@ class MainCredentialActivity : DatabaseModeActivity() { */ @Throws(FileNotFoundException::class) - fun launch(activity: Activity, - databaseFile: Uri, - keyFile: Uri?, - hardwareKey: HardwareKey?) { + fun launch( + activity: Activity, + databaseFile: Uri, + keyFile: Uri?, + hardwareKey: HardwareKey? + ) { buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> activity.startActivity(intent) } @@ -786,185 +779,73 @@ class MainCredentialActivity : DatabaseModeActivity() { */ @Throws(FileNotFoundException::class) - fun launchForSearchResult(activity: Activity, - databaseFile: Uri, - keyFile: Uri?, - hardwareKey: HardwareKey?, - searchInfo: SearchInfo) { + fun launchForSearchResult( + activity: Activity, + databaseFile: Uri, + keyFile: Uri?, + hardwareKey: HardwareKey?, + searchInfo: SearchInfo + ) { buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> EntrySelectionHelper.startActivityForSearchModeResult( - activity, - intent, - searchInfo) + context = activity, + intent = intent, + searchInfo = searchInfo + ) } } /* * ------------------------- - * Save Launch + * Selection Launch * ------------------------- */ @Throws(FileNotFoundException::class) - fun launchForSaveResult(activity: Activity, - databaseFile: Uri, - keyFile: Uri?, - hardwareKey: HardwareKey?, - searchInfo: SearchInfo) { + fun launchForSelection( + activity: AppCompatActivity, + databaseFile: Uri, + keyFile: Uri?, + hardwareKey: HardwareKey?, + typeMode: TypeMode, + searchInfo: SearchInfo?, + activityResultLauncher: ActivityResultLauncher? = null, + ) { buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> - EntrySelectionHelper.startActivityForSaveModeResult( - activity, - intent, - searchInfo) + EntrySelectionHelper.startActivityForSelectionModeResult( + context = activity, + intent = intent, + typeMode = typeMode, + searchInfo = searchInfo, + activityResultLauncher = activityResultLauncher + ) } } /* * ------------------------- - * Keyboard Launch + * Registration Launch * ------------------------- */ @Throws(FileNotFoundException::class) - fun launchForKeyboardResult(activity: Activity, - databaseFile: Uri, - keyFile: Uri?, - hardwareKey: HardwareKey?, - searchInfo: SearchInfo?) { - buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> - EntrySelectionHelper.startActivityForKeyboardSelectionModeResult( - activity, - intent, - searchInfo) - } - } - - /* - * ------------------------- - * Autofill Launch - * ------------------------- - */ - - @RequiresApi(api = Build.VERSION_CODES.O) - @Throws(FileNotFoundException::class) - fun launchForAutofillResult(activity: AppCompatActivity, - databaseFile: Uri, - keyFile: Uri?, - hardwareKey: HardwareKey?, - activityResultLauncher: ActivityResultLauncher?, - autofillComponent: AutofillComponent, - searchInfo: SearchInfo?) { - buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> - AutofillHelper.startActivityForAutofillResult( - activity, - intent, - activityResultLauncher, - autofillComponent, - searchInfo) - } - } - - /* - * ------------------------- - * Registration Launch - * ------------------------- - */ - fun launchForRegistration(activity: Activity, - databaseFile: Uri, - keyFile: Uri?, - hardwareKey: HardwareKey?, - registerInfo: RegisterInfo?) { + fun launchForRegistration( + activity: Activity, + activityResultLauncher: ActivityResultLauncher?, + databaseFile: Uri, + keyFile: Uri?, + hardwareKey: HardwareKey?, + typeMode: TypeMode, + registerInfo: RegisterInfo? + ) { buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> EntrySelectionHelper.startActivityForRegistrationModeResult( - activity, - intent, - registerInfo) - } - } - - /* - * ------------------------- - * Global Launch - * ------------------------- - */ - fun launch(activity: AppCompatActivity, - databaseUri: Uri, - keyFile: Uri?, - hardwareKey: HardwareKey?, - fileNoFoundAction: (exception: FileNotFoundException) -> Unit, - onCancelSpecialMode: () -> Unit, - onLaunchActivitySpecialMode: () -> Unit, - autofillActivityResultLauncher: ActivityResultLauncher?) { - - try { - EntrySelectionHelper.doSpecialAction(activity.intent, - { - launch( - activity, - databaseUri, - keyFile, - hardwareKey - ) - }, - { searchInfo -> // Search Action - launchForSearchResult( - activity, - databaseUri, - keyFile, - hardwareKey, - searchInfo - ) - onLaunchActivitySpecialMode() - }, - { searchInfo -> // Save Action - launchForSaveResult( - activity, - databaseUri, - keyFile, - hardwareKey, - searchInfo - ) - onLaunchActivitySpecialMode() - }, - { searchInfo -> // Keyboard Selection Action - launchForKeyboardResult( - activity, - databaseUri, - keyFile, - hardwareKey, - searchInfo - ) - onLaunchActivitySpecialMode() - }, - { searchInfo, autofillComponent -> // Autofill Selection Action - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - launchForAutofillResult( - activity, - databaseUri, - keyFile, - hardwareKey, - autofillActivityResultLauncher, - autofillComponent, - searchInfo - ) - onLaunchActivitySpecialMode() - } else { - onCancelSpecialMode() - } - }, - { registerInfo -> // Registration Action - launchForRegistration( - activity, - databaseUri, - keyFile, - hardwareKey, - registerInfo - ) - onLaunchActivitySpecialMode() - } + context = activity, + activityResultLauncher = activityResultLauncher, + intent = intent, + typeMode = typeMode, + registerInfo = registerInfo ) - } catch (e: FileNotFoundException) { - fileNoFoundAction(e) } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseChangedDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseChangedDialogFragment.kt index 9318a6167..8b2bf24da 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseChangedDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseChangedDialogFragment.kt @@ -67,7 +67,7 @@ class DatabaseChangedDialogFragment : DatabaseDialogFragment() { } builder.setMessage(stringBuilder) builder.setPositiveButton(android.R.string.ok) { _, _ -> - actionDatabaseListener?.validateDatabaseChanged() + actionDatabaseListener?.onDatabaseChangeValidated() } return builder.create() } @@ -76,7 +76,7 @@ class DatabaseChangedDialogFragment : DatabaseDialogFragment() { } interface ActionDatabaseChangedListener { - fun validateDatabaseChanged() + fun onDatabaseChangeValidated() } companion object { @@ -86,9 +86,10 @@ class DatabaseChangedDialogFragment : DatabaseDialogFragment() { private const val NEW_FILE_DATABASE_INFO = "NEW_FILE_DATABASE_INFO" private const val READ_ONLY_DATABASE = "READ_ONLY_DATABASE" - fun getInstance(oldSnapFileDatabaseInfo: SnapFileDatabaseInfo, - newSnapFileDatabaseInfo: SnapFileDatabaseInfo, - readOnly: Boolean + fun getInstance( + oldSnapFileDatabaseInfo: SnapFileDatabaseInfo, + newSnapFileDatabaseInfo: SnapFileDatabaseInfo, + readOnly: Boolean ) : DatabaseChangedDialogFragment { val fragment = DatabaseChangedDialogFragment() diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseDialogFragment.kt index a8d53f9ee..e143fef3e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseDialogFragment.kt @@ -5,6 +5,9 @@ import android.view.View import android.view.WindowManager.LayoutParams.FLAG_SECURE import androidx.fragment.app.DialogFragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.kunzisoft.keepass.activities.legacy.DatabaseRetrieval import com.kunzisoft.keepass.activities.legacy.resetAppTimeoutWhenViewTouchedOrFocused import com.kunzisoft.keepass.database.ContextualDatabase @@ -12,23 +15,40 @@ import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.tasks.ActionRunnable import com.kunzisoft.keepass.timeout.TimeoutHelper import com.kunzisoft.keepass.viewmodels.DatabaseViewModel +import kotlinx.coroutines.launch abstract class DatabaseDialogFragment : DialogFragment(), DatabaseRetrieval { private val mDatabaseViewModel: DatabaseViewModel by activityViewModels() - private var mDatabase: ContextualDatabase? = null + private val mDatabase: ContextualDatabase? + get() = mDatabaseViewModel.database override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - mDatabaseViewModel.database.observe(this) { database -> - this.mDatabase = database - resetAppTimeoutOnTouchOrFocus() - onDatabaseRetrieved(database) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mDatabaseViewModel.actionState.collect { uiState -> + when (uiState) { + is DatabaseViewModel.ActionState.OnDatabaseActionFinished -> { + onDatabaseActionFinished( + uiState.database, + uiState.actionTask, + uiState.result + ) + } + else -> {} + } + } + } } - - mDatabaseViewModel.actionFinished.observe(this) { result -> - onDatabaseActionFinished(result.database, result.actionTask, result.result) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + mDatabaseViewModel.databaseState.collect { database -> + database?.let { + onDatabaseRetrieved(database) + } + } + } } } @@ -45,13 +65,14 @@ abstract class DatabaseDialogFragment : DialogFragment(), DatabaseRetrieval { } @Suppress("DEPRECATION") + @Deprecated(message = "") override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) resetAppTimeoutOnTouchOrFocus() } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { // Can be overridden by a subclass } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupDialogFragment.kt index 593a99d6f..e3af2a169 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupDialogFragment.kt @@ -36,7 +36,7 @@ import com.kunzisoft.keepass.database.element.icon.IconImage import com.kunzisoft.keepass.model.GroupInfo import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.utils.TimeUtil.getDateTimeString -import com.kunzisoft.keepass.utils.UuidUtil +import com.kunzisoft.keepass.utils.UUIDUtils.asHexString import com.kunzisoft.keepass.utils.getParcelableCompat import com.kunzisoft.keepass.view.DateTimeFieldView @@ -62,14 +62,14 @@ class GroupDialogFragment : DatabaseDialogFragment() { private lateinit var uuidContainerView: ViewGroup private lateinit var uuidReferenceView: TextView - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { super.onDatabaseRetrieved(database) mPopulateIconMethod = { imageView, icon -> - database?.iconDrawableFactory?.assignDatabaseIcon(imageView, icon, mIconColor) + database.iconDrawableFactory.assignDatabaseIcon(imageView, icon, mIconColor) } mPopulateIconMethod?.invoke(iconView, mGroupInfo.icon) - if (database?.allowCustomSearchableGroup() == true) { + if (database.allowCustomSearchableGroup()) { searchableLabelView.visibility = View.VISIBLE searchableView.visibility = View.VISIBLE } else { @@ -155,7 +155,7 @@ class GroupDialogFragment : DatabaseDialogFragment() { searchableView.text = stringFromInheritableBoolean(mGroupInfo.searchable) autoTypeView.text = stringFromInheritableBoolean(mGroupInfo.enableAutoType, mGroupInfo.defaultAutoTypeSequence) - val uuid = UuidUtil.toHexString(mGroupInfo.id) + val uuid = mGroupInfo.id?.asHexString() if (uuid == null || uuid.isEmpty()) { uuidContainerView.visibility = View.GONE } else { diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupEditDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupEditDialogFragment.kt index 9bbe577bd..ae09979d9 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupEditDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupEditDialogFragment.kt @@ -112,32 +112,32 @@ class GroupEditDialogFragment : DatabaseDialogFragment() { } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { super.onDatabaseRetrieved(database) mPopulateIconMethod = { imageView, icon -> - database?.iconDrawableFactory?.assignDatabaseIcon(imageView, icon, mIconColor) + database.iconDrawableFactory.assignDatabaseIcon(imageView, icon, mIconColor) } mPopulateIconMethod?.invoke(iconButtonView, mGroupInfo.icon) - searchableContainerView.visibility = if (database?.allowCustomSearchableGroup() == true) { + searchableContainerView.visibility = if (database.allowCustomSearchableGroup()) { View.VISIBLE } else { View.GONE } - if (database?.allowAutoType() == true) { + if (database.allowAutoType()) { autoTypeContainerView.visibility = View.VISIBLE } else { autoTypeContainerView.visibility = View.GONE } - tagsAdapter = TagsProposalAdapter(requireContext(), database?.tagPool) + tagsAdapter = TagsProposalAdapter(requireContext(), database.tagPool) tagsCompletionView.apply { threshold = 1 setAdapter(tagsAdapter) } - tagsContainerView.visibility = if (database?.allowTags() == true) View.VISIBLE else View.GONE + tagsContainerView.visibility = if (database.allowTags()) View.VISIBLE else View.GONE } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/IconEditDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/IconEditDialogFragment.kt index 84b881eac..e5d8ba542 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/IconEditDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/IconEditDialogFragment.kt @@ -45,10 +45,10 @@ class IconEditDialogFragment : DatabaseDialogFragment() { private var mCustomIcon: IconImageCustom? = null - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { super.onDatabaseRetrieved(database) mPopulateIconMethod = { imageView, icon -> - database?.iconDrawableFactory?.assignDatabaseIcon(imageView, icon) + database.iconDrawableFactory.assignDatabaseIcon(imageView, icon) } mCustomIcon?.let { customIcon -> populateViewsWithCustomIcon(customIcon) diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SetMainCredentialDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SetMainCredentialDialogFragment.kt index f8e0c7013..d9dfbb4db 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SetMainCredentialDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SetMainCredentialDialogFragment.kt @@ -35,9 +35,9 @@ import com.google.android.material.textfield.TextInputLayout import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper import com.kunzisoft.keepass.activities.helpers.setOpenDocumentClickListener +import com.kunzisoft.keepass.credentialprovider.activity.HardwareKeyActivity import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.hardware.HardwareKey -import com.kunzisoft.keepass.hardware.HardwareKeyActivity import com.kunzisoft.keepass.password.PasswordEntropy import com.kunzisoft.keepass.utils.UriUtil.getDocumentFile import com.kunzisoft.keepass.utils.UriUtil.openUrl @@ -258,8 +258,7 @@ class SetMainCredentialDialogFragment : DatabaseDialogFragment() { showEmptyPasswordConfirmationDialog() } else if (!error && hardwareKey != null - && !HardwareKeyActivity.isHardwareKeyAvailable( - requireActivity(), hardwareKey, false) + && !HardwareKeyActivity.isHardwareKeyAvailable(requireActivity(), hardwareKey) ) { // show hardware driver dialog if required error = true diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SetOTPDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SetOTPDialogFragment.kt index 1185dd066..c1f9cb834 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SetOTPDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SetOTPDialogFragment.kt @@ -29,7 +29,11 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo -import android.widget.* +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.Spinner +import android.widget.TextView import androidx.appcompat.app.AlertDialog import com.google.android.material.textfield.TextInputLayout import com.kunzisoft.keepass.R @@ -40,15 +44,15 @@ import com.kunzisoft.keepass.otp.OtpElement.Companion.MAX_OTP_DIGITS import com.kunzisoft.keepass.otp.OtpElement.Companion.MAX_TOTP_PERIOD import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_HOTP_COUNTER import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_OTP_DIGITS -import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_TOTP_PERIOD import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_OTP_SECRET +import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_TOTP_PERIOD import com.kunzisoft.keepass.otp.OtpTokenType import com.kunzisoft.keepass.otp.OtpType import com.kunzisoft.keepass.otp.TokenCalculator -import com.kunzisoft.keepass.utils.UriUtil.isContributingUser +import com.kunzisoft.keepass.utils.AppUtil.isContributingUser import com.kunzisoft.keepass.utils.UriUtil.openUrl import com.kunzisoft.keepass.utils.getParcelableCompat -import java.util.* +import java.util.Locale class SetOTPDialogFragment : DatabaseDialogFragment() { diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/DatabaseFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/DatabaseFragment.kt index e59b48871..a630afd26 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/DatabaseFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/DatabaseFragment.kt @@ -4,36 +4,59 @@ import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.kunzisoft.keepass.activities.legacy.DatabaseRetrieval import com.kunzisoft.keepass.activities.legacy.resetAppTimeoutWhenViewTouchedOrFocused import com.kunzisoft.keepass.database.ContextualDatabase -import com.kunzisoft.keepass.database.element.binary.BinaryData import com.kunzisoft.keepass.tasks.ActionRunnable import com.kunzisoft.keepass.viewmodels.DatabaseViewModel +import kotlinx.coroutines.launch abstract class DatabaseFragment : Fragment(), DatabaseRetrieval { - private val mDatabaseViewModel: DatabaseViewModel by activityViewModels() - protected var mDatabase: ContextualDatabase? = null + protected val mDatabaseViewModel: DatabaseViewModel by activityViewModels() + protected val mDatabase: ContextualDatabase? + get() = mDatabaseViewModel.database - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - mDatabaseViewModel.database.observe(viewLifecycleOwner) { database -> - if (mDatabase == null || mDatabase != database) { - this.mDatabase = database - onDatabaseRetrieved(database) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mDatabaseViewModel.actionState.collect { uiState -> + when (uiState) { + is DatabaseViewModel.ActionState.OnDatabaseActionFinished -> { + onDatabaseActionFinished( + uiState.database, + uiState.actionTask, + uiState.result + ) + } + + else -> {} + } + } } } - - mDatabaseViewModel.actionFinished.observe(viewLifecycleOwner) { result -> - onDatabaseActionFinished(result.database, result.actionTask, result.result) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + mDatabaseViewModel.databaseState.collect { database -> + database?.let { + onDatabaseRetrieved(database) + } + } + } } } protected fun resetAppTimeoutWhenViewFocusedOrChanged(view: View?) { context?.let { - view?.resetAppTimeoutWhenViewTouchedOrFocused(it, mDatabase?.loaded) + view?.resetAppTimeoutWhenViewTouchedOrFocused( + context = it, + databaseLoaded = mDatabase?.loaded + ) } } @@ -44,8 +67,4 @@ abstract class DatabaseFragment : Fragment(), DatabaseRetrieval { ) { // Can be overridden by a subclass } - - protected fun buildNewBinaryAttachment(): BinaryData? { - return mDatabase?.buildNewBinaryAttachment() - } } \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryEditFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryEditFragment.kt index d796d048b..dd7885ff4 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryEditFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryEditFragment.kt @@ -230,7 +230,7 @@ class EntryEditFragment: DatabaseFragment() { val attachmentToUploadUri = it.attachmentToUploadUri val fileName = it.fileName - buildNewBinaryAttachment()?.let { binaryAttachment -> + mDatabaseViewModel.buildNewAttachment()?.let { binaryAttachment -> val entryAttachment = Attachment(fileName, binaryAttachment) // Ask to replace the current attachment if ((!mAllowMultipleAttachments @@ -273,13 +273,13 @@ class EntryEditFragment: DatabaseFragment() { } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { templateView.populateIconMethod = { imageView, icon -> - database?.iconDrawableFactory?.assignDatabaseIcon(imageView, icon, mIconColor) + database.iconDrawableFactory.assignDatabaseIcon(imageView, icon, mIconColor) } - mAllowMultipleAttachments = database?.allowMultipleAttachments == true + mAllowMultipleAttachments = database.allowMultipleAttachments == true attachmentsAdapter?.database = database attachmentsAdapter?.onListSizeChangedListener = { previousSize, newSize -> @@ -290,12 +290,12 @@ class EntryEditFragment: DatabaseFragment() { } } - tagsAdapter = TagsProposalAdapter(requireContext(), database?.tagPool) + tagsAdapter = TagsProposalAdapter(requireContext(), database.tagPool) tagsCompletionView.apply { threshold = 1 setAdapter(tagsAdapter) } - tagsContainerView.visibility = if (database?.allowTags() == true) View.VISIBLE else View.GONE + tagsContainerView.visibility = if (database.allowTags()) View.VISIBLE else View.GONE } private fun assignEntryInfo(entryInfo: EntryInfo?) { diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryFragment.kt index a9de1526c..5b276a168 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryFragment.kt @@ -24,7 +24,7 @@ import com.kunzisoft.keepass.model.StreamDirection import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.timeout.ClipboardHelper import com.kunzisoft.keepass.utils.TimeUtil.getDateTimeString -import com.kunzisoft.keepass.utils.UuidUtil +import com.kunzisoft.keepass.utils.UUIDUtils.asHexString import com.kunzisoft.keepass.view.TemplateView import com.kunzisoft.keepass.view.hideByFading import com.kunzisoft.keepass.view.showByFading @@ -133,7 +133,7 @@ class EntryFragment: DatabaseFragment() { } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { context?.let { context -> attachmentsAdapter = EntryAttachmentsItemsAdapter(context) attachmentsAdapter?.database = database @@ -184,7 +184,7 @@ class EntryFragment: DatabaseFragment() { // customDataView.text = entryInfo?.customData?.toString() // Assign special data - uuidReferenceView.text = UuidUtil.toHexString(entryInfo?.id) + uuidReferenceView.text = entryInfo?.id?.asHexString() } private fun showClipboardDialog() { diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt index f26cc3a70..242d87ab0 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt @@ -36,9 +36,9 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.adapters.NodesAdapter +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveSpecialMode +import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.Group import com.kunzisoft.keepass.database.element.SortNodeEnum @@ -154,46 +154,44 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen super.onDetach() } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { context?.let { context -> - database?.let { database -> - mAdapter = NodesAdapter(context, database).apply { - setOnNodeClickListener(object : NodesAdapter.NodeClickCallback { - override fun onNodeClick(database: ContextualDatabase, node: Node) { - if (nodeActionSelectionMode) { - if (listActionNodes.contains(node)) { - // Remove selected item if already selected - listActionNodes.remove(node) - } else { - // Add selected item if not already selected - listActionNodes.add(node) - } - nodeClickListener?.onNodeSelected(database, listActionNodes) - setActionNodes(listActionNodes) - notifyNodeChanged(node) + mAdapter = NodesAdapter(context, database).apply { + setOnNodeClickListener(object : NodesAdapter.NodeClickCallback { + override fun onNodeClick(database: ContextualDatabase, node: Node) { + if (nodeActionSelectionMode) { + if (listActionNodes.contains(node)) { + // Remove selected item if already selected + listActionNodes.remove(node) } else { - nodeClickListener?.onNodeClick(database, node) + // Add selected item if not already selected + listActionNodes.add(node) } + nodeClickListener?.onNodeSelected(database, listActionNodes) + setActionNodes(listActionNodes) + notifyNodeChanged(node) + } else { + nodeClickListener?.onNodeClick(database, node) } + } - override fun onNodeLongClick(database: ContextualDatabase, node: Node): Boolean { - if (nodeActionPasteMode == PasteMode.UNDEFINED) { - // Select the first item after a long click - if (!listActionNodes.contains(node)) - listActionNodes.add(node) + override fun onNodeLongClick(database: ContextualDatabase, node: Node): Boolean { + if (nodeActionPasteMode == PasteMode.UNDEFINED) { + // Select the first item after a long click + if (!listActionNodes.contains(node)) + listActionNodes.add(node) - nodeClickListener?.onNodeSelected(database, listActionNodes) + nodeClickListener?.onNodeSelected(database, listActionNodes) - setActionNodes(listActionNodes) - notifyNodeChanged(node) - activity?.hideKeyboard() - } - return true + setActionNodes(listActionNodes) + notifyNodeChanged(node) + activity?.hideKeyboard() } - }) - } - mNodesRecyclerView?.adapter = mAdapter + return true + } + }) } + mNodesRecyclerView?.adapter = mAdapter } } @@ -248,7 +246,7 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen mNodesRecyclerView?.addOnScrollListener(mRecycleViewScrollListener) activity?.intent?.let { - specialMode = EntrySelectionHelper.retrieveSpecialModeFromIntent(it) + specialMode = it.retrieveSpecialMode() } } @@ -299,9 +297,9 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen } } - private fun containsRecycleBin(nodes: List): Boolean { - return mDatabase?.isRecycleBinEnabled == true - && nodes.any { it == mDatabase?.recycleBin } + private fun containsRecycleBin(database: ContextualDatabase?, nodes: List): Boolean { + return database?.isRecycleBinEnabled == true + && nodes.any { it == database.recycleBin } } fun actionNodesCallback(database: ContextualDatabase, @@ -328,7 +326,7 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen // Open and Edit for a single item if (nodes.size == 1) { // Edition - if (database.isReadOnly || containsRecycleBin(nodes)) { + if (database.isReadOnly || containsRecycleBin(database, nodes)) { menu?.removeItem(R.id.menu_edit) } } else { @@ -348,7 +346,7 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen } // Deletion - if (database.isReadOnly || containsRecycleBin(nodes)) { + if (database.isReadOnly || containsRecycleBin(database, nodes)) { menu?.removeItem(R.id.menu_delete) } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/IconFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/IconFragment.kt index 929015b30..3c5c6cd90 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/IconFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/IconFragment.kt @@ -71,8 +71,8 @@ abstract class IconFragment : DatabaseFragment(), resetAppTimeoutWhenViewFocusedOrChanged(view) } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - iconPickerAdapter.iconDrawableFactory = database?.iconDrawableFactory + override fun onDatabaseRetrieved(database: ContextualDatabase) { + iconPickerAdapter.iconDrawableFactory = database.iconDrawableFactory CoroutineScope(Dispatchers.IO).launch { val populateList = launch { diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/IconPickerFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/IconPickerFragment.kt index f2c2451f1..cbf798117 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/IconPickerFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/IconPickerFragment.kt @@ -48,9 +48,9 @@ class IconPickerFragment : DatabaseFragment() { } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { iconPickerPagerAdapter = IconPickerPagerAdapter(this, - if (database?.allowCustomIcons == true) 2 else 1) + if (database.allowCustomIcons) 2 else 1) viewPager.adapter = iconPickerPagerAdapter TabLayoutMediator(tabLayout, viewPager) { tab, position -> tab.text = when (position) { diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/KeyGeneratorFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/KeyGeneratorFragment.kt index d4abf2569..9b196b202 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/KeyGeneratorFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/KeyGeneratorFragment.kt @@ -107,7 +107,7 @@ class KeyGeneratorFragment : DatabaseFragment() { super.onDestroyView() } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { // Nothing here } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/PassphraseGeneratorFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/PassphraseGeneratorFragment.kt index 6070ea8d2..de576bd10 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/PassphraseGeneratorFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/PassphraseGeneratorFragment.kt @@ -244,7 +244,7 @@ class PassphraseGeneratorFragment : DatabaseFragment() { } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { // Nothing here } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/PasswordGeneratorFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/PasswordGeneratorFragment.kt index 7a7d9c067..c35f90aaf 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/PasswordGeneratorFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/PasswordGeneratorFragment.kt @@ -293,20 +293,22 @@ class PasswordGeneratorFragment : DatabaseFragment() { private fun generatePassword() { var password = "" try { - password = PasswordGenerator(resources).generatePassword(getPasswordLength(), - uppercaseCompound.isChecked, - lowercaseCompound.isChecked, - digitsCompound.isChecked, - minusCompound.isChecked, - underlineCompound.isChecked, - spaceCompound.isChecked, - specialsCompound.isChecked, - bracketsCompound.isChecked, - extendedCompound.isChecked, - getConsiderChars(), - getIgnoreChars(), - atLeastOneCompound.isChecked, - excludeAmbiguousCompound.isChecked) + password = PasswordGenerator(resources).generatePassword( + length = getPasswordLength(), + upperCase = uppercaseCompound.isChecked, + lowerCase = lowercaseCompound.isChecked, + digits = digitsCompound.isChecked, + minus = minusCompound.isChecked, + underline = underlineCompound.isChecked, + space = spaceCompound.isChecked, + specials = specialsCompound.isChecked, + brackets = bracketsCompound.isChecked, + extended = extendedCompound.isChecked, + considerChars = getConsiderChars(), + ignoreChars = getIgnoreChars(), + atLeastOneFromEach = atLeastOneCompound.isChecked, + excludeAmbiguousChar = excludeAmbiguousCompound.isChecked + ) } catch (e: Exception) { Log.e(TAG, "Unable to generate a password", e) } @@ -318,7 +320,7 @@ class PasswordGeneratorFragment : DatabaseFragment() { super.onDestroy() } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { + override fun onDatabaseRetrieved(database: ContextualDatabase) { // Nothing here } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt b/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt deleted file mode 100644 index 31a988772..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt +++ /dev/null @@ -1,211 +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 . - * - */ -package com.kunzisoft.keepass.activities.helpers - -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 -import com.kunzisoft.keepass.utils.getParcelableExtraCompat -import com.kunzisoft.keepass.utils.getEnumExtra -import com.kunzisoft.keepass.utils.putEnumExtra - -object EntrySelectionHelper { - - private const val KEY_SPECIAL_MODE = "com.kunzisoft.keepass.extra.SPECIAL_MODE" - private const val KEY_TYPE_MODE = "com.kunzisoft.keepass.extra.TYPE_MODE" - private const val KEY_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO" - private const val KEY_REGISTER_INFO = "com.kunzisoft.keepass.extra.REGISTER_INFO" - - fun startActivityForSearchModeResult(context: Context, - intent: Intent, - searchInfo: SearchInfo) { - addSpecialModeInIntent(intent, SpecialMode.SEARCH) - addSearchInfoInIntent(intent, searchInfo) - intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) - } - - fun startActivityForSaveModeResult(context: Context, - intent: Intent, - searchInfo: SearchInfo) { - addSpecialModeInIntent(intent, SpecialMode.SAVE) - addTypeModeInIntent(intent, TypeMode.DEFAULT) - addSearchInfoInIntent(intent, searchInfo) - intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) - } - - fun startActivityForKeyboardSelectionModeResult(context: Context, - intent: Intent, - searchInfo: SearchInfo?) { - addSpecialModeInIntent(intent, SpecialMode.SELECTION) - addTypeModeInIntent(intent, TypeMode.MAGIKEYBOARD) - addSearchInfoInIntent(intent, searchInfo) - intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) - } - - fun startActivityForRegistrationModeResult(context: Context, - intent: Intent, - registerInfo: RegisterInfo?) { - addSpecialModeInIntent(intent, SpecialMode.REGISTRATION) - // At the moment, only autofill for registration - addTypeModeInIntent(intent, TypeMode.AUTOFILL) - addRegisterInfoInIntent(intent, registerInfo) - intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) - } - - fun addSearchInfoInIntent(intent: Intent, searchInfo: SearchInfo?) { - searchInfo?.let { - intent.putExtra(KEY_SEARCH_INFO, it) - } - } - - fun retrieveSearchInfoFromIntent(intent: Intent): SearchInfo? { - return intent.getParcelableExtraCompat(KEY_SEARCH_INFO) - } - - private fun addRegisterInfoInIntent(intent: Intent, registerInfo: RegisterInfo?) { - registerInfo?.let { - intent.putExtra(KEY_REGISTER_INFO, it) - } - } - - fun retrieveRegisterInfoFromIntent(intent: Intent): RegisterInfo? { - return intent.getParcelableExtraCompat(KEY_REGISTER_INFO) - } - - fun removeInfoFromIntent(intent: Intent) { - intent.removeExtra(KEY_SEARCH_INFO) - intent.removeExtra(KEY_REGISTER_INFO) - } - - fun addSpecialModeInIntent(intent: Intent, specialMode: SpecialMode) { - intent.putEnumExtra(KEY_SPECIAL_MODE, specialMode) - } - - fun retrieveSpecialModeFromIntent(intent: Intent): SpecialMode { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (AutofillHelper.retrieveAutofillComponent(intent) != null) - return SpecialMode.SELECTION - } - return intent.getEnumExtra(KEY_SPECIAL_MODE) ?: SpecialMode.DEFAULT - } - - private fun addTypeModeInIntent(intent: Intent, typeMode: TypeMode) { - intent.putEnumExtra(KEY_TYPE_MODE, typeMode) - } - - fun retrieveTypeModeFromIntent(intent: Intent): TypeMode { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (AutofillHelper.retrieveAutofillComponent(intent) != null) - return TypeMode.AUTOFILL - } - return intent.getEnumExtra(KEY_TYPE_MODE) ?: TypeMode.DEFAULT - } - - fun removeModesFromIntent(intent: Intent) { - intent.removeExtra(KEY_SPECIAL_MODE) - intent.removeExtra(KEY_TYPE_MODE) - } - - fun doSpecialAction(intent: Intent, - defaultAction: () -> Unit, - searchAction: (searchInfo: SearchInfo) -> Unit, - saveAction: (searchInfo: SearchInfo) -> Unit, - keyboardSelectionAction: (searchInfo: SearchInfo?) -> Unit, - autofillSelectionAction: (searchInfo: SearchInfo?, - autofillComponent: AutofillComponent) -> Unit, - autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) { - - when (retrieveSpecialModeFromIntent(intent)) { - SpecialMode.DEFAULT -> { - removeModesFromIntent(intent) - removeInfoFromIntent(intent) - defaultAction.invoke() - } - SpecialMode.SEARCH -> { - val searchInfo = retrieveSearchInfoFromIntent(intent) - removeModesFromIntent(intent) - removeInfoFromIntent(intent) - if (searchInfo != null) - searchAction.invoke(searchInfo) - else { - defaultAction.invoke() - } - } - SpecialMode.SAVE -> { - val searchInfo = retrieveSearchInfoFromIntent(intent) - removeModesFromIntent(intent) - removeInfoFromIntent(intent) - if (searchInfo != null) - saveAction.invoke(searchInfo) - else { - defaultAction.invoke() - } - } - SpecialMode.SELECTION -> { - val searchInfo: SearchInfo? = retrieveSearchInfoFromIntent(intent) - var autofillComponentInit = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AutofillHelper.retrieveAutofillComponent(intent)?.let { autofillComponent -> - autofillSelectionAction.invoke(searchInfo, autofillComponent) - autofillComponentInit = true - } - } - if (!autofillComponentInit) { - if (intent.getEnumExtra(KEY_SPECIAL_MODE) != null) { - when (retrieveTypeModeFromIntent(intent)) { - TypeMode.DEFAULT -> { - removeModesFromIntent(intent) - if (searchInfo != null) - searchAction.invoke(searchInfo) - else - defaultAction.invoke() - } - TypeMode.MAGIKEYBOARD -> keyboardSelectionAction.invoke(searchInfo) - else -> { - // In this case, error - removeModesFromIntent(intent) - removeInfoFromIntent(intent) - } - } - } else { - if (searchInfo != null) - searchAction.invoke(searchInfo) - else - defaultAction.invoke() - } - } - } - SpecialMode.REGISTRATION -> { - val registerInfo: RegisterInfo? = retrieveRegisterInfoFromIntent(intent) - removeModesFromIntent(intent) - removeInfoFromIntent(intent) - autofillRegistrationAction.invoke(registerInfo) - } - } - } -} diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/TypeMode.kt b/app/src/main/java/com/kunzisoft/keepass/activities/helpers/TypeMode.kt deleted file mode 100644 index 2269c0b01..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/TypeMode.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.kunzisoft.keepass.activities.helpers - -enum class TypeMode { - DEFAULT, MAGIKEYBOARD, AUTOFILL -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseActivity.kt index a38b173ed..f823220f6 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseActivity.kt @@ -1,96 +1,240 @@ package com.kunzisoft.keepass.activities.legacy -import android.net.Uri +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.activities.dialogs.DatabaseChangedDialogFragment +import com.kunzisoft.keepass.activities.dialogs.DatabaseChangedDialogFragment.Companion.DATABASE_CHANGED_DIALOG_TAG import com.kunzisoft.keepass.activities.stylish.StylishActivity import com.kunzisoft.keepass.database.ContextualDatabase -import com.kunzisoft.keepass.database.MainCredential -import com.kunzisoft.keepass.database.DatabaseTaskProvider -import com.kunzisoft.keepass.model.CipherEncryptDatabase +import com.kunzisoft.keepass.database.DatabaseTaskProvider.Companion.startDatabaseService +import com.kunzisoft.keepass.database.ProgressMessage +import com.kunzisoft.keepass.model.SnapFileDatabaseInfo import com.kunzisoft.keepass.tasks.ActionRunnable -import com.kunzisoft.keepass.utils.getBinaryDir +import com.kunzisoft.keepass.tasks.ProgressTaskDialogFragment +import com.kunzisoft.keepass.tasks.ProgressTaskDialogFragment.Companion.PROGRESS_TASK_DIALOG_TAG import com.kunzisoft.keepass.viewmodels.DatabaseViewModel +import kotlinx.coroutines.launch -abstract class DatabaseActivity: StylishActivity(), DatabaseRetrieval { +abstract class DatabaseActivity : StylishActivity(), DatabaseRetrieval { protected val mDatabaseViewModel: DatabaseViewModel by viewModels() - protected var mDatabaseTaskProvider: DatabaseTaskProvider? = null - protected var mDatabase: ContextualDatabase? = null + protected val mDatabase: ContextualDatabase? + get() = mDatabaseViewModel.database + + private var progressTaskDialogFragment: ProgressTaskDialogFragment? = null + private var databaseChangedDialogFragment: DatabaseChangedDialogFragment? = null + + private val mActionDatabaseListener = + object : DatabaseChangedDialogFragment.ActionDatabaseChangedListener { + override fun onDatabaseChangeValidated() { + mDatabaseViewModel.onDatabaseChangeValidated() + } + } + + private val tempServiceParameters = mutableListOf>() + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { _ -> + // Whether or not the user has accepted, the service can be started, + // There just won't be any notification if it's not allowed. + tempServiceParameters.removeFirstOrNull()?.let { + startDatabaseService(it.first, it.second) + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mDatabaseViewModel.actionState.collect { uiState -> + when (uiState) { + is DatabaseViewModel.ActionState.Loading -> {} + is DatabaseViewModel.ActionState.OnDatabaseReloaded -> { + if (finishActivityIfReloadRequested()) { + finish() + } + } - mDatabaseTaskProvider = DatabaseTaskProvider(this, showDatabaseDialog()) + is DatabaseViewModel.ActionState.OnDatabaseInfoChanged -> { + if (manageDatabaseInfo()) { + showDatabaseChangedDialog( + uiState.previousDatabaseInfo, + uiState.newDatabaseInfo, + uiState.readOnlyDatabase + ) + } + } - mDatabaseTaskProvider?.onDatabaseRetrieved = { database -> - val databaseWasReloaded = database?.wasReloaded == true - if (databaseWasReloaded && finishActivityIfReloadRequested()) { - finish() - } else if (mDatabase == null || mDatabase != database || databaseWasReloaded) { - database?.wasReloaded = false - onDatabaseRetrieved(database) + is DatabaseViewModel.ActionState.OnDatabaseActionRequested -> { + startDatabasePermissionService( + uiState.bundle, + uiState.actionTask + ) + } + + is DatabaseViewModel.ActionState.OnDatabaseActionStarted -> { + if (showDatabaseDialog()) + startDialog(uiState.progressMessage) + } + + is DatabaseViewModel.ActionState.OnDatabaseActionUpdated -> { + if (showDatabaseDialog()) + updateDialog(uiState.progressMessage) + } + + is DatabaseViewModel.ActionState.OnDatabaseActionStopped -> { + // Remove the progress task + stopDialog() + } + + is DatabaseViewModel.ActionState.OnDatabaseActionFinished -> { + onDatabaseActionFinished( + uiState.database, + uiState.actionTask, + uiState.result + ) + stopDialog() + } + } + } } } - mDatabaseTaskProvider?.onActionFinish = { database, actionTask, result -> - onDatabaseActionFinished(database, actionTask, result) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + mDatabaseViewModel.databaseState.collect { database -> + // Nullable function + onUnknownDatabaseRetrieved(database) + database?.let { + onDatabaseRetrieved(database) + } + } + } } } - protected open fun showDatabaseDialog(): Boolean { - return true - } - - override fun onDestroy() { - mDatabaseTaskProvider?.destroy() - mDatabaseTaskProvider = null - mDatabase = null - super.onDestroy() - } - - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - mDatabase = database - mDatabaseViewModel.defineDatabase(database) + /** + * Nullable function to retrieve a database + */ + open fun onUnknownDatabaseRetrieved(database: ContextualDatabase?) { // optional method implementation } + override fun onDatabaseRetrieved(database: ContextualDatabase) { + // optional method implementation + } + + open fun manageDatabaseInfo(): Boolean = true + override fun onDatabaseActionFinished( database: ContextualDatabase, actionTask: String, result: ActionRunnable.Result ) { - mDatabaseViewModel.onActionFinished(database, actionTask, result) // optional method implementation } - fun createDatabase( - databaseUri: Uri, - mainCredential: MainCredential + private fun startDatabasePermissionService(bundle: Bundle?, actionTask: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED + ) { + startDatabaseService(bundle, actionTask) + } else if (ActivityCompat.shouldShowRequestPermissionRationale( + this, + Manifest.permission.POST_NOTIFICATIONS + ) + ) { + // it's not the first time, so the user deliberately chooses not to display the notification + startDatabaseService(bundle, actionTask) + } else { + AlertDialog.Builder(this) + .setMessage(R.string.warning_database_notification_permission) + .setNegativeButton(R.string.later) { _, _ -> + // Refuses the notification, so start the service + startDatabaseService(bundle, actionTask) + } + .setPositiveButton(R.string.ask) { _, _ -> + // Save the temp parameters to ask the permission + tempServiceParameters.add(Pair(bundle, actionTask)) + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + }.create().show() + } + } else { + startDatabaseService(bundle, actionTask) + } + } + + private fun showDatabaseChangedDialog( + previousDatabaseInfo: SnapFileDatabaseInfo, + newDatabaseInfo: SnapFileDatabaseInfo, + readOnlyDatabase: Boolean ) { - mDatabaseTaskProvider?.startDatabaseCreate(databaseUri, mainCredential) + lifecycleScope.launch { + if (databaseChangedDialogFragment == null) { + databaseChangedDialogFragment = supportFragmentManager + .findFragmentByTag(DATABASE_CHANGED_DIALOG_TAG) as DatabaseChangedDialogFragment? + databaseChangedDialogFragment?.actionDatabaseListener = + mActionDatabaseListener + } + if (progressTaskDialogFragment == null) { + databaseChangedDialogFragment = DatabaseChangedDialogFragment.getInstance( + previousDatabaseInfo, + newDatabaseInfo, + readOnlyDatabase + ) + databaseChangedDialogFragment?.actionDatabaseListener = + mActionDatabaseListener + databaseChangedDialogFragment?.show( + supportFragmentManager, + DATABASE_CHANGED_DIALOG_TAG + ) + } + } } - fun loadDatabase( - databaseUri: Uri, - mainCredential: MainCredential, - readOnly: Boolean, - cipherEncryptDatabase: CipherEncryptDatabase?, - fixDuplicateUuid: Boolean - ) { - mDatabaseTaskProvider?.startDatabaseLoad(databaseUri, mainCredential, readOnly, cipherEncryptDatabase, fixDuplicateUuid) + private fun startDialog(progressMessage: ProgressMessage) { + lifecycleScope.launch { + if (progressTaskDialogFragment == null) { + progressTaskDialogFragment = supportFragmentManager + .findFragmentByTag(PROGRESS_TASK_DIALOG_TAG) as ProgressTaskDialogFragment? + } + if (progressTaskDialogFragment == null) { + progressTaskDialogFragment = ProgressTaskDialogFragment() + progressTaskDialogFragment?.show( + supportFragmentManager, + PROGRESS_TASK_DIALOG_TAG + ) + } + updateDialog(progressMessage) + } } - protected fun closeDatabase() { - mDatabase?.clearAndClose(this.getBinaryDir()) + private fun updateDialog(progressMessage: ProgressMessage) { + progressTaskDialogFragment?.apply { + updateTitle(progressMessage.titleId) + updateMessage(progressMessage.messageId) + updateWarning(progressMessage.warningId) + setCancellable(progressMessage.cancelable) + } } - override fun onResume() { - super.onResume() - mDatabaseTaskProvider?.registerProgressTask() + private fun stopDialog() { + progressTaskDialogFragment?.dismissAllowingStateLoss() + progressTaskDialogFragment = null } - override fun onPause() { - mDatabaseTaskProvider?.unregisterProgressTask() - super.onPause() + protected open fun showDatabaseDialog(): Boolean { + return true } } \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseLockActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseLockActivity.kt index 9f02b639b..dc0bf7f2e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseLockActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseLockActivity.kt @@ -34,8 +34,8 @@ import androidx.appcompat.app.AlertDialog import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.dialogs.DeleteNodesDialogFragment import com.kunzisoft.keepass.activities.dialogs.PasswordEncodingDialogFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.removeModes +import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.element.Entry @@ -87,128 +87,44 @@ abstract class DatabaseLockActivity : DatabaseModeActivity(), deleteDatabaseNodes(nodes) } - mDatabaseViewModel.saveDatabase.observe(this) { save -> - mDatabaseTaskProvider?.startDatabaseSave(save) - } - - mDatabaseViewModel.mergeDatabase.observe(this) { save -> - mDatabaseTaskProvider?.startDatabaseMerge(save) - } - - mDatabaseViewModel.reloadDatabase.observe(this) { fixDuplicateUuid -> - mDatabaseTaskProvider?.askToStartDatabaseReload(mDatabase?.dataModifiedSinceLastLoading != false) { - mDatabaseTaskProvider?.startDatabaseReload(fixDuplicateUuid) - } - } - - mDatabaseViewModel.saveName.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveName(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveDescription.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveDescription(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveDefaultUsername.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveDefaultUsername(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveColor.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveColor(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveCompression.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveCompression(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.removeUnlinkData.observe(this) { - mDatabaseTaskProvider?.startDatabaseRemoveUnlinkedData(it) - } - - mDatabaseViewModel.saveRecycleBin.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveRecycleBin(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveTemplatesGroup.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveTemplatesGroup(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveMaxHistoryItems.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveMaxHistoryItems(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveMaxHistorySize.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveMaxHistorySize(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveEncryption.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveEncryption(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveKeyDerivation.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveKeyDerivation(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveIterations.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveIterations(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveMemoryUsage.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveMemoryUsage(it.oldValue, it.newValue, it.save) - } - - mDatabaseViewModel.saveParallelism.observe(this) { - mDatabaseTaskProvider?.startDatabaseSaveParallelism(it.oldValue, it.newValue, it.save) - } - mExitLock = false } - open fun finishActivityIfDatabaseNotLoaded(): Boolean { - return true - } - - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - + override fun onDatabaseRetrieved(database: ContextualDatabase) { // End activity if database not loaded - if (finishActivityIfDatabaseNotLoaded() && (database == null || !database.loaded)) { + if (database.loaded.not()) finish() - } // Focus view to reinitialize timeout, // view is not necessary loaded so retry later in resume viewToInvalidateTimeout() - ?.resetAppTimeoutWhenViewTouchedOrFocused(this, database?.loaded) + ?.resetAppTimeoutWhenViewTouchedOrFocused(this, database.loaded) - database?.let { - // check timeout - if (mTimeoutEnable) { - if (mLockReceiver == null) { - mLockReceiver = LockReceiver { - mDatabase = null - closeDatabase(database) - mExitLock = true - closeOptionsMenu() - finish() - } - registerLockReceiver(mLockReceiver) + // check timeout + if (mTimeoutEnable) { + if (mLockReceiver == null) { + mLockReceiver = LockReceiver { + closeDatabase(database) + mExitLock = true + closeOptionsMenu() + finish() } - - // After the first creation - // or If simply swipe with another application - // If the time is out -> close the Activity - TimeoutHelper.checkTimeAndLockIfTimeout(this) - // If onCreate already record time - if (!mExitLock) - TimeoutHelper.recordTime(this, database.loaded) + registerLockReceiver(mLockReceiver) } - mDatabaseReadOnly = database.isReadOnly - mMergeDataAllowed = database.isMergeDataAllowed() - - checkRegister() + // After the first creation + // or If simply swipe with another application + // If the time is out -> close the Activity + TimeoutHelper.checkTimeAndLockIfTimeout(this) + // If onCreate already record time + if (!mExitLock) + TimeoutHelper.recordTime(this, database.loaded) } + + mDatabaseReadOnly = database.isReadOnly + mMergeDataAllowed = database.isMergeDataAllowed() + + checkRegister() } override fun finish() { @@ -227,7 +143,6 @@ abstract class DatabaseLockActivity : DatabaseModeActivity(), actionTask: String, result: ActionRunnable.Result ) { - super.onDatabaseActionFinished(database, actionTask, result) when (actionTask) { DatabaseTaskNotificationService.ACTION_DATABASE_MERGE_TASK, DatabaseTaskNotificationService.ACTION_DATABASE_RELOAD_TASK -> { @@ -249,24 +164,15 @@ abstract class DatabaseLockActivity : DatabaseModeActivity(), databaseUri: Uri?, mainCredential: MainCredential ) { - assignDatabasePassword(databaseUri, mainCredential) + mDatabaseViewModel.assignMainCredential(databaseUri, mainCredential) } - private fun assignDatabasePassword( - databaseUri: Uri?, - mainCredential: MainCredential - ) { - if (databaseUri != null) { - mDatabaseTaskProvider?.startDatabaseAssignCredential(databaseUri, mainCredential) - } - } - - fun assignPassword(mainCredential: MainCredential) { + fun assignMainCredential(mainCredential: MainCredential) { mDatabase?.let { database -> database.fileUri?.let { databaseUri -> // Show the progress dialog now or after dialog confirmation if (database.isValidCredential(mainCredential.toMasterCredential(contentResolver))) { - assignDatabasePassword(databaseUri, mainCredential) + mDatabaseViewModel.assignMainCredential(databaseUri, mainCredential) } else { PasswordEncodingDialogFragment.getInstance(databaseUri, mainCredential) .show(supportFragmentManager, "passwordEncodingTag") @@ -276,45 +182,51 @@ abstract class DatabaseLockActivity : DatabaseModeActivity(), } fun saveDatabase() { - mDatabaseTaskProvider?.startDatabaseSave(true) + mDatabaseViewModel.saveDatabase(save = true) } fun saveDatabaseTo(uri: Uri) { - mDatabaseTaskProvider?.startDatabaseSave(true, uri) + mDatabaseViewModel.saveDatabase(save = true, saveToUri = uri) } fun mergeDatabase() { - mDatabaseTaskProvider?.startDatabaseMerge(mAutoSaveEnable) + mDatabaseViewModel.mergeDatabase(save = mAutoSaveEnable) } fun mergeDatabaseFrom(uri: Uri, mainCredential: MainCredential) { - mDatabaseTaskProvider?.startDatabaseMerge(mAutoSaveEnable, uri, mainCredential) + mDatabaseViewModel.mergeDatabase(mAutoSaveEnable, uri, mainCredential) } fun reloadDatabase() { - mDatabaseTaskProvider?.askToStartDatabaseReload(mDatabase?.dataModifiedSinceLastLoading != false) { - mDatabaseTaskProvider?.startDatabaseReload(false) - } + mDatabaseViewModel.reloadDatabase(fixDuplicateUuid = false) } - fun createEntry(newEntry: Entry, - parent: Group) { - mDatabaseTaskProvider?.startDatabaseCreateEntry(newEntry, parent, mAutoSaveEnable) + fun createEntry( + newEntry: Entry, + parent: Group + ) { + mDatabaseViewModel.createEntry(newEntry, parent, mAutoSaveEnable) } - fun updateEntry(oldEntry: Entry, - entryToUpdate: Entry) { - mDatabaseTaskProvider?.startDatabaseUpdateEntry(oldEntry, entryToUpdate, mAutoSaveEnable) + fun updateEntry( + oldEntry: Entry, + entryToUpdate: Entry + ) { + mDatabaseViewModel.updateEntry(oldEntry, entryToUpdate, mAutoSaveEnable) } - fun copyNodes(nodesToCopy: List, - newParent: Group) { - mDatabaseTaskProvider?.startDatabaseCopyNodes(nodesToCopy, newParent, mAutoSaveEnable) + fun copyNodes( + nodesToCopy: List, + newParent: Group + ) { + mDatabaseViewModel.copyNodes(nodesToCopy, newParent, mAutoSaveEnable) } - fun moveNodes(nodesToMove: List, - newParent: Group) { - mDatabaseTaskProvider?.startDatabaseMoveNodes(nodesToMove, newParent, mAutoSaveEnable) + fun moveNodes( + nodesToMove: List, + newParent: Group + ) { + mDatabaseViewModel.moveNodes(nodesToMove, newParent, mAutoSaveEnable) } private fun eachNodeRecyclable(database: ContextualDatabase, nodes: List): Boolean { @@ -330,6 +242,7 @@ abstract class DatabaseLockActivity : DatabaseModeActivity(), } fun deleteNodes(nodes: List, recycleBin: Boolean = false) { + // TODO Move in ViewModel mDatabase?.let { database -> // If recycle bin enabled, ensure it exists if (database.isRecycleBinEnabled) { @@ -350,11 +263,14 @@ abstract class DatabaseLockActivity : DatabaseModeActivity(), } private fun deleteDatabaseNodes(nodes: List) { - mDatabaseTaskProvider?.startDatabaseDeleteNodes(nodes, mAutoSaveEnable) + mDatabaseViewModel.deleteNodes(nodes, mAutoSaveEnable) } - fun createGroup(parent: Group, - groupInfo: GroupInfo?) { + fun createGroup( + parent: Group, + groupInfo: GroupInfo? + ) { + // TODO Move in ViewModel // Build the group mDatabase?.createGroup()?.let { newGroup -> groupInfo?.let { info -> @@ -362,12 +278,15 @@ abstract class DatabaseLockActivity : DatabaseModeActivity(), } // Not really needed here because added in runnable but safe newGroup.parent = parent - mDatabaseTaskProvider?.startDatabaseCreateGroup(newGroup, parent, mAutoSaveEnable) + mDatabaseViewModel.createGroup(newGroup, parent, mAutoSaveEnable) } } - fun updateGroup(oldGroup: Group, - groupInfo: GroupInfo) { + fun updateGroup( + oldGroup: Group, + groupInfo: GroupInfo + ) { + // TODO Move in ViewModel // If group updated save it in the database val updateGroup = Group(oldGroup).let { updateGroup -> updateGroup.apply { @@ -377,27 +296,28 @@ abstract class DatabaseLockActivity : DatabaseModeActivity(), this.setGroupInfo(groupInfo) } } - mDatabaseTaskProvider?.startDatabaseUpdateGroup(oldGroup, updateGroup, mAutoSaveEnable) + mDatabaseViewModel.updateGroup(oldGroup, updateGroup, mAutoSaveEnable) } - fun restoreEntryHistory(mainEntryId: NodeId, - entryHistoryPosition: Int) { - mDatabaseTaskProvider - ?.startDatabaseRestoreEntryHistory(mainEntryId, entryHistoryPosition, mAutoSaveEnable) + fun restoreEntryHistory( + mainEntryId: NodeId, + entryHistoryPosition: Int + ) { + mDatabaseViewModel.restoreEntryHistory(mainEntryId, entryHistoryPosition, mAutoSaveEnable) } - fun deleteEntryHistory(mainEntryId: NodeId, - entryHistoryPosition: Int) { - mDatabaseTaskProvider?.startDatabaseDeleteEntryHistory(mainEntryId, entryHistoryPosition, mAutoSaveEnable) + fun deleteEntryHistory( + mainEntryId: NodeId, + entryHistoryPosition: Int + ) { + mDatabaseViewModel.deleteEntryHistory(mainEntryId, entryHistoryPosition, mAutoSaveEnable) } private fun checkRegister() { - // If in ave or registration mode, don't allow read only - if ((mSpecialMode == SpecialMode.SAVE - || mSpecialMode == SpecialMode.REGISTRATION) - && mDatabaseReadOnly) { + // If in registration mode, don't allow read only + if (mSpecialMode == SpecialMode.REGISTRATION && mDatabaseReadOnly) { Toast.makeText(this, R.string.error_registration_read_only , Toast.LENGTH_LONG).show() - EntrySelectionHelper.removeModesFromIntent(intent) + intent.removeModes() finish() } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt index 640682278..4f9860753 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt @@ -1,13 +1,24 @@ package com.kunzisoft.keepass.activities.legacy +import android.content.Intent import android.os.Bundle import android.view.View import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode -import com.kunzisoft.keepass.activities.helpers.TypeMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.isIntentSenderMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.removeInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.removeModes +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveRegisterInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveSearchInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveSpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveTypeMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.setActivityResult +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.view.ToolbarSpecial @@ -19,10 +30,25 @@ import com.kunzisoft.keepass.view.ToolbarSpecial abstract class DatabaseModeActivity : DatabaseActivity() { protected var mSpecialMode: SpecialMode = SpecialMode.DEFAULT - private var mTypeMode: TypeMode = TypeMode.DEFAULT + protected var mTypeMode: TypeMode = TypeMode.DEFAULT private var mToolbarSpecial: ToolbarSpecial? = null + /** + * Utility activity result launcher, + * Used recursively, close each activity with return data + */ + protected open var mCredentialActivityResultLauncher: ActivityResultLauncher? = + registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + setActivityResult( + lockDatabase = false, + resultCode = it.resultCode, + data = it.data + ) + } + open fun onDatabaseBackPressed() { if (mSpecialMode != SpecialMode.DEFAULT) onCancelSpecialMode() @@ -42,20 +68,14 @@ abstract class DatabaseModeActivity : DatabaseActivity() { /** * Intent sender uses special retains data in callback */ - private fun isIntentSender(): Boolean { - return (mSpecialMode == SpecialMode.SELECTION - && mTypeMode == TypeMode.AUTOFILL) - /* TODO Registration callback #765 - || (mSpecialMode == SpecialMode.REGISTRATION - && mTypeMode == TypeMode.AUTOFILL - && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - */ + protected fun isIntentSender(): Boolean { + return isIntentSenderMode(mSpecialMode, mTypeMode) } fun onLaunchActivitySpecialMode() { if (!isIntentSender()) { - EntrySelectionHelper.removeModesFromIntent(intent) - EntrySelectionHelper.removeInfoFromIntent(intent) + intent.removeModes() + intent.removeInfo() finish() } } @@ -64,8 +84,8 @@ abstract class DatabaseModeActivity : DatabaseActivity() { if (isIntentSender()) { super.finish() } else { - EntrySelectionHelper.removeModesFromIntent(intent) - EntrySelectionHelper.removeInfoFromIntent(intent) + intent.removeModes() + intent.removeInfo() if (mSpecialMode != SpecialMode.DEFAULT) { backToTheMainAppAndFinish() } @@ -77,8 +97,8 @@ abstract class DatabaseModeActivity : DatabaseActivity() { // To get the app caller, only for IntentSender onRegularBackPressed() } else { - EntrySelectionHelper.removeModesFromIntent(intent) - EntrySelectionHelper.removeInfoFromIntent(intent) + intent.removeModes() + intent.removeInfo() if (mSpecialMode != SpecialMode.DEFAULT) { backToTheMainAppAndFinish() } @@ -109,17 +129,18 @@ abstract class DatabaseModeActivity : DatabaseActivity() { } }) - mSpecialMode = EntrySelectionHelper.retrieveSpecialModeFromIntent(intent) - mTypeMode = EntrySelectionHelper.retrieveTypeModeFromIntent(intent) + mSpecialMode = intent.retrieveSpecialMode() + mTypeMode = intent.retrieveTypeMode() } override fun onResume() { super.onResume() - mSpecialMode = EntrySelectionHelper.retrieveSpecialModeFromIntent(intent) - mTypeMode = EntrySelectionHelper.retrieveTypeModeFromIntent(intent) - val searchInfo: SearchInfo? = EntrySelectionHelper.retrieveRegisterInfoFromIntent(intent)?.searchInfo - ?: EntrySelectionHelper.retrieveSearchInfoFromIntent(intent) + mSpecialMode = intent.retrieveSpecialMode() + mTypeMode = intent.retrieveTypeMode() + val registerInfo: RegisterInfo? = intent.retrieveRegisterInfo() + val searchInfo: SearchInfo? = registerInfo?.searchInfo + ?: intent.retrieveSearchInfo() // To show the selection mode mToolbarSpecial = findViewById(R.id.special_mode_view) @@ -128,26 +149,25 @@ abstract class DatabaseModeActivity : DatabaseActivity() { val selectionModeStringId = when (mSpecialMode) { SpecialMode.DEFAULT, // Not important because hidden SpecialMode.SEARCH -> R.string.search_mode - SpecialMode.SAVE -> R.string.save_mode SpecialMode.SELECTION -> R.string.selection_mode - SpecialMode.REGISTRATION -> R.string.registration_mode + SpecialMode.REGISTRATION -> R.string.save_mode // Save is registration mode } val typeModeStringId = when (mTypeMode) { TypeMode.DEFAULT, // Not important because hidden TypeMode.MAGIKEYBOARD -> R.string.magic_keyboard_title TypeMode.AUTOFILL -> R.string.autofill + TypeMode.PASSKEY -> R.string.passkey } title = getString(selectionModeStringId) if (mTypeMode != TypeMode.DEFAULT) title = "$title (${getString(typeModeStringId)})" // Populate subtitle - subtitle = searchInfo?.getName(resources) + subtitle = registerInfo?.getName(resources) ?: searchInfo?.getName(resources) // Show the toolbar or not visible = when (mSpecialMode) { SpecialMode.DEFAULT -> false SpecialMode.SEARCH -> true - SpecialMode.SAVE -> true SpecialMode.SELECTION -> true SpecialMode.REGISTRATION -> true } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseRetrieval.kt b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseRetrieval.kt index 8c9db97ac..4ac41db06 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseRetrieval.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseRetrieval.kt @@ -4,8 +4,11 @@ import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.tasks.ActionRunnable interface DatabaseRetrieval { - fun onDatabaseRetrieved(database: ContextualDatabase?) - fun onDatabaseActionFinished(database: ContextualDatabase, - actionTask: String, - result: ActionRunnable.Result) + fun onDatabaseRetrieved(database: ContextualDatabase) + + fun onDatabaseActionFinished( + database: ContextualDatabase, + actionTask: String, + result: ActionRunnable.Result + ) } \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt b/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt index b58005093..890e0d7b4 100644 --- a/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt +++ b/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt @@ -22,7 +22,6 @@ package com.kunzisoft.keepass.adapters import android.content.Context import android.content.res.ColorStateList import android.graphics.Color -import android.os.Build import android.util.Log import android.util.TypedValue import android.view.LayoutInflater @@ -418,6 +417,7 @@ class NodesAdapter ( } } + // OTP val otpElement = entry.getOtpElement() holder.otpContainer?.removeCallbacks(holder.otpRunnable) if (otpElement != null @@ -438,7 +438,11 @@ class NodesAdapter ( holder.otpContainer?.visibility = View.GONE } holder.attachmentIcon?.visibility = - if (entry.containsAttachment()) View.VISIBLE else View.GONE + if (entry.containsAttachment()) View.VISIBLE else View.GONE + + // Passkey + holder.passkeyIcon?.visibility = + if (entry.getPasskey() != null) View.VISIBLE else View.GONE // Assign colors assignBackgroundColor(holder.container, entry) @@ -451,6 +455,7 @@ class NodesAdapter ( holder.otpToken?.setTextColor(foregroundColor) holder.otpProgress?.setIndicatorColor(foregroundColor) holder.attachmentIcon?.setColorFilter(foregroundColor) + holder.passkeyIcon?.setColorFilter(foregroundColor) holder.meta.setTextColor(foregroundColor) iconColor = foregroundColor } else { @@ -459,6 +464,7 @@ class NodesAdapter ( holder.otpToken?.setTextColor(mTextColorSecondary) holder.otpProgress?.setIndicatorColor(mTextColorSecondary) holder.attachmentIcon?.setColorFilter(mTextColorSecondary) + holder.passkeyIcon?.setColorFilter(mTextColorSecondary) holder.meta.setTextColor(mTextColor) } } else { @@ -467,6 +473,7 @@ class NodesAdapter ( holder.otpToken?.setTextColor(mColorOnSecondary) holder.otpProgress?.setIndicatorColor(mColorOnSecondary) holder.attachmentIcon?.setColorFilter(mColorOnSecondary) + holder.passkeyIcon?.setColorFilter(mColorOnSecondary) holder.meta.setTextColor(mColorOnSecondary) } @@ -611,6 +618,7 @@ class NodesAdapter ( var otpRunnable: OtpRunnable = OtpRunnable(otpContainer) var numberChildren: TextView? = itemView.findViewById(R.id.node_child_numbers) var attachmentIcon: ImageView? = itemView.findViewById(R.id.node_attachment_icon) + var passkeyIcon: ImageView? = itemView.findViewById(R.id.node_passkey_icon) } companion object { diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt b/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt deleted file mode 100644 index bf8a2996f..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.kunzisoft.keepass.autofill - -import android.app.assist.AssistStructure - -data class AutofillComponent(val assistStructure: AssistStructure, - val compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest?) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt new file mode 100644 index 000000000..bf60df362 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt @@ -0,0 +1,374 @@ +/* + * 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build +import android.os.Bundle +import android.os.ParcelUuid +import android.util.Log +import android.widget.RemoteViews +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.model.EntryInfo +import com.kunzisoft.keepass.model.RegisterInfo +import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.utils.LOCK_ACTION +import com.kunzisoft.keepass.utils.getEnumExtra +import com.kunzisoft.keepass.utils.getParcelableExtraCompat +import com.kunzisoft.keepass.utils.getParcelableList +import com.kunzisoft.keepass.utils.putEnumExtra +import com.kunzisoft.keepass.utils.putParcelableList +import java.util.UUID + +object EntrySelectionHelper { + + private const val KEY_SPECIAL_MODE = "com.kunzisoft.keepass.extra.SPECIAL_MODE" + private const val KEY_TYPE_MODE = "com.kunzisoft.keepass.extra.TYPE_MODE" + private const val KEY_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO" + private const val KEY_REGISTER_INFO = "com.kunzisoft.keepass.extra.REGISTER_INFO" + private const val EXTRA_NODES_IDS = "com.kunzisoft.keepass.extra.NODES_IDS" + private const val EXTRA_NODE_ID = "com.kunzisoft.keepass.extra.NODE_ID" + + /** + * Finish the activity by passing the result code and by locking the database if necessary + */ + fun Activity.setActivityResult( + lockDatabase: Boolean = false, + resultCode: Int, + data: Intent? = null + ) { + when (resultCode) { + Activity.RESULT_OK -> + this.setResult(resultCode, data) + Activity.RESULT_CANCELED -> + this.setResult(resultCode) + } + this.finish() + + if (lockDatabase) { + // Close the database + this.sendBroadcast(Intent(LOCK_ACTION)) + } + } + + fun startActivityForSearchModeResult( + context: Context, + intent: Intent, + searchInfo: SearchInfo + ) { + intent.addSpecialMode(SpecialMode.SEARCH) + intent.addSearchInfo(searchInfo) + intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK + context.startActivity(intent) + } + + fun startActivityForSelectionModeResult( + context: Context, + intent: Intent, + typeMode: TypeMode, + searchInfo: SearchInfo?, + activityResultLauncher: ActivityResultLauncher? = null, + ) { + intent.addSpecialMode(SpecialMode.SELECTION) + intent.addTypeMode(typeMode) + intent.addSearchInfo(searchInfo) + if (activityResultLauncher == null) { + intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + activityResultLauncher?.launch(intent) ?: context.startActivity(intent) + } + + fun startActivityForRegistrationModeResult( + context: Context, + activityResultLauncher: ActivityResultLauncher?, + intent: Intent, + registerInfo: RegisterInfo?, + typeMode: TypeMode + ) { + intent.addSpecialMode(SpecialMode.REGISTRATION) + intent.addTypeMode(typeMode) + intent.addRegisterInfo(registerInfo) + if (activityResultLauncher == null) { + intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + activityResultLauncher?.launch(intent) ?: context.startActivity(intent) + } + + /** + * Build the special mode response for internal entry selection for one entry + */ + fun Activity.buildSpecialModeResponseAndSetResult( + entryInfo: EntryInfo, + extras: Bundle? = null + ) { + this.buildSpecialModeResponseAndSetResult(listOf(entryInfo), extras) + } + + /** + * Build the special mode response for internal entry selection for multiple entries + */ + fun Activity.buildSpecialModeResponseAndSetResult( + entriesInfo: List, + extras: Bundle? = null + ) { + try { + val mReplyIntent = Intent() + Log.d(javaClass.name, "Success special mode manual selection") + mReplyIntent.addNodesIds(entriesInfo.map { it.id }) + extras?.let { + mReplyIntent.putExtras(it) + } + setResult(Activity.RESULT_OK, mReplyIntent) + } catch (e: Exception) { + Log.e(javaClass.name, "Unable to add the result", e) + setResult(Activity.RESULT_CANCELED) + } + } + + fun Intent.addSearchInfo(searchInfo: SearchInfo?): Intent { + searchInfo?.let { + putExtra(KEY_SEARCH_INFO, it) + } + return this + } + + fun Intent.retrieveSearchInfo(): SearchInfo? { + return getParcelableExtraCompat(KEY_SEARCH_INFO) + } + + fun Intent.addRegisterInfo(registerInfo: RegisterInfo?): Intent { + registerInfo?.let { + putExtra(KEY_REGISTER_INFO, it) + } + return this + } + + fun Intent.retrieveRegisterInfo(): RegisterInfo? { + return getParcelableExtraCompat(KEY_REGISTER_INFO) + } + + fun Intent.removeInfo() { + removeExtra(KEY_SEARCH_INFO) + removeExtra(KEY_REGISTER_INFO) + } + + fun Intent.addSpecialMode(specialMode: SpecialMode): Intent { + this.putEnumExtra(KEY_SPECIAL_MODE, specialMode) + return this + } + + fun Intent.retrieveSpecialMode(): SpecialMode { + return getEnumExtra(KEY_SPECIAL_MODE) ?: SpecialMode.DEFAULT + } + + fun Intent.addTypeMode(typeMode: TypeMode): Intent { + this.putEnumExtra(KEY_TYPE_MODE, typeMode) + return this + } + + fun Intent.retrieveTypeMode(): TypeMode { + return getEnumExtra(KEY_TYPE_MODE) ?: TypeMode.DEFAULT + } + + fun Intent.removeModes() { + removeExtra(KEY_SPECIAL_MODE) + removeExtra(KEY_TYPE_MODE) + } + + fun Intent.addNodesIds(nodesIds: List): Intent { + this.putParcelableList(EXTRA_NODES_IDS, nodesIds.map { ParcelUuid(it) }) + return this + } + + fun Intent.retrieveNodesIds(): List? { + return getParcelableList(EXTRA_NODES_IDS)?.map { it.uuid } + } + + fun Intent.removeNodesIds() { + removeExtra(EXTRA_NODES_IDS) + } + + /** + * Add the node id to the intent + */ + fun Intent.addNodeId(nodeId: UUID?) { + nodeId?.let { + putExtra(EXTRA_NODE_ID, ParcelUuid(nodeId)) + } + } + + /** + * Retrieve the node id from the intent + */ + fun Intent.retrieveNodeId(): UUID? { + return getParcelableExtraCompat(EXTRA_NODE_ID)?.uuid + } + + fun Intent.removeNodeId() { + removeExtra(EXTRA_NODE_ID) + } + + /** + * Intent sender uses special retains data in callback + */ + fun isIntentSenderMode(specialMode: SpecialMode, typeMode: TypeMode): Boolean { + return (specialMode == SpecialMode.SELECTION + && (typeMode == TypeMode.AUTOFILL || typeMode == TypeMode.PASSKEY)) + || (specialMode == SpecialMode.REGISTRATION + && (typeMode == TypeMode.AUTOFILL || typeMode == TypeMode.PASSKEY)) + } + + fun doSpecialAction( + intent: Intent, + defaultAction: () -> Unit, + searchAction: (searchInfo: SearchInfo) -> Unit, + selectionAction: ( + intentSenderMode: Boolean, + typeMode: TypeMode, + searchInfo: SearchInfo? + ) -> Unit, + registrationAction: ( + intentSenderMode: Boolean, + typeMode: TypeMode, + registerInfo: RegisterInfo? + ) -> Unit + ) { + when (val specialMode = intent.retrieveSpecialMode()) { + SpecialMode.DEFAULT -> { + intent.removeModes() + intent.removeInfo() + defaultAction.invoke() + } + SpecialMode.SEARCH -> { + val searchInfo = intent.retrieveSearchInfo() + intent.removeModes() + intent.removeInfo() + if (searchInfo != null) + searchAction.invoke(searchInfo) + else { + defaultAction.invoke() + } + } + SpecialMode.SELECTION -> { + val searchInfo: SearchInfo? = intent.retrieveSearchInfo() + if (intent.getEnumExtra(KEY_SPECIAL_MODE) != null) { + when (val typeMode = intent.retrieveTypeMode()) { + TypeMode.DEFAULT -> { + intent.removeModes() + if (searchInfo != null) + searchAction.invoke(searchInfo) + else + defaultAction.invoke() + } + TypeMode.MAGIKEYBOARD -> selectionAction.invoke( + isIntentSenderMode(specialMode, typeMode), + typeMode, + searchInfo + ) + TypeMode.PASSKEY -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + selectionAction.invoke( + isIntentSenderMode(specialMode, typeMode), + typeMode, + searchInfo + ) + } else + defaultAction.invoke() + TypeMode.AUTOFILL -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + selectionAction.invoke( + isIntentSenderMode(specialMode, typeMode), + typeMode, + searchInfo + ) + } else + defaultAction.invoke() + } + } + } else { + if (searchInfo != null) + searchAction.invoke(searchInfo) + else + defaultAction.invoke() + } + } + SpecialMode.REGISTRATION -> { + val registerInfo: RegisterInfo? = intent.retrieveRegisterInfo() + val typeMode = intent.retrieveTypeMode() + val intentSenderMode = isIntentSenderMode(specialMode, typeMode) + if (!intentSenderMode) { + intent.removeModes() + intent.removeInfo() + } + if (registerInfo != null) + registrationAction.invoke( + intentSenderMode, + typeMode, + registerInfo + ) + else { + defaultAction.invoke() + } + } + } + } + + fun performSelection(items: List, + actionPopulateCredentialProvider: (entryInfo: EntryInfo) -> Unit, + actionEntrySelection: (autoSearch: Boolean) -> Unit) { + if (items.size == 1) { + val itemFound = items[0] + actionPopulateCredentialProvider.invoke(itemFound) + } else if (items.size > 1) { + // Select the one we want in the selection + actionEntrySelection.invoke(true) + } else { + // Select an arbitrary one + actionEntrySelection.invoke(false) + } + } + + /** + * Method to assign a drawable to a new icon from a database icon + */ + @RequiresApi(Build.VERSION_CODES.M) + fun EntryInfo.buildIcon( + context: Context, + database: ContextualDatabase + ): Icon? { + try { + database.iconDrawableFactory.getBitmapFromIcon(context, + this.icon, ContextCompat.getColor(context, R.color.green))?.let { bitmap -> + return IconCompat.createWithBitmap(bitmap).toIcon(context) + } + } catch (e: Exception) { + Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e) + } + return null + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/SpecialMode.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/SpecialMode.kt similarity index 59% rename from app/src/main/java/com/kunzisoft/keepass/activities/helpers/SpecialMode.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/SpecialMode.kt index 7b1dbc2ef..d4694d50a 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/SpecialMode.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/SpecialMode.kt @@ -1,9 +1,8 @@ -package com.kunzisoft.keepass.activities.helpers +package com.kunzisoft.keepass.credentialprovider enum class SpecialMode { DEFAULT, SEARCH, - SAVE, SELECTION, REGISTRATION; } \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/TypeMode.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/TypeMode.kt new file mode 100644 index 000000000..55b28e31c --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/TypeMode.kt @@ -0,0 +1,5 @@ +package com.kunzisoft.keepass.credentialprovider + +enum class TypeMode { + DEFAULT, MAGIKEYBOARD, PASSKEY, AUTOFILL +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/AutofillLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/AutofillLauncherActivity.kt new file mode 100644 index 000000000..1fad3d25d --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/AutofillLauncherActivity.kt @@ -0,0 +1,237 @@ +/* + * 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.activity + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.lifecycle.lifecycleScope +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity +import com.kunzisoft.keepass.activities.GroupActivity +import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addRegisterInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSearchInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.setActivityResult +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper.addAutofillComponent +import com.kunzisoft.keepass.credentialprovider.viewmodel.AutofillLauncherViewModel +import com.kunzisoft.keepass.credentialprovider.viewmodel.CredentialLauncherViewModel +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.exception.RegisterInReadOnlyDatabaseException +import com.kunzisoft.keepass.model.RegisterInfo +import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.utils.AppUtil.randomRequestCode +import com.kunzisoft.keepass.view.toastError +import kotlinx.coroutines.launch + +@RequiresApi(api = Build.VERSION_CODES.O) +class AutofillLauncherActivity : DatabaseModeActivity() { + + private val autofillLauncherViewModel: AutofillLauncherViewModel by viewModels() + + private var mAutofillSelectionActivityResultLauncher: ActivityResultLauncher? = + this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + autofillLauncherViewModel.manageSelectionResult(it) + } + + private var mAutofillRegistrationActivityResultLauncher: ActivityResultLauncher? = + this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + autofillLauncherViewModel.manageRegistrationResult(it) + } + + override fun applyCustomStyle(): Boolean { + return false + } + + override fun finishActivityIfReloadRequested(): Boolean { + return true + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + autofillLauncherViewModel.initialize() + lifecycleScope.launch { + // Initialize the parameters + autofillLauncherViewModel.uiState.collect { uiState -> + when (uiState) { + AutofillLauncherViewModel.UIState.Loading -> {} + is AutofillLauncherViewModel.UIState.ShowBlockRestartMessage -> { + showBlockRestartMessage() + autofillLauncherViewModel.cancelResult() + } + is AutofillLauncherViewModel.UIState.ShowReadOnlyMessage -> { + showReadOnlySaveMessage() + autofillLauncherViewModel.cancelResult() + } + is AutofillLauncherViewModel.UIState.ShowAutofillSuggestionMessage -> { + showAutofillSuggestionMessage() + } + } + } + } + lifecycleScope.launch { + // Retrieve the UI + autofillLauncherViewModel.credentialUiState.collect { uiState -> + when (uiState) { + is CredentialLauncherViewModel.UIState.Loading -> {} + is CredentialLauncherViewModel.UIState.LaunchGroupActivityForSelection -> { + GroupActivity.launchForSelection( + context = this@AutofillLauncherActivity, + database = uiState.database, + searchInfo = uiState.searchInfo, + typeMode = uiState.typeMode, + activityResultLauncher = mAutofillSelectionActivityResultLauncher, + ) + } + is CredentialLauncherViewModel.UIState.LaunchGroupActivityForRegistration -> { + GroupActivity.launchForRegistration( + context = this@AutofillLauncherActivity, + database = uiState.database, + registerInfo = uiState.registerInfo, + typeMode = uiState.typeMode, + activityResultLauncher = mAutofillRegistrationActivityResultLauncher + ) + } + is CredentialLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForSelection -> { + FileDatabaseSelectActivity.launchForSelection( + context = this@AutofillLauncherActivity, + searchInfo = uiState.searchInfo, + typeMode = uiState.typeMode, + activityResultLauncher = mAutofillSelectionActivityResultLauncher + ) + } + is CredentialLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForRegistration -> { + FileDatabaseSelectActivity.launchForRegistration( + context = this@AutofillLauncherActivity, + registerInfo = uiState.registerInfo, + typeMode = uiState.typeMode, + activityResultLauncher = mAutofillRegistrationActivityResultLauncher, + ) + } + is CredentialLauncherViewModel.UIState.SetActivityResult -> { + setActivityResult( + lockDatabase = uiState.lockDatabase, + resultCode = uiState.resultCode, + data = uiState.data + ) + } + is CredentialLauncherViewModel.UIState.ShowError -> { + toastError(uiState.error) + autofillLauncherViewModel.cancelResult() + } + } + } + } + } + + override fun onUnknownDatabaseRetrieved(database: ContextualDatabase?) { + super.onUnknownDatabaseRetrieved(database) + autofillLauncherViewModel.launchActionIfNeeded(intent, mSpecialMode, database) + } + + private fun showBlockRestartMessage() { + // If item not allowed, show a toast + Toast.makeText( + applicationContext, + R.string.autofill_block_restart, + Toast.LENGTH_LONG + ).show() + } + + private fun showAutofillSuggestionMessage() { + Toast.makeText( + applicationContext, + R.string.autofill_inline_suggestions_keyboard, + Toast.LENGTH_SHORT + ).show() + } + + private fun showReadOnlySaveMessage() { + toastError(RegisterInReadOnlyDatabaseException()) + } + + companion object { + + private val TAG = AutofillLauncherActivity::class.java.name + + fun getPendingIntentForSelection( + context: Context, + searchInfo: SearchInfo? = null, + autofillComponent: AutofillComponent + ): PendingIntent? { + try { + return PendingIntent.getActivity( + context, + randomRequestCode(), + // Doesn't work with direct extra Parcelable (don't know why?) + // Wrap into a bundle to bypass the problem + Intent(context, AutofillLauncherActivity::class.java).apply { + addSpecialMode(SpecialMode.SELECTION) + addSearchInfo(searchInfo) + addAutofillComponent(autofillComponent) + }, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT + } else { + PendingIntent.FLAG_CANCEL_CURRENT + } + ) + } catch (e: RuntimeException) { + Log.e(TAG, "Unable to create pending intent for selection", e) + return null + } + } + + fun getPendingIntentForRegistration( + context: Context, + registerInfo: RegisterInfo + ): PendingIntent? { + try { + return PendingIntent.getActivity( + context, + randomRequestCode(), + Intent(context, AutofillLauncherActivity::class.java).apply { + addSpecialMode(SpecialMode.REGISTRATION) + addRegisterInfo(registerInfo) + }, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT + } else { + PendingIntent.FLAG_CANCEL_CURRENT + } + ) + } catch (e: RuntimeException) { + Log.e(TAG, "Unable to create pending intent for registration", e) + return null + } + } + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntrySelectionLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/EntrySelectionLauncherActivity.kt similarity index 50% rename from app/src/main/java/com/kunzisoft/keepass/activities/EntrySelectionLauncherActivity.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/EntrySelectionLauncherActivity.kt index 679ab39ba..7bb3936e8 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntrySelectionLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/EntrySelectionLauncherActivity.kt @@ -17,23 +17,25 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.activities +package com.kunzisoft.keepass.credentialprovider.activity import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.widget.Toast -import com.kunzisoft.keepass.R +import androidx.core.net.toUri +import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity +import com.kunzisoft.keepass.activities.GroupActivity import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.exception.RegisterInReadOnlyDatabaseException import com.kunzisoft.keepass.database.helper.SearchHelper -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.otp.OtpEntryFields import com.kunzisoft.keepass.utils.KeyboardUtil.isKeyboardActivatedInSettings import com.kunzisoft.keepass.utils.getParcelableCompat -import com.kunzisoft.keepass.utils.WebDomain +import com.kunzisoft.keepass.view.toastError /** * Activity to search or select entry in database, @@ -49,8 +51,8 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() { return false } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) + override fun onUnknownDatabaseRetrieved(database: ContextualDatabase?) { + super.onUnknownDatabaseRetrieved(database) val keySelectionBundle = intent.getBundleExtra(KEY_SELECTION_BUNDLE) if (keySelectionBundle != null) { @@ -73,7 +75,7 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() { if (OtpEntryFields.isOTPUri(extra)) otpString = extra else - sharedWebDomain = Uri.parse(extra).host + sharedWebDomain = extra.toUri().host } } launchSelection(database, sharedWebDomain, otpString) @@ -84,7 +86,7 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() { if (OtpEntryFields.isOTPUri(extra)) otpString = extra } - launchSelection(database, sharedWebDomain, otpString) + launchSelection(database, null, otpString) } else -> { if (database != null) { @@ -106,11 +108,7 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() { this.webDomain = sharedWebDomain this.otpString = otpString } - - WebDomain.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain -> - searchInfo.webDomain = concreteWebDomain - launch(database, searchInfo) - } + launch(database, searchInfo) } private fun launch(database: ContextualDatabase?, @@ -121,87 +119,106 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() { // If database is open val readOnly = database?.isReadOnly != false - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, items -> - // Items found - if (searchInfo.otpString != null) { - if (!readOnly) { - GroupActivity.launchForSaveResult( - this, - openedDatabase, - searchInfo, - false) - } else { - Toast.makeText(applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG) - .show() - } - } else if (searchShareForMagikeyboard) { - MagikeyboardService.performSelection( - items, - { entryInfo -> - // Automatically populate keyboard - MagikeyboardService.populateKeyboardAndMoveAppToBackground( - this, - entryInfo - ) - }, - { autoSearch -> - GroupActivity.launchForKeyboardSelectionResult(this, - openedDatabase, - searchInfo, - autoSearch) - } + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> + // Items found + if (searchInfo.otpString != null) { + if (!readOnly) { + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = null, + database = openedDatabase, + registerInfo = searchInfo.toRegisterInfo(), + typeMode = TypeMode.DEFAULT ) } else { - GroupActivity.launchForSearchResult(this, - openedDatabase, - searchInfo, - true) + toastError(RegisterInReadOnlyDatabaseException()) } - }, - { openedDatabase -> - // Show the database UI to select the entry - if (searchInfo.otpString != null) { - if (!readOnly) { - GroupActivity.launchForSaveResult(this, - openedDatabase, - searchInfo, - false) - } else { - Toast.makeText(applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG) - .show() + } else if (searchShareForMagikeyboard) { + MagikeyboardService.performSelection( + items, + { entryInfo -> + // Automatically populate keyboard + MagikeyboardService.populateKeyboardAndMoveAppToBackground( + this, + entryInfo + ) + }, + { autoSearch -> + GroupActivity.launchForSelection( + context = this, + database = openedDatabase, + typeMode = TypeMode.MAGIKEYBOARD, + searchInfo = searchInfo, + autoSearch = autoSearch + ) } - } else if (searchShareForMagikeyboard) { - GroupActivity.launchForKeyboardSelectionResult(this, - openedDatabase, - searchInfo, - false) - } else { - GroupActivity.launchForSearchResult(this, - openedDatabase, - searchInfo, - false) - } - }, - { - // If database not open - if (searchInfo.otpString != null) { - FileDatabaseSelectActivity.launchForSaveResult(this, - searchInfo) - } else if (searchShareForMagikeyboard) { - FileDatabaseSelectActivity.launchForKeyboardSelectionResult(this, - searchInfo) - } else { - FileDatabaseSelectActivity.launchForSearchResult(this, - searchInfo) - } + ) + } else { + GroupActivity.launchForSearchResult( + this, + openedDatabase, + searchInfo, + true + ) } + }, + onItemNotFound = { openedDatabase -> + // Show the database UI to select the entry + if (searchInfo.otpString != null) { + if (!readOnly) { + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = null, + database = openedDatabase, + registerInfo = searchInfo.toRegisterInfo(), + typeMode = TypeMode.DEFAULT + ) + } else { + toastError(RegisterInReadOnlyDatabaseException()) + } + } else if (searchShareForMagikeyboard) { + GroupActivity.launchForSelection( + context = this, + database = openedDatabase, + typeMode = TypeMode.MAGIKEYBOARD, + searchInfo = searchInfo, + autoSearch = false + ) + } else { + GroupActivity.launchForSearchResult( + this, + openedDatabase, + searchInfo, + false + ) + } + }, + onDatabaseClosed = { + // If database not open + if (searchInfo.otpString != null) { + FileDatabaseSelectActivity.launchForRegistration( + context = this, + activityResultLauncher = null, + registerInfo = searchInfo.toRegisterInfo(), + typeMode = TypeMode.DEFAULT + ) + } else if (searchShareForMagikeyboard) { + FileDatabaseSelectActivity.launchForSelection( + context = this, + typeMode = TypeMode.MAGIKEYBOARD, + searchInfo = searchInfo + ) + } else { + FileDatabaseSelectActivity.launchForSearchResult( + this, + searchInfo + ) + } + } ) } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/HardwareKeyActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/HardwareKeyActivity.kt new file mode 100644 index 000000000..8759cc86e --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/HardwareKeyActivity.kt @@ -0,0 +1,170 @@ +package com.kunzisoft.keepass.credentialprovider.activity + +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.setActivityResult +import com.kunzisoft.keepass.credentialprovider.viewmodel.CredentialLauncherViewModel +import com.kunzisoft.keepass.credentialprovider.viewmodel.HardwareKeyLauncherViewModel +import com.kunzisoft.keepass.credentialprovider.viewmodel.HardwareKeyLauncherViewModel.Companion.addHardwareKey +import com.kunzisoft.keepass.credentialprovider.viewmodel.HardwareKeyLauncherViewModel.Companion.addSeed +import com.kunzisoft.keepass.credentialprovider.viewmodel.HardwareKeyLauncherViewModel.Companion.buildHardwareKeyChallenge +import com.kunzisoft.keepass.credentialprovider.viewmodel.HardwareKeyLauncherViewModel.Companion.isYubikeyDriverAvailable +import com.kunzisoft.keepass.credentialprovider.viewmodel.HardwareKeyLauncherViewModel.UIState +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.hardware.HardwareKey +import com.kunzisoft.keepass.tasks.ActionRunnable +import com.kunzisoft.keepass.utils.AppUtil.openExternalApp +import com.kunzisoft.keepass.view.toastError +import kotlinx.coroutines.launch + +/** + * Special activity to deal with hardware key drivers, + * return the response to the database service once finished + */ +class HardwareKeyActivity: DatabaseModeActivity(){ + + private val mHardwareKeyLauncherViewModel: HardwareKeyLauncherViewModel by viewModels() + + private var activityResultLauncher: ActivityResultLauncher = + this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + mHardwareKeyLauncherViewModel.manageSelectionResult(it) + } + + override fun applyCustomStyle(): Boolean = false + + override fun showDatabaseDialog(): Boolean = false + + override fun manageDatabaseInfo(): Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + mHardwareKeyLauncherViewModel.uiState.collect { uiState -> + when (uiState) { + is UIState.Loading -> {} + is UIState.ShowHardwareKeyDriverNeeded -> { + showHardwareKeyDriverNeeded( + this@HardwareKeyActivity, + uiState.hardwareKey + ) { + mDatabaseViewModel.onChallengeResponded(null) + finish() + } + } + is UIState.LaunchChallengeActivityForResponse -> { + // Send to the driver + activityResultLauncher.launch( + buildHardwareKeyChallenge(uiState.challenge) + ) + } + is UIState.OnChallengeResponded -> { + mDatabaseViewModel.onChallengeResponded(uiState.response) + } + } + } + } + lifecycleScope.launch { + mHardwareKeyLauncherViewModel.credentialUiState.collect { uiState -> + when (uiState) { + is CredentialLauncherViewModel.UIState.SetActivityResult -> { + setActivityResult( + lockDatabase = uiState.lockDatabase, + resultCode = uiState.resultCode, + data = uiState.data + ) + } + is CredentialLauncherViewModel.UIState.ShowError -> { + toastError(uiState.error) + mHardwareKeyLauncherViewModel.cancelResult() + } + else -> {} + } + } + } + } + + override fun onDatabaseRetrieved(database: ContextualDatabase) { + super.onDatabaseRetrieved(database) + mHardwareKeyLauncherViewModel.launchActionIfNeeded(intent, mSpecialMode, database) + } + + override fun onDatabaseActionFinished( + database: ContextualDatabase, + actionTask: String, + result: ActionRunnable.Result + ) { + super.onDatabaseActionFinished(database, actionTask, result) + finish() + } + + private fun showHardwareKeyDriverNeeded( + context: Context, + hardwareKey: HardwareKey?, + onDialogDismissed: DialogInterface.OnDismissListener + ) { + val builder = AlertDialog.Builder(context) + builder + .setMessage( + context.getString(R.string.error_driver_required, hardwareKey.toString()) + ) + .setPositiveButton(R.string.download) { _, _ -> + context.openExternalApp( + context.getString(R.string.key_driver_app_id), + context.getString(R.string.key_driver_url) + ) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .setOnDismissListener(onDialogDismissed) + builder.create().show() + } + + companion object { + private val TAG = HardwareKeyActivity::class.java.simpleName + + fun launchHardwareKeyActivity( + context: Context, + hardwareKey: HardwareKey, + seed: ByteArray? + ) { + context.startActivity( + Intent( + context, + HardwareKeyActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_MULTIPLE_TASK + addHardwareKey(hardwareKey) + addSeed(seed) + }) + } + + fun isHardwareKeyAvailable( + context: Context, + hardwareKey: HardwareKey? + ): Boolean { + if (hardwareKey == null) + return false + return when (hardwareKey) { + /* + HardwareKey.FIDO2_SECRET -> { + // TODO FIDO2 under development + false + } + */ + HardwareKey.CHALLENGE_RESPONSE_YUBIKEY -> { + // Check available intent + isYubikeyDriverAvailable(context) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt new file mode 100644 index 000000000..1fc0c04c6 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -0,0 +1,298 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.activity + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity +import com.kunzisoft.keepass.activities.GroupActivity +import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addNodeId +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSearchInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addTypeMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.setActivityResult +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAppOrigin +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAuthCode +import com.kunzisoft.keepass.credentialprovider.viewmodel.CredentialLauncherViewModel +import com.kunzisoft.keepass.credentialprovider.viewmodel.PasskeyLauncherViewModel +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.model.AppOrigin +import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK +import com.kunzisoft.keepass.tasks.ActionRunnable +import com.kunzisoft.keepass.utils.AppUtil.randomRequestCode +import com.kunzisoft.keepass.view.toastError +import kotlinx.coroutines.launch +import java.util.UUID + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeyLauncherActivity : DatabaseLockActivity() { + + private val passkeyLauncherViewModel: PasskeyLauncherViewModel by viewModels() + + private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher? = + this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + passkeyLauncherViewModel.manageSelectionResult(it) + } + + private var mPasskeyRegistrationActivityResultLauncher: ActivityResultLauncher? = + this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + passkeyLauncherViewModel.manageRegistrationResult(it) + } + + override fun applyCustomStyle(): Boolean { + return false + } + + override fun finishActivityIfReloadRequested(): Boolean { + return false + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + // Initialize the parameters + passkeyLauncherViewModel.initialize() + // Retrieve the UI + passkeyLauncherViewModel.uiState.collect { uiState -> + when (uiState) { + is PasskeyLauncherViewModel.UIState.Loading -> { + // Nothing to do + } + is PasskeyLauncherViewModel.UIState.ShowAppPrivilegedDialog -> { + showAppPrivilegedDialog( + temptingApp = uiState.temptingApp + ) + } + is PasskeyLauncherViewModel.UIState.ShowAppSignatureDialog -> { + showAppSignatureDialog( + temptingApp = uiState.temptingApp, + nodeId = uiState.nodeId + ) + } + is PasskeyLauncherViewModel.UIState.UpdateEntry -> { + updateEntry(uiState.oldEntry, uiState.newEntry) + } + } + } + } + lifecycleScope.launch { + passkeyLauncherViewModel.credentialUiState.collect { uiState -> + when (uiState) { + is CredentialLauncherViewModel.UIState.Loading -> {} + is CredentialLauncherViewModel.UIState.SetActivityResult -> { + setActivityResult( + lockDatabase = uiState.lockDatabase, + resultCode = uiState.resultCode, + data = uiState.data + ) + } + is CredentialLauncherViewModel.UIState.ShowError -> { + toastError(uiState.error) + passkeyLauncherViewModel.cancelResult() + } + is CredentialLauncherViewModel.UIState.LaunchGroupActivityForSelection -> { + GroupActivity.launchForSelection( + context = this@PasskeyLauncherActivity, + database = uiState.database, + typeMode = uiState.typeMode, + searchInfo = uiState.searchInfo, + activityResultLauncher = mPasskeySelectionActivityResultLauncher + ) + } + is CredentialLauncherViewModel.UIState.LaunchGroupActivityForRegistration -> { + GroupActivity.launchForRegistration( + context = this@PasskeyLauncherActivity, + database = uiState.database, + typeMode = uiState.typeMode, + registerInfo = uiState.registerInfo, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher + ) + } + is CredentialLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForSelection -> { + FileDatabaseSelectActivity.launchForSelection( + context = this@PasskeyLauncherActivity, + typeMode = uiState.typeMode, + searchInfo = uiState.searchInfo, + activityResultLauncher = mPasskeySelectionActivityResultLauncher + ) + } + is CredentialLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForRegistration -> { + FileDatabaseSelectActivity.launchForRegistration( + context = this@PasskeyLauncherActivity, + typeMode = uiState.typeMode, + registerInfo = uiState.registerInfo, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + ) + } + } + } + } + } + + override fun onUnknownDatabaseRetrieved(database: ContextualDatabase?) { + super.onUnknownDatabaseRetrieved(database) + passkeyLauncherViewModel.launchActionIfNeeded(intent, mSpecialMode, database) + } + + override fun onDatabaseActionFinished( + database: ContextualDatabase, + actionTask: String, + result: ActionRunnable.Result + ) { + super.onDatabaseActionFinished(database, actionTask, result) + when (actionTask) { + ACTION_DATABASE_UPDATE_ENTRY_TASK -> { + // TODO When auto save is enabled, WARNING filter by the calling activity + // passkeyLauncherViewModel.autoSelectPasskey(result, database) + } + } + } + + override fun viewToInvalidateTimeout(): View? { + return null + } + + /** + * Display a dialog that asks the user to add an app to the list of privileged apps. + */ + private fun showAppPrivilegedDialog( + temptingApp: AndroidPrivilegedApp + ) { + Log.w(javaClass.simpleName, "No privileged apps file found") + AlertDialog.Builder(this@PasskeyLauncherActivity).apply { + setTitle(getString(R.string.passkeys_privileged_apps_ask_title)) + setMessage(StringBuilder() + .append( + getString( + R.string.passkeys_privileged_apps_ask_message, + temptingApp.toString() + ) + ) + .append("\n\n") + .append(getString(R.string.passkeys_privileged_apps_explanation)) + .toString() + ) + setPositiveButton(android.R.string.ok) { _, _ -> + passkeyLauncherViewModel.saveCustomPrivilegedApp( + intent = intent, + specialMode = mSpecialMode, + database = mDatabase, + temptingApp = temptingApp + ) + } + setNegativeButton(android.R.string.cancel) { _, _ -> + passkeyLauncherViewModel.cancelResult() + } + setOnCancelListener { + passkeyLauncherViewModel.cancelResult() + } + }.create().show() + } + + /** + * Display a dialog that asks the user to add an app signature in an existing passkey + */ + private fun showAppSignatureDialog( + temptingApp: AppOrigin, + nodeId: UUID + ) { + AlertDialog.Builder(this@PasskeyLauncherActivity).apply { + setTitle(getString(R.string.passkeys_missing_signature_app_ask_title)) + setMessage(StringBuilder() + .append( + getString( + R.string.passkeys_missing_signature_app_ask_message, + temptingApp.toString() + ) + ) + .append("\n\n") + .append(getString(R.string.passkeys_missing_signature_app_ask_explanation)) + .append("\n\n") + .append(getString(R.string.passkeys_missing_signature_app_ask_question)) + .toString() + ) + setPositiveButton(android.R.string.ok) { _, _ -> + passkeyLauncherViewModel.saveAppSignature( + database = mDatabase, + temptingApp = temptingApp, + nodeId = nodeId + ) + } + setNegativeButton(android.R.string.cancel) { _, _ -> + passkeyLauncherViewModel.cancelResult() + } + setOnCancelListener { + passkeyLauncherViewModel.cancelResult() + } + }.create().show() + } + + companion object { + private val TAG = PasskeyLauncherActivity::class.java.name + + /** + * Get a pending intent to launch the passkey launcher activity + * [nodeId] can be : + * - null if manual selection is requested + * - null if manual registration is requested + * - an entry node id if direct selection is requested + * - a group node id if direct registration is requested in a default group + * - an entry node id if overwriting is requested in an existing entry + */ + fun getPendingIntent( + context: Context, + specialMode: SpecialMode, + searchInfo: SearchInfo? = null, + appOrigin: AppOrigin? = null, + nodeId: UUID? = null + ): PendingIntent? { + return PendingIntent.getActivity( + context, + randomRequestCode(), + Intent(context, PasskeyLauncherActivity::class.java).apply { + addSpecialMode(specialMode) + addTypeMode(TypeMode.PASSKEY) + addSearchInfo(searchInfo) + addAppOrigin(appOrigin) + addNodeId(nodeId) + addAuthCode(nodeId) + }, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillComponent.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillComponent.kt new file mode 100644 index 000000000..7fe7a5e37 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillComponent.kt @@ -0,0 +1,8 @@ +package com.kunzisoft.keepass.credentialprovider.autofill + +import android.app.assist.AssistStructure + +data class AutofillComponent( + val assistStructure: AssistStructure, + val compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest? +) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillHelper.kt similarity index 68% rename from app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillHelper.kt index b0af5bf53..3663db746 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillHelper.kt @@ -17,10 +17,9 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.annotation.SuppressLint -import android.app.Activity import android.app.PendingIntent import android.app.assist.AssistStructure import android.content.Context @@ -38,19 +37,14 @@ import android.view.autofill.AutofillId import android.view.autofill.AutofillManager import android.view.autofill.AutofillValue import android.widget.RemoteViews -import android.widget.Toast import android.widget.inline.InlinePresentationSpec -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity 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.AutofillLauncherActivity -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildIcon +import com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.icon.IconImage import com.kunzisoft.keepass.database.element.template.TemplateField @@ -58,22 +52,32 @@ import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.settings.AutofillSettingsActivity import com.kunzisoft.keepass.settings.PreferencesUtil -import com.kunzisoft.keepass.utils.LOCK_ACTION +import com.kunzisoft.keepass.utils.AppUtil.randomRequestCode import com.kunzisoft.keepass.utils.getParcelableExtraCompat +import java.io.IOException import kotlin.math.min @RequiresApi(api = Build.VERSION_CODES.O) object AutofillHelper { - private const val EXTRA_ASSIST_STRUCTURE = AutofillManager.EXTRA_ASSIST_STRUCTURE + private const val EXTRA_BASE_STRUCTURE = "com.kunzisoft.keepass.autofill.BASE_STRUCTURE" private const val EXTRA_INLINE_SUGGESTIONS_REQUEST = "com.kunzisoft.keepass.autofill.INLINE_SUGGESTIONS_REQUEST" - fun retrieveAutofillComponent(intent: Intent?): AutofillComponent? { - intent?.getParcelableExtraCompat(EXTRA_ASSIST_STRUCTURE)?.let { assistStructure -> + fun Intent.addAutofillComponent(autofillComponent: AutofillComponent) { + this.putExtra(EXTRA_BASE_STRUCTURE, autofillComponent.assistStructure) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + autofillComponent.compatInlineSuggestionsRequest?.let { + this.putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it) + } + } + } + + fun Intent.retrieveAutofillComponent(): AutofillComponent? { + getParcelableExtraCompat(EXTRA_BASE_STRUCTURE)?.let { assistStructure -> return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { AutofillComponent(assistStructure, - intent.getParcelableExtraCompat(EXTRA_INLINE_SUGGESTIONS_REQUEST)) + getParcelableExtraCompat(EXTRA_INLINE_SUGGESTIONS_REQUEST)) } else { AutofillComponent(assistStructure, null) } @@ -132,11 +136,13 @@ object AutofillHelper { return this } - private fun buildDatasetForEntry(context: Context, - database: ContextualDatabase, - entryInfo: EntryInfo, - struct: StructureParser.Result, - inlinePresentation: InlinePresentation?): Dataset { + private fun buildDatasetForEntry( + context: Context, + database: ContextualDatabase, + entryInfo: EntryInfo, + struct: StructureParser.Result, + inlinePresentation: InlinePresentation? + ): Dataset { val remoteViews: RemoteViews = newRemoteViews(context, database, makeEntryTitle(entryInfo), entryInfo.icon) val datasetBuilder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -263,7 +269,7 @@ object AutofillHelper { } } } - for (field in entryInfo.customFields) { + for (field in entryInfo.getCustomFieldsForFilling()) { if (field.name == TemplateField.LABEL_HOLDER) { struct.creditCardHolderId?.let { ccNameId -> datasetBuilder.addValueToDatasetBuilder( @@ -294,30 +300,15 @@ object AutofillHelper { return dataset } - /** - * Method to assign a drawable to a new icon from a database icon - */ - private fun buildIconFromEntry(context: Context, - database: ContextualDatabase, - entryInfo: EntryInfo): Icon? { - try { - database.iconDrawableFactory.getBitmapFromIcon(context, - entryInfo.icon, ContextCompat.getColor(context, R.color.green))?.let { bitmap -> - return Icon.createWithBitmap(bitmap) - } - } catch (e: Exception) { - Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e) - } - return null - } - @SuppressLint("RestrictedApi") @RequiresApi(Build.VERSION_CODES.R) - private fun buildInlinePresentationForEntry(context: Context, - database: ContextualDatabase, - compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest, - positionItem: Int, - entryInfo: EntryInfo): InlinePresentation? { + private fun buildInlinePresentationForEntry( + context: Context, + database: ContextualDatabase, + compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest, + positionItem: Int, + entryInfo: EntryInfo + ): InlinePresentation? { compatInlineSuggestionsRequest.inlineSuggestionsRequest?.let { inlineSuggestionsRequest -> val inlinePresentationSpecs = inlineSuggestionsRequest.inlinePresentationSpecs val maxSuggestion = inlineSuggestionsRequest.maxSuggestionCount @@ -336,13 +327,9 @@ object AutofillHelper { // Build the content for IME UI val pendingIntent = PendingIntent.getActivity( context, - 0, + randomRequestCode(), Intent(context, AutofillSettingsActivity::class.java), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_IMMUTABLE - } else { - 0 - } + PendingIntent.FLAG_IMMUTABLE ) return InlinePresentation( InlineSuggestionUi.newContentBuilder(pendingIntent).apply { @@ -353,7 +340,7 @@ object AutofillHelper { Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply { setTintBlendMode(BlendMode.DST) }) - buildIconFromEntry(context, database, entryInfo)?.let { icon -> + entryInfo.buildIcon(context, database)?.let { icon -> setEndIcon(icon.apply { setTintBlendMode(BlendMode.DST) }) @@ -367,9 +354,11 @@ object AutofillHelper { @RequiresApi(Build.VERSION_CODES.R) @SuppressLint("RestrictedApi") - private fun buildInlinePresentationForManualSelection(context: Context, - inlinePresentationSpec: InlinePresentationSpec, - pendingIntent: PendingIntent): InlinePresentation? { + private fun buildInlinePresentationForManualSelection( + context: Context, + inlinePresentationSpec: InlinePresentationSpec, + pendingIntent: PendingIntent + ): InlinePresentation? { // 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)) @@ -386,11 +375,13 @@ object AutofillHelper { }.build().slice, inlinePresentationSpec, false) } - fun buildResponse(context: Context, - database: ContextualDatabase, - entriesInfo: List, - parseResult: StructureParser.Result, - compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest?): FillResponse? { + fun buildResponse( + context: Context, + database: ContextualDatabase, + entriesInfo: List, + parseResult: StructureParser.Result, + autofillComponent: AutofillComponent + ): FillResponse? { val responseBuilder = FillResponse.Builder() // Add Header if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -411,7 +402,8 @@ object AutofillHelper { // Add inline suggestion for new IME and dataset var numberInlineSuggestions = 0 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - compatInlineSuggestionsRequest?.inlineSuggestionsRequest?.let { inlineSuggestionsRequest -> + autofillComponent.compatInlineSuggestionsRequest + ?.inlineSuggestionsRequest?.let { inlineSuggestionsRequest -> numberInlineSuggestions = minOf(inlineSuggestionsRequest.maxSuggestionCount, entriesInfo.size) if (PreferencesUtil.isAutofillManualSelectionEnable(context)) { if (entriesInfo.size >= inlineSuggestionsRequest.maxSuggestionCount) { @@ -427,21 +419,27 @@ object AutofillHelper { var inlinePresentation: InlinePresentation? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && numberInlineSuggestions > 0 - && compatInlineSuggestionsRequest != null) { + && autofillComponent.compatInlineSuggestionsRequest != null) { inlinePresentation = buildInlinePresentationForEntry( context, database, - compatInlineSuggestionsRequest, + autofillComponent.compatInlineSuggestionsRequest, numberInlineSuggestions--, entry ) } // Create dataset for each entry responseBuilder.addDataset( - buildDatasetForEntry(context, database, entry, parseResult, inlinePresentation) + buildDatasetForEntry( + context = context, + database = database, + entryInfo = entry, + struct = parseResult, + inlinePresentation = inlinePresentation + ) ) } catch (e: Exception) { - Log.e(TAG, "Unable to add dataset") + Log.e(TAG, "Unable to add dataset", e) } } @@ -453,21 +451,28 @@ object AutofillHelper { webScheme = parseResult.webScheme manualSelection = true } - val manualSelectionView = RemoteViews(context.packageName, R.layout.item_autofill_select_entry) - AutofillLauncherActivity.getPendingIntentForSelection(context, - searchInfo, compatInlineSuggestionsRequest)?.let { pendingIntent -> + val manualSelectionView = RemoteViews( + context.packageName, + R.layout.item_autofill_select_entry + ) + AutofillLauncherActivity.getPendingIntentForSelection( + context, + searchInfo, + autofillComponent + )?.let { pendingIntent -> var inlinePresentation: InlinePresentation? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - compatInlineSuggestionsRequest?.inlineSuggestionsRequest?.let { inlineSuggestionsRequest -> - val inlinePresentationSpec = - inlineSuggestionsRequest.inlinePresentationSpecs[0] - inlinePresentation = buildInlinePresentationForManualSelection( - context, - inlinePresentationSpec, - pendingIntent - ) - } + autofillComponent.compatInlineSuggestionsRequest + ?.inlineSuggestionsRequest?.let { inlineSuggestionsRequest -> + val inlinePresentationSpec = + inlineSuggestionsRequest.inlinePresentationSpecs[0] + inlinePresentation = buildInlinePresentationForManualSelection( + context, + inlinePresentationSpec, + pendingIntent + ) + } } val datasetBuilder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -512,92 +517,33 @@ object AutofillHelper { } /** - * Build the Autofill response for one entry + * Build the Autofill response */ - fun buildResponseAndSetResult(activity: Activity, - database: ContextualDatabase, - entryInfo: EntryInfo) { - buildResponseAndSetResult(activity, database, ArrayList().apply { add(entryInfo) }) - } - - /** - * Build the Autofill response for many entry - */ - fun buildResponseAndSetResult(activity: Activity, - database: ContextualDatabase, - entriesInfo: List) { + fun buildResponse( + context: Context, + autofillComponent: AutofillComponent, + database: ContextualDatabase, + entriesInfo: List, + onIntentCreated: (Intent) -> Unit + ) { if (entriesInfo.isEmpty()) { - activity.setResult(Activity.RESULT_CANCELED) + throw IOException("No entries found") } else { - var setResultOk = false - activity.intent?.getParcelableExtraCompat(EXTRA_ASSIST_STRUCTURE)?.let { structure -> - StructureParser(structure).parse()?.let { result -> - // New Response - val response = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val compatInlineSuggestionsRequest = activity.intent?.getParcelableExtraCompat(EXTRA_INLINE_SUGGESTIONS_REQUEST) - if (compatInlineSuggestionsRequest != null) { - Toast.makeText(activity.applicationContext, R.string.autofill_inline_suggestions_keyboard, Toast.LENGTH_SHORT).show() - } - buildResponse(activity, database, entriesInfo, result, compatInlineSuggestionsRequest) - } else { - buildResponse(activity, database, entriesInfo, result, null) - } - val mReplyIntent = Intent() - Log.d(activity.javaClass.name, "Success 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) - } + StructureParser(autofillComponent.assistStructure).parse()?.let { result -> + // New Response + onIntentCreated(Intent().putExtra( + AutofillManager.EXTRA_AUTHENTICATION_RESULT, + buildResponse( + context = context, + database = database, + entriesInfo = entriesInfo, + parseResult = result, + autofillComponent = autofillComponent + ) + )) + } ?: throw IOException("Unable to parse the structure") } } - fun buildActivityResultLauncher(activity: AppCompatActivity, - lockDatabase: Boolean = false): ActivityResultLauncher { - return activity.registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - // Utility method to loop and close each activity with return data - if (it.resultCode == Activity.RESULT_OK) { - activity.setResult(it.resultCode, it.data) - } - if (it.resultCode == Activity.RESULT_CANCELED) { - activity.setResult(Activity.RESULT_CANCELED) - } - activity.finish() - - if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(activity)) { - // Close the database - activity.sendBroadcast(Intent(LOCK_ACTION)) - } - } - } - - /** - * Utility method to start an activity with an Autofill for result - */ - fun startActivityForAutofillResult(activity: AppCompatActivity, - intent: Intent, - activityResultLauncher: ActivityResultLauncher?, - autofillComponent: AutofillComponent, - searchInfo: SearchInfo?) { - EntrySelectionHelper.addSpecialModeInIntent(intent, SpecialMode.SELECTION) - intent.putExtra(EXTRA_ASSIST_STRUCTURE, autofillComponent.assistStructure) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R - && PreferencesUtil.isAutofillInlineSuggestionsEnable(activity)) { - autofillComponent.compatInlineSuggestionsRequest?.let { - intent.putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it) - } - } - EntrySelectionHelper.addSearchInfoInIntent(intent, searchInfo) - activityResultLauncher?.launch(intent) - } - private val TAG = AutofillHelper::class.java.name } diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/CompatInlineSuggestionsRequest.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/CompatInlineSuggestionsRequest.kt similarity index 97% rename from app/src/main/java/com/kunzisoft/keepass/autofill/CompatInlineSuggestionsRequest.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/CompatInlineSuggestionsRequest.kt index 5705682b5..8fba6845b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/CompatInlineSuggestionsRequest.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/CompatInlineSuggestionsRequest.kt @@ -17,7 +17,7 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.annotation.TargetApi import android.os.Build diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/KeeAutofillService.kt similarity index 81% rename from app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/KeeAutofillService.kt index 98979a9d9..6ac09d620 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/KeeAutofillService.kt @@ -17,7 +17,7 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.annotation.SuppressLint import android.app.PendingIntent @@ -43,8 +43,8 @@ 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.autofill.StructureParser.Companion.APPLICATION_ID_POPUP_WINDOW +import com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity +import com.kunzisoft.keepass.credentialprovider.autofill.StructureParser.Companion.APPLICATION_ID_POPUP_WINDOW import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.DatabaseTaskProvider import com.kunzisoft.keepass.database.helper.SearchHelper @@ -53,7 +53,7 @@ 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.WebDomain +import com.kunzisoft.keepass.utils.AppUtil.randomRequestCode import org.joda.time.DateTime @@ -92,10 +92,11 @@ class KeeAutofillService : AutofillService() { autofillInlineSuggestionsEnabled = PreferencesUtil.isAutofillInlineSuggestionsEnable(this) } - override fun onFillRequest(request: FillRequest, - cancellationSignal: CancellationSignal, - callback: FillCallback) { - + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { cancellationSignal.setOnCancelListener { Log.w(TAG, "Cancel autofill.") } if (request.flags and FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST != 0) { @@ -120,64 +121,64 @@ class KeeAutofillService : AutofillService() { webDomain = parseResult.webDomain webScheme = parseResult.webScheme } - WebDomain.getConcreteWebDomain(this, searchInfo.webDomain) { webDomainWithoutSubDomain -> - searchInfo.webDomain = webDomainWithoutSubDomain - val inlineSuggestionsRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R - && autofillInlineSuggestionsEnabled) { - CompatInlineSuggestionsRequest(request) - } else { - null - } - launchSelection(mDatabase, - searchInfo, - parseResult, - inlineSuggestionsRequest, - callback) + val inlineSuggestionsRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + && autofillInlineSuggestionsEnabled) { + CompatInlineSuggestionsRequest(request) + } else { + null } + val autofillComponent = AutofillComponent( + latestStructure, + inlineSuggestionsRequest + ) + SearchHelper.checkAutoSearchInfo( + context = this, + database = mDatabase, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> + callback.onSuccess( + AutofillHelper.buildResponse( + context = this, + database = openedDatabase, + entriesInfo = items, + parseResult = parseResult, + autofillComponent = autofillComponent + ) + ) + }, + onItemNotFound = { openedDatabase -> + // Show UI if no search result + showUIForEntrySelection(parseResult, openedDatabase, + searchInfo, autofillComponent, callback) + }, + onDatabaseClosed = { + // Show UI if database not open + showUIForEntrySelection(parseResult, null, + searchInfo, autofillComponent, callback) + } + ) } } } - private fun launchSelection(database: ContextualDatabase?, - searchInfo: SearchInfo, - parseResult: StructureParser.Result, - inlineSuggestionsRequest: CompatInlineSuggestionsRequest?, - callback: FillCallback) { - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, items -> - callback.onSuccess( - AutofillHelper.buildResponse(this, openedDatabase, - items, parseResult, inlineSuggestionsRequest) - ) - }, - { openedDatabase -> - // Show UI if no search result - showUIForEntrySelection(parseResult, openedDatabase, - searchInfo, inlineSuggestionsRequest, callback) - }, - { - // Show UI if database not open - showUIForEntrySelection(parseResult, null, - searchInfo, inlineSuggestionsRequest, callback) - } - ) - } - @SuppressLint("RestrictedApi") - private fun showUIForEntrySelection(parseResult: StructureParser.Result, - database: ContextualDatabase?, - searchInfo: SearchInfo, - inlineSuggestionsRequest: CompatInlineSuggestionsRequest?, - callback: FillCallback) { + private fun showUIForEntrySelection( + parseResult: StructureParser.Result, + database: ContextualDatabase?, + searchInfo: SearchInfo, + autofillComponent: AutofillComponent, + callback: FillCallback + ) { var success = false parseResult.allAutofillIds().let { autofillIds -> if (autofillIds.isNotEmpty()) { // If the entire Autofill Response is authenticated, AuthActivity is used // to generate Response. - AutofillLauncherActivity.getPendingIntentForSelection(this, - searchInfo, inlineSuggestionsRequest)?.intentSender?.let { intentSender -> + AutofillLauncherActivity.getPendingIntentForSelection( + this, + searchInfo, + autofillComponent + )?.intentSender?.let { intentSender -> val responseBuilder = FillResponse.Builder() val remoteViewsUnlock: RemoteViews = if (database == null) { if (!parseResult.webDomain.isNullOrEmpty()) { @@ -268,7 +269,8 @@ class KeeAutofillService : AutofillService() { && autofillInlineSuggestionsEnabled ) { var inlinePresentation: InlinePresentation? = null - inlineSuggestionsRequest?.inlineSuggestionsRequest?.let { inlineSuggestionsRequest -> + autofillComponent.compatInlineSuggestionsRequest + ?.inlineSuggestionsRequest?.let { inlineSuggestionsRequest -> val inlinePresentationSpecs = inlineSuggestionsRequest.inlinePresentationSpecs if (inlineSuggestionsRequest.maxSuggestionCount > 0 @@ -286,7 +288,7 @@ class KeeAutofillService : AutofillService() { InlineSuggestionUi.newContentBuilder( PendingIntent.getActivity( this, - 0, + randomRequestCode(), Intent(this, AutofillSettingsActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) @@ -358,7 +360,7 @@ class KeeAutofillService : AutofillService() { override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { var success = false - if (askToSaveData) { + if (askToSaveData && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val latestStructure = request.fillContexts.last().structure StructureParser(latestStructure).parse(true)?.let { parseResult -> @@ -384,30 +386,32 @@ class KeeAutofillService : AutofillService() { } // Show UI to save data + val searchInfo = SearchInfo().apply { + applicationId = parseResult.applicationId + webDomain = parseResult.webDomain + webScheme = parseResult.webScheme + } val registerInfo = RegisterInfo( - SearchInfo().apply { - applicationId = parseResult.applicationId - webDomain = parseResult.webDomain - webScheme = parseResult.webScheme - }, - parseResult.usernameValue?.textValue?.toString(), - parseResult.passwordValue?.textValue?.toString(), + searchInfo = searchInfo, + username = parseResult.usernameValue?.textValue?.toString(), + password = parseResult.passwordValue?.textValue?.toString(), + creditCard = parseResult.creditCardNumber?.let { cardNumber -> CreditCard( parseResult.creditCardHolder, - parseResult.creditCardNumber, + cardNumber, expiration, parseResult.cardVerificationValue - )) + ) + } + ) - // TODO Callback in each activity #765 - //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - // callback.onSuccess(AutofillLauncherActivity.getAuthIntentSenderForRegistration(this, - // registerInfo)) - //} else { - AutofillLauncherActivity.launchForRegistration(this, registerInfo) - success = true - callback.onSuccess() - //} + AutofillLauncherActivity.getPendingIntentForRegistration( + this, + registerInfo + )?.intentSender?.let { intentSender -> + success = true + callback.onSuccess(intentSender) + } } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/StructureParser.kt similarity index 99% rename from app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/StructureParser.kt index c4b7ad0c8..425181463 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/StructureParser.kt @@ -16,7 +16,7 @@ * You should have received a copy of the GNU General Public License * along with KeePassDX. If not, see . */ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.app.assist.AssistStructure import android.os.Build @@ -362,8 +362,8 @@ class StructureParser(private val structure: AssistStructure) { if (result?.passwordId == null) { usernameIdCandidate = autofillId usernameValueCandidate = node.autofillValue + Log.d(TAG, "Autofill username candidate android text type: ${showHexInputType(inputType)}") } - Log.d(TAG, "Autofill username candidate android text type: ${showHexInputType(inputType)}") } inputIsVariationType(inputType, InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) -> { diff --git a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/Keyboard.java b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/Keyboard.java similarity index 99% rename from app/src/main/java/com/kunzisoft/keepass/magikeyboard/Keyboard.java rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/Keyboard.java index 313fa465f..b385a897c 100644 --- a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/Keyboard.java +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/Keyboard.java @@ -14,7 +14,7 @@ * the License. */ -package com.kunzisoft.keepass.magikeyboard; +package com.kunzisoft.keepass.credentialprovider.magikeyboard; import android.content.Context; import android.content.res.Resources; diff --git a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/KeyboardView.java b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/KeyboardView.java similarity index 98% rename from app/src/main/java/com/kunzisoft/keepass/magikeyboard/KeyboardView.java rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/KeyboardView.java index 1ee04d2ea..fc992247e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/KeyboardView.java +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/KeyboardView.java @@ -14,14 +14,14 @@ * the License. */ -package com.kunzisoft.keepass.magikeyboard; +package com.kunzisoft.keepass.credentialprovider.magikeyboard; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_BACK_KEYBOARD; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_CHANGE_KEYBOARD; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_ENTRY; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_ENTRY_ALT; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_OTP; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_OTP_ALT; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_BACK_KEYBOARD; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_CHANGE_KEYBOARD; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_ENTRY; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_ENTRY_ALT; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_OTP; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_OTP_ALT; import android.content.Context; import android.content.res.TypedArray; @@ -52,7 +52,7 @@ import android.widget.TextView; import androidx.annotation.RequiresApi; import com.kunzisoft.keepass.R; -import com.kunzisoft.keepass.magikeyboard.Keyboard.Key; +import com.kunzisoft.keepass.credentialprovider.magikeyboard.Keyboard.Key; import java.util.Arrays; import java.util.HashMap; diff --git a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/MagikeyboardService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/MagikeyboardService.kt similarity index 93% rename from app/src/main/java/com/kunzisoft/keepass/magikeyboard/MagikeyboardService.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/MagikeyboardService.kt index 34770e605..875609bd4 100644 --- a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/MagikeyboardService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/MagikeyboardService.kt @@ -18,7 +18,7 @@ * */ -package com.kunzisoft.keepass.magikeyboard +package com.kunzisoft.keepass.credentialprovider.magikeyboard import android.app.Activity import android.content.Context @@ -41,9 +41,10 @@ import androidx.core.graphics.BlendModeCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.EntrySelectionLauncherActivity -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.adapters.FieldsAdapter +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.removeModes +import com.kunzisoft.keepass.credentialprovider.activity.EntrySelectionLauncherActivity import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.DatabaseTaskProvider import com.kunzisoft.keepass.database.element.Field @@ -324,9 +325,9 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL actionGoAutomatically() } KEY_FIELDS -> { - getEntryInfo()?.customFields?.let { customFields -> + getEntryInfo()?.getCustomFieldsForFilling()?.let { customFields -> fieldsAdapter?.apply { - setFields(customFields.filter { it.name != OTP_TOKEN_FIELD}) + setFields(customFields) notifyDataSetChanged() } } @@ -341,10 +342,11 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL } private fun actionKeyEntry(searchInfo: SearchInfo? = null) { - SearchHelper.checkAutoSearchInfo(this, - mDatabase, - searchInfo, - { _, items -> + SearchHelper.checkAutoSearchInfo( + context = this, + database = mDatabase, + searchInfo = searchInfo, + onItemsFound = { _, items -> performSelection( items, { @@ -361,11 +363,11 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL } ) }, - { + onItemNotFound = { // Select if not found launchEntrySelection(searchInfo) }, - { + onDatabaseClosed = { // Select if database not opened removeEntryInfo() launchEntrySelection(searchInfo) @@ -463,21 +465,18 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL fun performSelection(items: List, actionPopulateKeyboard: (entryInfo: EntryInfo) -> Unit, actionEntrySelection: (autoSearch: Boolean) -> Unit) { - if (items.size == 1) { - val itemFound = items[0] - if (entryUUID != itemFound.id) { - actionPopulateKeyboard.invoke(itemFound) - } else { - // Force selection if magikeyboard already populated - actionEntrySelection.invoke(false) - } - } else if (items.size > 1) { - // Select the one we want in the selection - actionEntrySelection.invoke(true) - } else { - // Select an arbitrary one - actionEntrySelection.invoke(false) - } + EntrySelectionHelper.performSelection( + items = items, + actionPopulateCredentialProvider = { itemFound -> + if (entryUUID != itemFound.id) { + actionPopulateKeyboard.invoke(itemFound) + } else { + // Force selection if magikeyboard already populated + actionEntrySelection.invoke(false) + } + }, + actionEntrySelection = actionEntrySelection + ) } fun populateKeyboardAndMoveAppToBackground(activity: Activity, @@ -486,7 +485,7 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL // Populate Magikeyboard with entry addEntryAndLaunchNotificationIfAllowed(activity, entry, toast) // Consume the selection mode - EntrySelectionHelper.removeModesFromIntent(activity.intent) + activity.intent.removeModes() activity.moveTaskToBack(true) } } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt new file mode 100644 index 000000000..37ddd7e28 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -0,0 +1,372 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey + +import android.graphics.BlendMode +import android.graphics.drawable.Icon +import android.os.Build +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.CredentialEntry +import androidx.credentials.provider.CredentialProviderService +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import androidx.credentials.provider.PublicKeyCredentialEntry +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildIcon +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.activity.PasskeyLauncherActivity +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.DatabaseTaskProvider +import com.kunzisoft.keepass.database.exception.RegisterInReadOnlyDatabaseException +import com.kunzisoft.keepass.database.helper.SearchHelper +import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.settings.PreferencesUtil.isPasskeyAutoSelectEnable +import com.kunzisoft.keepass.view.toastError +import java.io.IOException +import java.time.Instant + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeyProviderService : CredentialProviderService() { + + private var mDatabaseTaskProvider: DatabaseTaskProvider? = null + private var mDatabase: ContextualDatabase? = null + private lateinit var defaultIcon: Icon + private var isAutoSelectAllowed: Boolean = false + + override fun onCreate() { + super.onCreate() + + mDatabaseTaskProvider = DatabaseTaskProvider(this) + mDatabaseTaskProvider?.registerProgressTask() + mDatabaseTaskProvider?.onDatabaseRetrieved = { database -> + this.mDatabase = database + } + + defaultIcon = Icon.createWithResource( + this@PasskeyProviderService, + R.mipmap.ic_launcher_round + ).apply { + setTintBlendMode(BlendMode.DST) + } + + isAutoSelectAllowed = isPasskeyAutoSelectEnable(this) + } + + override fun onDestroy() { + mDatabaseTaskProvider?.unregisterProgressTask() + super.onDestroy() + } + + private fun buildPasskeySearchInfo(relyingParty: String): SearchInfo { + return SearchInfo().apply { + this.relyingParty = relyingParty + } + } + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + Log.d(javaClass.simpleName, "onBeginGetCredentialRequest called") + try { + processGetCredentialsRequest(request) { response -> + callback.onResult(response) + } + } catch (e: Exception) { + Log.e(javaClass.simpleName, "onBeginGetCredentialRequest error", e) + callback.onError(GetCredentialUnknownException()) + } + } + + private fun processGetCredentialsRequest( + request: BeginGetCredentialRequest, + callback: (BeginGetCredentialResponse?) -> Unit + ) { + var knownOption = false + for (option in request.beginGetCredentialOptions) { + when (option) { + is BeginGetPublicKeyCredentialOption -> { + knownOption = true + populatePasskeyData(option) { listCredentials -> + callback(BeginGetCredentialResponse(listCredentials)) + } + } + } + } + if (knownOption.not()) { + throw IOException("unknown type of beginGetCredentialOption") + } + } + + private fun populatePasskeyData( + option: BeginGetPublicKeyCredentialOption, + callback: (List) -> Unit + ) { + + val passkeyEntries: MutableList = mutableListOf() + + val relyingPartyId = PublicKeyCredentialRequestOptions(option.requestJson).rpId + val searchInfo = buildPasskeySearchInfo(relyingPartyId) + Log.d(TAG, "Build passkey search for relying party $relyingPartyId") + SearchHelper.checkAutoSearchInfo( + context = this, + database = mDatabase, + searchInfo = searchInfo, + onItemsFound = { database, items -> + Log.d(TAG, "Add pending intent for passkey selection with found items") + for (passkeyEntry in items) { + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.SELECTION, + nodeId = passkeyEntry.id, + appOrigin = passkeyEntry.appOrigin + )?.let { usagePendingIntent -> + val passkey = passkeyEntry.passkey + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = passkey?.username ?: "Unknown", + icon = passkeyEntry.buildIcon(this@PasskeyProviderService, database)?.apply { + setTintBlendMode(BlendMode.DST) + } ?: defaultIcon, + pendingIntent = usagePendingIntent, + beginGetPublicKeyCredentialOption = option, + displayName = passkeyEntry.getVisualTitle(), + isAutoSelectAllowed = isAutoSelectAllowed + ) + ) + } + } + callback(passkeyEntries) + }, + onItemNotFound = { _ -> + Log.w(TAG, "No passkey found in the database with this relying party : $relyingPartyId") + Log.d(TAG, "Add pending intent for passkey selection in opened database") + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.SELECTION, + searchInfo = searchInfo + )?.let { pendingIntent -> + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = getString(R.string.passkey_database_username), + displayName = getString(R.string.passkey_selection_description), + icon = defaultIcon, + pendingIntent = pendingIntent, + beginGetPublicKeyCredentialOption = option, + lastUsedTime = Instant.now(), + isAutoSelectAllowed = isAutoSelectAllowed + ) + ) + } + callback(passkeyEntries) + }, + onDatabaseClosed = { + Log.d(TAG, "Add pending intent for passkey selection in closed database") + // Database is locked, a public key credential entry is shown to unlock it + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.SELECTION, + searchInfo = searchInfo + )?.let { pendingIntent -> + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = getString(R.string.passkey_database_username), + displayName = getString(R.string.passkey_locked_database_description), + icon = defaultIcon, + pendingIntent = pendingIntent, + beginGetPublicKeyCredentialOption = option, + lastUsedTime = Instant.now(), + isAutoSelectAllowed = isAutoSelectAllowed + ) + ) + } + callback(passkeyEntries) + } + ) + } + + override fun onBeginCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + Log.d(javaClass.simpleName, "onBeginCreateCredentialRequest called") + try { + processCreateCredentialRequest(request) { + callback.onResult(BeginCreateCredentialResponse(it)) + } + } catch (e: Exception) { + Log.e(javaClass.simpleName, "onBeginCreateCredentialRequest error", e) + toastError(e) + callback.onError(CreateCredentialUnknownException(e.localizedMessage)) + } + } + + private fun processCreateCredentialRequest( + request: BeginCreateCredentialRequest, + callback: (List) -> Unit + ) { + when (request) { + is BeginCreatePublicKeyCredentialRequest -> { + // Request is passkey type + handleCreatePasskeyQuery(request, callback) + } + else -> { + // request type not supported + throw IOException("unknown type of BeginCreateCredentialRequest") + } + } + } + + private fun MutableList.addPendingIntentCreationNewEntry( + accountName: String, + searchInfo: SearchInfo? + ) { + Log.d(TAG, "Add pending intent for registration in opened database to create new item") + // TODO add a setting to directly store in a specific group + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.REGISTRATION, + searchInfo = searchInfo + )?.let { pendingIntent -> + this.add( + CreateEntry( + accountName = accountName, + icon = defaultIcon, + pendingIntent = pendingIntent, + description = getString(R.string.passkey_creation_description) + ) + ) + } + } + + private fun handleCreatePasskeyQuery( + request: BeginCreatePublicKeyCredentialRequest, + callback: (List) -> Unit + ) { + val databaseName = mDatabase?.name + val accountName = + if (databaseName?.isBlank() != false) + getString(R.string.passkey_database_username) + else databaseName + val createEntries: MutableList = mutableListOf() + val relyingPartyId = PublicKeyCredentialCreationOptions( + requestJson = request.requestJson, + clientDataHash = request.clientDataHash + ).relyingPartyEntity.id + val searchInfo = buildPasskeySearchInfo(relyingPartyId) + Log.d(TAG, "Build passkey search for relying party $relyingPartyId") + SearchHelper.checkAutoSearchInfo( + context = this, + database = mDatabase, + searchInfo = searchInfo, + onItemsFound = { database, items -> + if (database.isReadOnly) { + throw RegisterInReadOnlyDatabaseException() + } else { + // To create a new entry + createEntries.addPendingIntentCreationNewEntry(accountName, searchInfo) + /* TODO Overwrite + // To select an existing entry and permit an overwrite + Log.w(TAG, "Passkey already registered") + for (entryInfo in items) { + PasskeyHelper.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.REGISTRATION, + searchInfo = searchInfo, + passkeyEntryNodeId = entryInfo.id + )?.let { createPendingIntent -> + createEntries.add( + CreateEntry( + accountName = accountName, + pendingIntent = createPendingIntent, + description = getString( + R.string.passkey_update_description, + entryInfo.passkey?.displayName + ) + ) + ) + } + }*/ + } + callback(createEntries) + }, + onItemNotFound = { database -> + // To create a new entry + if (database.isReadOnly) { + throw RegisterInReadOnlyDatabaseException() + } else { + createEntries.addPendingIntentCreationNewEntry(accountName, searchInfo) + } + callback(createEntries) + }, + onDatabaseClosed = { + // Launch the passkey launcher activity to open the database + Log.d(TAG, "Add pending intent for passkey registration in closed database") + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.REGISTRATION + )?.let { pendingIntent -> + createEntries.add( + CreateEntry( + accountName = accountName, + icon = defaultIcon, + pendingIntent = pendingIntent, + description = getString(R.string.passkey_locked_database_description) + ) + ) + } + callback(createEntries) + } + ) + } + + override fun onClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + // nothing to do + } + + companion object { + private val TAG = PasskeyProviderService::class.java.simpleName + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AndroidPrivilegedApp.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AndroidPrivilegedApp.kt new file mode 100644 index 000000000..848b2e3f6 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AndroidPrivilegedApp.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2025 AOSP modified by 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import android.os.Build +import android.os.Parcelable +import android.util.Log +import kotlinx.parcelize.Parcelize +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * Represents an Android privileged app, based on AOSP code + */ +@Parcelize +data class AndroidPrivilegedApp( + val packageName: String, + val fingerprints: Set +): Parcelable { + + override fun toString(): String { + return "$packageName ($fingerprints)" + } + + companion object { + private const val PACKAGE_NAME_KEY = "package_name" + private const val SIGNATURES_KEY = "signatures" + private const val FINGERPRINT_KEY = "cert_fingerprint_sha256" + private const val BUILD_KEY = "build" + private const val USER_DEBUG_KEY = "userdebug" + private const val TYPE_KEY = "type" + private const val APP_INFO_KEY = "info" + private const val ANDROID_TYPE_KEY = "android" + private const val USER_BUILD_TYPE = "userdebug" + private const val APPS_KEY = "apps" + + /** + * Extracts a list of AndroidPrivilegedApp objects from a JSONObject. + */ + @JvmStatic + fun extractPrivilegedApps(jsonObject: JSONObject): List { + val apps = mutableListOf() + if (!jsonObject.has(APPS_KEY)) { + return apps + } + val appsJsonArray = jsonObject.getJSONArray(APPS_KEY) + for (i in 0 until appsJsonArray.length()) { + try { + val appJsonObject = appsJsonArray.getJSONObject(i) + if (appJsonObject.getString(TYPE_KEY) != ANDROID_TYPE_KEY) { + continue + } + if (!appJsonObject.has(APP_INFO_KEY)) { + continue + } + apps.add( + createFromJSONObject( + appJsonObject.getJSONObject(APP_INFO_KEY) + ) + ) + } catch (e: JSONException) { + Log.e(AndroidPrivilegedApp::class.simpleName, "Error parsing privileged app", e) + } + } + return apps + } + + /** + * Creates an AndroidPrivilegedApp object from a JSONObject. + */ + @JvmStatic + private fun createFromJSONObject( + appInfoJsonObject: JSONObject, + filterUserDebug: Boolean = true + ): AndroidPrivilegedApp { + val signaturesJson = appInfoJsonObject.getJSONArray(SIGNATURES_KEY) + val fingerprints = mutableSetOf() + for (j in 0 until signaturesJson.length()) { + if (filterUserDebug) { + if (USER_DEBUG_KEY == signaturesJson.getJSONObject(j) + .optString(BUILD_KEY) && USER_BUILD_TYPE != Build.TYPE + ) { + continue + } + } + fingerprints.add(signaturesJson.getJSONObject(j).getString(FINGERPRINT_KEY)) + } + return AndroidPrivilegedApp( + packageName = appInfoJsonObject.getString(PACKAGE_NAME_KEY), + fingerprints = fingerprints + ) + } + + /** + * Creates a JSONObject from a list of AndroidPrivilegedApp objects. + * The structure will be similar to what `extractPrivilegedApps` expects. + * + * @param privilegedApps The list of AndroidPrivilegedApp objects. + * @return A JSONObject representing the list. + */ + @JvmStatic + fun toJsonObject(privilegedApps: List): JSONObject { + val rootJsonObject = JSONObject() + val appsJsonArray = JSONArray() + + for (app in privilegedApps) { + val appInfoObject = JSONObject() + appInfoObject.put(PACKAGE_NAME_KEY, app.packageName) + + val signaturesArray = JSONArray() + for (fingerprint in app.fingerprints) { + val signatureObject = JSONObject() + signatureObject.put(FINGERPRINT_KEY, fingerprint) + // If needed: signatureObject.put(BUILD_KEY, "user") + signaturesArray.put(signatureObject) + } + appInfoObject.put(SIGNATURES_KEY, signaturesArray) + + val appContainerObject = JSONObject() + appContainerObject.put(TYPE_KEY, ANDROID_TYPE_KEY) + appContainerObject.put(APP_INFO_KEY, appInfoObject) + + appsJsonArray.put(appContainerObject) + } + + rootJsonObject.put(APPS_KEY, appsJsonArray) + return rootJsonObject + } + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAssertionResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAssertionResponse.kt new file mode 100644 index 000000000..88415d9b3 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAssertionResponse.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import android.util.Log +import androidx.credentials.exceptions.GetCredentialUnknownException +import com.kunzisoft.encrypt.Signature +import com.kunzisoft.encrypt.Base64Helper.Companion.b64Encode +import org.json.JSONObject + +class AuthenticatorAssertionResponse( + private val requestOptions: PublicKeyCredentialRequestOptions, + private val userPresent: Boolean, + private val userVerified: Boolean, + private val backupEligibility: Boolean, + private val backupState: Boolean, + private var userHandle: String, + privateKey: String, + private val clientDataResponse: ClientDataResponse, +) : AuthenticatorResponse { + + override var clientJson = JSONObject() + private var authenticatorData: ByteArray = AuthenticatorData.buildAuthenticatorData( + relyingPartyId = requestOptions.rpId.toByteArray(), + userPresent = userPresent, + userVerified = userVerified, + backupEligibility = backupEligibility, + backupState = backupState + ) + private var signature: ByteArray = byteArrayOf() + + init { + try { + signature = Signature.sign(privateKey, dataToSign()) + } catch (e: Exception) { + Log.e(this::class.java.simpleName, "Unable to sign: ${e.message}") + throw GetCredentialUnknownException("Signing failed") + } + } + + private fun dataToSign(): ByteArray { + return authenticatorData + clientDataResponse.hashData() + } + + override fun json(): JSONObject { + // https://www.w3.org/TR/webauthn-3/#authdata-flags + return clientJson.apply { + put("clientDataJSON", clientDataResponse.buildResponse()) + put("authenticatorData", b64Encode(authenticatorData)) + put("signature", b64Encode(signature)) + put("userHandle", userHandle) + } + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAttestationResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAttestationResponse.kt new file mode 100644 index 000000000..07ac0ba45 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAttestationResponse.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import com.kunzisoft.encrypt.Base64Helper.Companion.b64Encode +import com.kunzisoft.keepass.utils.UUIDUtils.asBytes +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID + +class AuthenticatorAttestationResponse( + private val requestOptions: PublicKeyCredentialCreationOptions, + private val credentialId: ByteArray, + private val credentialPublicKey: ByteArray, + private val userPresent: Boolean, + private val userVerified: Boolean, + private val backupEligibility: Boolean, + private val backupState: Boolean, + private val publicKeyTypeId: Long, + private val publicKeyCbor: ByteArray, + private val clientDataResponse: ClientDataResponse, +) : AuthenticatorResponse { + + override var clientJson = JSONObject() + var attestationObject: ByteArray + + init { + attestationObject = defaultAttestationObject() + } + + private fun buildAuthData(): ByteArray { + return AuthenticatorData.buildAuthenticatorData( + relyingPartyId = requestOptions.relyingPartyEntity.id.toByteArray(), + userPresent = userPresent, + userVerified = userVerified, + backupEligibility = backupEligibility, + backupState = backupState, + attestedCredentialData = true + ) + AAGUID + + //credIdLen + byteArrayOf((credentialId.size shr 8).toByte(), credentialId.size.toByte()) + + credentialId + + credentialPublicKey + } + + internal fun defaultAttestationObject(): ByteArray { + // https://www.w3.org/TR/webauthn-3/#attestation-object + val ao = mutableMapOf() + ao.put("fmt", "none") + ao.put("attStmt", emptyMap()) + ao.put("authData", buildAuthData()) + return Cbor().encode(ao) + } + + override fun json(): JSONObject { + // See AuthenticatorAttestationResponseJSON at + // https://w3c.github.io/webauthn/#ref-for-dom-publickeycredential-tojson + return clientJson.apply { + put("clientDataJSON", clientDataResponse.buildResponse()) + put("authenticatorData", b64Encode(buildAuthData())) + put("transports", JSONArray(listOf("internal", "hybrid"))) + put("publicKey", b64Encode(publicKeyCbor)) + put("publicKeyAlgorithm", publicKeyTypeId) + put("attestationObject", b64Encode(attestationObject)) + } + } + + companion object { + // Authenticator Attestation Global Unique Identifier + private val AAGUID: ByteArray = UUID.fromString("eaecdef2-1c31-5634-8639-f1cbd9c00a08").asBytes() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorData.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorData.kt new file mode 100644 index 000000000..6df2b10be --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorData.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import com.kunzisoft.encrypt.HashManager + +class AuthenticatorData { + + companion object { + fun buildAuthenticatorData( + relyingPartyId: ByteArray, + userPresent: Boolean, + userVerified: Boolean, + backupEligibility: Boolean, + backupState: Boolean, + attestedCredentialData: Boolean = false + ): ByteArray { + // https://www.w3.org/TR/webauthn-3/#table-authData + var flags = 0 + if (userPresent) + flags = flags or 0x01 + // bit at index 1 is reserved + if (userVerified) + flags = flags or 0x04 + if (backupEligibility) + flags = flags or 0x08 + if (backupState) + flags = flags or 0x10 + // bit at index 5 is reserved + if (attestedCredentialData) { + flags = flags or 0x40 + } + // bit at index 7: Extension data included == false + + return HashManager.hashSha256(relyingPartyId) + + byteArrayOf(flags.toByte()) + + byteArrayOf(0, 0, 0, 0) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorResponse.kt new file mode 100644 index 000000000..57d9ab2dc --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorResponse.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import org.json.JSONObject + +interface AuthenticatorResponse { + var clientJson: JSONObject + + fun json(): JSONObject +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/Cbor.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/Cbor.kt new file mode 100644 index 000000000..8dbbb8657 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/Cbor.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import androidx.annotation.RestrictTo + +@RestrictTo(RestrictTo.Scope.LIBRARY) +class Cbor { + data class Item(val item: Any, val len: Int) + + data class Arg(val arg: Long, val len: Int) + + val TYPE_UNSIGNED_INT = 0x00 + val TYPE_NEGATIVE_INT = 0x01 + val TYPE_BYTE_STRING = 0x02 + val TYPE_TEXT_STRING = 0x03 + val TYPE_ARRAY = 0x04 + val TYPE_MAP = 0x05 + val TYPE_TAG = 0x06 + val TYPE_FLOAT = 0x07 + + fun decode(data: ByteArray): Any { + val ret = parseItem(data, 0) + return ret.item + } + + fun encode(data: Any): ByteArray { + if (data is Number) { + if (data is Double) { + throw IllegalArgumentException("Don't support doubles yet") + } else { + val value = data.toLong() + if (value >= 0) { + return createArg(TYPE_UNSIGNED_INT, value) + } else { + return createArg(TYPE_NEGATIVE_INT, -1 - value) + } + } + } + if (data is ByteArray) { + return createArg(TYPE_BYTE_STRING, data.size.toLong()) + data + } + if (data is String) { + return createArg(TYPE_TEXT_STRING, data.length.toLong()) + data.encodeToByteArray() + } + if (data is List<*>) { + var ret = createArg(TYPE_ARRAY, data.size.toLong()) + for (i in data) { + ret += encode(i!!) + } + return ret + } + if (data is Map<*, *>) { + // See: + // https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#ctap2-canonical-cbor-encoding-form + var ret = createArg(TYPE_MAP, data.size.toLong()) + var byteMap: MutableMap = mutableMapOf() + for (i in data) { + // Convert to byte arrays so we can sort them. + byteMap.put(encode(i.key!!), encode(i.value!!)) + } + + var keysList = ArrayList(byteMap.keys) + keysList.sortedWith( + Comparator { a, b -> + // If two keys have different lengths, the shorter one sorts earlier; + // If two keys have the same length, the one with the lower value in (byte-wise) + // lexical order sorts earlier. + var aBytes = byteMap.get(a)!! + var bBytes = byteMap.get(b)!! + when { + a.size > b.size -> 1 + a.size < b.size -> -1 + aBytes.size > bBytes.size -> 1 + aBytes.size < bBytes.size -> -1 + else -> 0 + } + } + ) + + for (key in keysList) { + ret += key + ret += byteMap.get(key)!! + } + return ret + } + throw IllegalArgumentException("Bad type") + } + + private fun getType(data: ByteArray, offset: Int): Int { + val d = data[offset].toInt() + return (d and 0xFF) shr 5 + } + + private fun getArg(data: ByteArray, offset: Int): Arg { + val arg = data[offset].toLong() and 0x1F + if (arg < 24) { + return Arg(arg, 1) + } + if (arg == 24L) { + return Arg(data[offset + 1].toLong() and 0xFF, 2) + } + if (arg == 25L) { + var ret = (data[offset + 1].toLong() and 0xFF) shl 8 + ret = ret or (data[offset + 2].toLong() and 0xFF) + return Arg(ret, 3) + } + if (arg == 26L) { + var ret = (data[offset + 1].toLong() and 0xFF) shl 24 + ret = ret or ((data[offset + 2].toLong() and 0xFF) shl 16) + ret = ret or ((data[offset + 3].toLong() and 0xFF) shl 8) + ret = ret or (data[offset + 4].toLong() and 0xFF) + return Arg(ret, 5) + } + throw IllegalArgumentException("Bad arg") + } + + private fun parseItem(data: ByteArray, offset: Int): Item { + val itemType = getType(data, offset) + val arg = getArg(data, offset) + println("Type $itemType ${arg.arg} ${arg.len}") + + when (itemType) { + TYPE_UNSIGNED_INT -> { + return Item(arg.arg, arg.len) + } + TYPE_NEGATIVE_INT -> { + return Item(-1 - arg.arg, arg.len) + } + TYPE_BYTE_STRING -> { + val ret = + data.sliceArray(offset + arg.len.toInt() until offset + arg.len.toInt() + arg.arg.toInt()) + return Item(ret, arg.len + arg.arg.toInt()) + } + TYPE_TEXT_STRING -> { + val ret = + data.sliceArray(offset + arg.len.toInt() until offset + arg.len.toInt() + arg.arg.toInt()) + return Item(ret.toString(Charsets.UTF_8), arg.len + arg.arg.toInt()) + } + TYPE_ARRAY -> { + val ret = mutableListOf() + var consumed = arg.len + for (i in 0 until arg.arg.toInt()) { + val item = parseItem(data, offset + consumed) + ret.add(item.item) + consumed += item.len + } + return Item(ret.toList(), consumed) + } + TYPE_MAP -> { + val ret = mutableMapOf() + var consumed = arg.len + for (i in 0 until arg.arg.toInt()) { + val key = parseItem(data, offset + consumed) + consumed += key.len + val value = parseItem(data, offset + consumed) + consumed += value.len + ret[key.item] = value.item + } + return Item(ret.toMap(), consumed) + } + else -> { + throw IllegalArgumentException("Bad type") + } + } + } + + private fun createArg(type: Int, arg: Long): ByteArray { + val t = type shl 5 + val a = arg.toInt() + if (arg < 24) { + return byteArrayOf(((t or a) and 0xFF).toByte()) + } + if (arg <= 0xFF) { + return byteArrayOf(((t or 24) and 0xFF).toByte(), (a and 0xFF).toByte()) + } + if (arg <= 0xFFFF) { + return byteArrayOf( + ((t or 25) and 0xFF).toByte(), + ((a shr 8) and 0xFF).toByte(), + (a and 0xFF).toByte() + ) + } + if (arg <= 0xFFFFFFFF) { + return byteArrayOf( + ((t or 26) and 0xFF).toByte(), + ((a shr 24) and 0xFF).toByte(), + ((a shr 16) and 0xFF).toByte(), + ((a shr 8) and 0xFF).toByte(), + (a and 0xFF).toByte() + ) + } + throw IllegalArgumentException("bad Arg") + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataBuildResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataBuildResponse.kt new file mode 100644 index 000000000..0e9454d19 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataBuildResponse.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import com.kunzisoft.encrypt.HashManager +import com.kunzisoft.encrypt.Base64Helper.Companion.b64Encode +import org.json.JSONObject + +open class ClientDataBuildResponse( + type: Type, + challenge: ByteArray, + origin: String, + crossOrigin: Boolean? = false, + topOrigin: String? = null, +): AuthenticatorResponse, ClientDataResponse { + override var clientJson = JSONObject() + + init { + // https://w3c.github.io/webauthn/#client-data + clientJson.put("type", type.value) + clientJson.put("challenge", b64Encode(challenge)) + clientJson.put("origin", origin) + crossOrigin?.let { + clientJson.put("crossOrigin", it) + } + topOrigin?.let { + clientJson.put("topOrigin", it) + } + } + + override fun json(): JSONObject { + return clientJson + } + + enum class Type(val value: String) { + GET("webauthn.get"), CREATE("webauthn.create") + } + + override fun buildResponse(): String { + return b64Encode(json().toString().toByteArray()) + } + + override fun hashData(): ByteArray { + return HashManager.hashSha256(json().toString().toByteArray()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataDefinedResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataDefinedResponse.kt new file mode 100644 index 000000000..ae323b904 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataDefinedResponse.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +open class ClientDataDefinedResponse( + private val clientDataHash: ByteArray +): ClientDataResponse { + + override fun hashData(): ByteArray { + return clientDataHash + } + + override fun buildResponse(): String { + return CLIENT_DATA_JSON_PRIVILEGED + } + + companion object { + private const val CLIENT_DATA_JSON_PRIVILEGED = "" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataResponse.kt new file mode 100644 index 000000000..bdc5b3ec2 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataResponse.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +interface ClientDataResponse { + fun hashData(): ByteArray + fun buildResponse(): String +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoDataTypes.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoDataTypes.kt new file mode 100644 index 000000000..62a70a485 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoDataTypes.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kunzisoft.keepass.credentialprovider.passkey.data + +data class PublicKeyCredentialRpEntity(val name: String, val id: String) + +data class PublicKeyCredentialUserEntity( + val name: String, + val id: ByteArray, + val displayName: String +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PublicKeyCredentialUserEntity + + if (name != other.name) return false + if (!id.contentEquals(other.id)) return false + if (displayName != other.displayName) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + id.contentHashCode() + result = 31 * result + displayName.hashCode() + return result + } +} + +data class PublicKeyCredentialParameters(val type: String, val alg: Long) + +data class PublicKeyCredentialDescriptor( + val type: String, + val id: ByteArray, + val transports: List +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PublicKeyCredentialDescriptor + + if (type != other.type) return false + if (!id.contentEquals(other.id)) return false + if (transports != other.transports) return false + + return true + } + + override fun hashCode(): Int { + var result = type.hashCode() + result = 31 * result + id.contentHashCode() + result = 31 * result + transports.hashCode() + return result + } +} + +data class AuthenticatorSelectionCriteria( + val authenticatorAttachment: String, + val residentKey: String, + val requireResidentKey: Boolean = false, + val userVerification: String = "preferred" +) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoPublicKeyCredential.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoPublicKeyCredential.kt new file mode 100644 index 000000000..9fa66a8a8 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoPublicKeyCredential.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import org.json.JSONObject + +class FidoPublicKeyCredential( + val id: String, + val response: AuthenticatorResponse, + val authenticatorAttachment: String +) { + + fun json(): String { + // see at https://www.w3.org/TR/webauthn-3/#sctn-authenticator-credential-properties-extension + val discoverableCredential = true + val rk = JSONObject() + rk.put("rk", discoverableCredential) + val credProps = JSONObject() + credProps.put("credProps", rk) + + // See RegistrationResponseJSON at + // https://w3c.github.io/webauthn/#ref-for-dom-publickeycredential-tojson + val ret = JSONObject() + ret.put("id", id) + ret.put("rawId", id) + ret.put("type", "public-key") + ret.put("authenticatorAttachment", authenticatorAttachment) + ret.put("response", response.json()) + ret.put("clientExtensionResults", JSONObject()) // TODO credProps + + return ret.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt new file mode 100644 index 000000000..b64634f63 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import com.kunzisoft.encrypt.Base64Helper +import org.json.JSONObject + +class PublicKeyCredentialCreationOptions( + requestJson: String, + var clientDataHash: ByteArray? +) { + val json: JSONObject = JSONObject(requestJson) + + val relyingPartyEntity: PublicKeyCredentialRpEntity + val userEntity: PublicKeyCredentialUserEntity + val challenge: ByteArray + val pubKeyCredParams: List + + var timeout: Long + var excludeCredentials: List + var authenticatorSelection: AuthenticatorSelectionCriteria + var attestation: String + + init { + val rpJson = json.getJSONObject("rp") + relyingPartyEntity = PublicKeyCredentialRpEntity(rpJson.getString("name"), rpJson.getString("id")) + val rpUser = json.getJSONObject("user") + val userId = Base64Helper.b64Decode(rpUser.getString("id")) + userEntity = + PublicKeyCredentialUserEntity( + rpUser.getString("name"), + userId, + rpUser.getString("displayName") + ) + challenge = Base64Helper.b64Decode(json.getString("challenge")) + val pubKeyCredParamsJson = json.getJSONArray("pubKeyCredParams") + val pubKeyCredParamsTmp: MutableList = mutableListOf() + for (i in 0 until pubKeyCredParamsJson.length()) { + val e = pubKeyCredParamsJson.getJSONObject(i) + pubKeyCredParamsTmp.add( + PublicKeyCredentialParameters(e.getString("type"), e.getLong("alg")) + ) + } + pubKeyCredParams = pubKeyCredParamsTmp.toList() + + timeout = json.optLong("timeout", 0) + // TODO: Fix excludeCredentials and authenticatorSelection + excludeCredentials = emptyList() + authenticatorSelection = AuthenticatorSelectionCriteria("platform", "required") + attestation = json.optString("attestation", "none") + } + + companion object { + private val TAG = PublicKeyCredentialCreationOptions::class.simpleName + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt new file mode 100644 index 000000000..542e15c0c --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import java.security.KeyPair + +data class PublicKeyCredentialCreationParameters( + val publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions, + val credentialId: ByteArray, + val signatureKey: Pair, + val clientDataResponse: ClientDataResponse +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PublicKeyCredentialCreationParameters + + if (publicKeyCredentialCreationOptions != other.publicKeyCredentialCreationOptions) return false + if (!credentialId.contentEquals(other.credentialId)) return false + if (signatureKey != other.signatureKey) return false + if (clientDataResponse != other.clientDataResponse) return false + + return true + } + + override fun hashCode(): Int { + var result = publicKeyCredentialCreationOptions.hashCode() + result = 31 * result + credentialId.contentHashCode() + result = 31 * result + signatureKey.hashCode() + result = 31 * result + clientDataResponse.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt new file mode 100644 index 000000000..42b0d75de --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import com.kunzisoft.encrypt.Base64Helper +import org.json.JSONObject + +class PublicKeyCredentialRequestOptions(requestJson: String) { + val json: JSONObject = JSONObject(requestJson) + val challenge: ByteArray = Base64Helper.b64Decode(json.getString("challenge")) + val timeout: Long = json.optLong("timeout", 0) + val rpId: String = json.optString("rpId", "") + val userVerification: String = json.optString("userVerification", "preferred") +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt new file mode 100644 index 000000000..5d914bca8 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import com.kunzisoft.keepass.model.AppOrigin + +data class PublicKeyCredentialUsageParameters( + val publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions, + val clientDataResponse: ClientDataResponse, + var appOrigin: AppOrigin +) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt new file mode 100644 index 000000000..7af8e349b --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt @@ -0,0 +1,604 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.util + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.PendingIntentHandler +import androidx.credentials.provider.ProviderCreateCredentialRequest +import androidx.credentials.provider.ProviderGetCredentialRequest +import com.kunzisoft.encrypt.Base64Helper.Companion.b64Encode +import com.kunzisoft.encrypt.Signature +import com.kunzisoft.encrypt.Signature.getApplicationFingerprints +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addNodeId +import com.kunzisoft.keepass.credentialprovider.passkey.data.AuthenticatorAssertionResponse +import com.kunzisoft.keepass.credentialprovider.passkey.data.AuthenticatorAttestationResponse +import com.kunzisoft.keepass.credentialprovider.passkey.data.Cbor +import com.kunzisoft.keepass.credentialprovider.passkey.data.ClientDataBuildResponse +import com.kunzisoft.keepass.credentialprovider.passkey.data.ClientDataDefinedResponse +import com.kunzisoft.keepass.credentialprovider.passkey.data.ClientDataResponse +import com.kunzisoft.keepass.credentialprovider.passkey.data.FidoPublicKeyCredential +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.getOriginFromPrivilegedAllowLists +import com.kunzisoft.keepass.model.AndroidOrigin +import com.kunzisoft.keepass.model.AppOrigin +import com.kunzisoft.keepass.model.EntryInfo +import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.utils.AppUtil +import com.kunzisoft.keepass.utils.StringUtil.toHexString +import com.kunzisoft.keepass.utils.getParcelableExtraCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.security.KeyStore +import java.security.MessageDigest +import java.security.SecureRandom +import java.time.Instant +import java.util.UUID +import javax.crypto.KeyGenerator +import javax.crypto.Mac +import javax.crypto.SecretKey + +/** + * Utility class to manage the passkey elements, + * allows to add and retrieve intent values with preconfigured keys, + * and makes it easy to create creation and usage requests + */ +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +object PasskeyHelper { + + private const val EXTRA_PASSKEY = "com.kunzisoft.keepass.passkey.extra.passkey" + + private const val HMAC_TYPE = "HmacSHA256" + + private const val EXTRA_APP_ORIGIN = "com.kunzisoft.keepass.extra.appOrigin" + private const val EXTRA_TIMESTAMP = "com.kunzisoft.keepass.extra.timestamp" + private const val EXTRA_AUTHENTICATION_CODE = "com.kunzisoft.keepass.extra.authenticationCode" + + private const val SEPARATOR = "_" + + private const val NAME_OF_HMAC_KEY = "KeePassDXCredentialProviderHMACKey" + + private const val KEYSTORE_TYPE = "AndroidKeyStore" + + private val PLACEHOLDER_FOR_NEW_NODE_ID = "0".repeat(32) + + private val REGEX_TIMESTAMP = "[0-9]{10}".toRegex() + private val REGEX_AUTHENTICATION_CODE = "[A-F0-9]{64}".toRegex() // 256 bits = 64 hex chars + + private const val MAX_DIFF_IN_SECONDS = 60 + + private val internalSecureRandom: SecureRandom = SecureRandom() + + /** + * Add an authentication code generated by an entry to the intent + */ + fun Intent.addAuthCode(passkeyEntryNodeId: UUID? = null) { + putExtras(Bundle().apply { + val timestamp = Instant.now().epochSecond + putString(EXTRA_TIMESTAMP, timestamp.toString()) + putString( + EXTRA_AUTHENTICATION_CODE, + generatedAuthenticationCode( + passkeyEntryNodeId, timestamp + ).toHexString() + ) + }) + } + + /** + * Add the passkey to the intent + */ + fun Intent.addPasskey(passkey: Passkey?) { + passkey?.let { + putExtra(EXTRA_PASSKEY, passkey) + } + } + + /** + * Retrieve the passkey from the intent + */ + fun Intent.retrievePasskey(): Passkey? { + return this.getParcelableExtraCompat(EXTRA_PASSKEY) + } + + /** + * Remove the passkey from the intent + */ + fun Intent.removePasskey() { + return this.removeExtra(EXTRA_PASSKEY) + } + + /** + * Add the app origin to the intent + */ + fun Intent.addAppOrigin(appOrigin: AppOrigin?) { + appOrigin?.let { + putExtra(EXTRA_APP_ORIGIN, appOrigin) + } + } + + /** + * Retrieve the app origin from the intent + */ + fun Intent.retrieveAppOrigin(): AppOrigin? { + return this.getParcelableExtraCompat(EXTRA_APP_ORIGIN) + } + + /** + * Remove the app origin from the intent + */ + fun Intent.removeAppOrigin() { + return this.removeExtra(EXTRA_APP_ORIGIN) + } + + /** + * Build the Passkey response for one entry + */ + fun Activity.buildPasskeyResponseAndSetResult( + entryInfo: EntryInfo, + extras: Bundle? = null + ) { + try { + entryInfo.passkey?.let { passkey -> + val mReplyIntent = Intent() + Log.d(javaClass.name, "Success Passkey manual selection") + mReplyIntent.addPasskey(passkey) + mReplyIntent.addAppOrigin(entryInfo.appOrigin) + mReplyIntent.addNodeId(entryInfo.id) + extras?.let { + mReplyIntent.putExtras(it) + } + setResult(Activity.RESULT_OK, mReplyIntent) + } ?: run { + throw IOException("No passkey found") + } + } catch (e: Exception) { + Log.e(javaClass.name, "Unable to add the passkey as result", e) + Toast.makeText( + this, + getString(R.string.error_passkey_result), + Toast.LENGTH_SHORT + ).show() + setResult(Activity.RESULT_CANCELED) + } + } + + /** + * Check the timestamp and authentication code transmitted via PendingIntent + */ + fun checkSecurity(intent: Intent, nodeId: UUID?) { + val timestampString = intent.getStringExtra(EXTRA_TIMESTAMP) + if (timestampString.isNullOrEmpty()) + throw CreateCredentialUnknownException("Timestamp null") + if (timestampString.matches(REGEX_TIMESTAMP).not()) { + throw CreateCredentialUnknownException("Timestamp not valid") + } + val timestamp = timestampString.toLong() + val diff = Instant.now().epochSecond - timestamp + if (diff < 0 || diff > MAX_DIFF_IN_SECONDS) { + throw CreateCredentialUnknownException("Out of time") + } + verifyAuthenticationCode( + intent.getStringExtra(EXTRA_AUTHENTICATION_CODE), + generatedAuthenticationCode(nodeId, timestamp) + ) + } + + /** + * Verify the authentication code from the encrypted message received from the intent + */ + private fun verifyAuthenticationCode( + valueToCheck: String?, + authenticationCode: ByteArray + ) { + if (valueToCheck.isNullOrEmpty()) + throw CreateCredentialUnknownException("Authentication code empty") + if (valueToCheck.matches(REGEX_AUTHENTICATION_CODE).not()) + throw CreateCredentialUnknownException("Authentication not valid") + if (MessageDigest.isEqual(authenticationCode, generateAuthenticationCode(valueToCheck))) + throw CreateCredentialUnknownException("Authentication code incorrect") + } + + /** + * Generate the authentication code base on the entry [nodeId] and [timestamp] + */ + private fun generatedAuthenticationCode(nodeId: UUID?, timestamp: Long): ByteArray { + return generateAuthenticationCode( + (nodeId?.toString() ?: PLACEHOLDER_FOR_NEW_NODE_ID) + SEPARATOR + timestamp.toString() + ) + } + + /** + * Generate the authentication code base on the entry [message] + */ + private fun generateAuthenticationCode(message: String): ByteArray { + val keyStore = KeyStore.getInstance(KEYSTORE_TYPE) + keyStore.load(null) + val hmacKey = try { + keyStore.getKey(NAME_OF_HMAC_KEY, null) as SecretKey + } catch (_: Exception) { + // key not found + generateKey() + } + + val mac = Mac.getInstance(HMAC_TYPE) + mac.init(hmacKey) + val authenticationCode = mac.doFinal(message.toByteArray()) + return authenticationCode + } + + /** + * Generate the HMAC key if cannot be found in the KeyStore + */ + private fun generateKey(): SecretKey? { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE_TYPE + ) + val keySizeInBits = 128 + keyGenerator.init( + KeyGenParameterSpec.Builder(NAME_OF_HMAC_KEY, KeyProperties.PURPOSE_SIGN) + .setKeySize(keySizeInBits) + .build() + ) + val key = keyGenerator.generateKey() + return key + } + + /** + * Retrieve the [PublicKeyCredentialCreationOptions] from the intent + */ + fun ProviderCreateCredentialRequest.retrievePasskeyCreationComponent(): PublicKeyCredentialCreationOptions { + val request = this + if (request.callingRequest !is CreatePublicKeyCredentialRequest) { + throw CreateCredentialUnknownException("callingRequest is of wrong type: ${request.callingRequest.type}") + } + val createPublicKeyCredentialRequest = request.callingRequest as CreatePublicKeyCredentialRequest + return PublicKeyCredentialCreationOptions( + requestJson = createPublicKeyCredentialRequest.requestJson, + clientDataHash = createPublicKeyCredentialRequest.clientDataHash + ) + } + + /** + * Retrieve the [GetPublicKeyCredentialOption] from the intent + */ + fun ProviderGetCredentialRequest.retrievePasskeyUsageComponent(): GetPublicKeyCredentialOption { + val request = this + if (request.credentialOptions.size != 1) { + throw GetCredentialUnknownException("not exact one credentialOption") + } + if (request.credentialOptions[0] !is GetPublicKeyCredentialOption) { + throw CreateCredentialUnknownException("credentialOptions is of wrong type: ${request.credentialOptions[0]}") + } + return request.credentialOptions[0] as GetPublicKeyCredentialOption + } + + /** + * Utility method to retrieve the origin asynchronously, + * checks for the presence of the application in the privilege lists + * + * @param providedClientDataHash Client data hash precalculated by the system + * @param callingAppInfo CallingAppInfo to verify and retrieve the specific Origin + * @param context Context for file operations. + * call [onOriginRetrieved] if the origin is already calculated by the system and in the privileged list, return the clientDataHash + * call [onOriginNotRetrieved] if the origin is not retrieved from the system, return a new Android Origin + */ + suspend fun getOrigin( + providedClientDataHash: ByteArray?, + callingAppInfo: CallingAppInfo?, + context: Context, + onOriginRetrieved: suspend (appOrigin: AppOrigin, clientDataHash: ByteArray) -> Unit, + onOriginNotRetrieved: suspend (appOrigin: AppOrigin, androidOriginString: String) -> Unit + ) { + if (callingAppInfo == null) { + throw SecurityException("Calling app info cannot be retrieved") + } + withContext(Dispatchers.IO) { + + // For trusted browsers like Chrome and Firefox + val callOrigin = try { + getOriginFromPrivilegedAllowLists(callingAppInfo, context) + } catch (e: Exception) { + // Throw the Privileged Exception only if it's a browser + if (e is PrivilegedAllowLists.PrivilegedException + && AppUtil.getInstalledBrowsersWithSignatures(context).any { + it.packageName == e.temptingApp.packageName + } + ) throw e + null + } + + // Build the default Android origin + val androidOrigin = AndroidOrigin( + packageName = callingAppInfo.packageName, + fingerprint = callingAppInfo.signingInfo.getApplicationFingerprints() + ) + + // Check if the webDomain is validated by the system + withContext(Dispatchers.Main) { + if (callOrigin != null && providedClientDataHash != null) { + // Origin already defined by the system + Log.d(javaClass.simpleName, "Origin $callOrigin retrieved from callingAppInfo") + onOriginRetrieved( + AppOrigin.fromOrigin(callOrigin, androidOrigin, verified = true), + providedClientDataHash + ) + } else { + // Add Android origin by default + onOriginNotRetrieved( + AppOrigin(verified = false).apply { + addAndroidOrigin(androidOrigin) + }, + androidOrigin.toOriginValue() + ) + } + } + } + } + + /** + * Generate a credential id randomly + */ + private fun generateCredentialId(): ByteArray { + // see https://w3c.github.io/webauthn/#credential-id + val size = 16 + val credentialId = ByteArray(size) + internalSecureRandom.nextBytes(credentialId) + return credentialId + } + + /** + * Utility method to create a passkey and the associated creation request parameters + * [intent] allows to retrieve the request + * [context] context to manage package verification files + * [defaultBackupEligibility] the default backup eligibility to add the the passkey entry + * [defaultBackupState] the default backup state to add the the passkey entry + * [passkeyCreated] is called asynchronously when the passkey has been created + */ + suspend fun retrievePasskeyCreationRequestParameters( + intent: Intent, + context: Context, + defaultBackupEligibility: Boolean?, + defaultBackupState: Boolean?, + passkeyCreated: suspend (Passkey, AppOrigin?, PublicKeyCredentialCreationParameters) -> Unit + ) { + val createCredentialRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + if (createCredentialRequest == null) + throw CreateCredentialUnknownException("could not retrieve request from intent") + val callingAppInfo = createCredentialRequest.callingAppInfo + val creationOptions = createCredentialRequest.retrievePasskeyCreationComponent() + + val relyingParty = creationOptions.relyingPartyEntity.id + val username = creationOptions.userEntity.name + val userHandle = creationOptions.userEntity.id + val pubKeyCredParams = creationOptions.pubKeyCredParams + val clientDataHash = creationOptions.clientDataHash + + val credentialId = generateCredentialId() + + val (keyPair, keyTypeId) = Signature.generateKeyPair( + pubKeyCredParams.map { params -> params.alg } + ) ?: throw CreateCredentialUnknownException("no known public key type found") + val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private) + + // Create the passkey element + val passkey = Passkey( + username = username, + privateKeyPem = privateKeyPem, + credentialId = b64Encode(credentialId), + userHandle = b64Encode(userHandle), + relyingParty = relyingParty, + backupEligibility = defaultBackupEligibility, + backupState = defaultBackupState + ) + + // create new entry in database + getOrigin( + providedClientDataHash = clientDataHash, + callingAppInfo = callingAppInfo, + context = context, + onOriginRetrieved = { appInfoToStore, clientDataHash -> + passkeyCreated.invoke( + passkey, + appInfoToStore, + PublicKeyCredentialCreationParameters( + publicKeyCredentialCreationOptions = creationOptions, + credentialId = credentialId, + signatureKey = Pair(keyPair, keyTypeId), + clientDataResponse = ClientDataDefinedResponse(clientDataHash) + ) + ) + }, + onOriginNotRetrieved = { appInfoToStore, origin -> + passkeyCreated.invoke( + passkey, + appInfoToStore, + PublicKeyCredentialCreationParameters( + publicKeyCredentialCreationOptions = creationOptions, + credentialId = credentialId, + signatureKey = Pair(keyPair, keyTypeId), + clientDataResponse = ClientDataBuildResponse( + type = ClientDataBuildResponse.Type.CREATE, + challenge = creationOptions.challenge, + origin = origin + ) + ) + ) + } + ) + } + + /** + * Build the passkey public key credential response, + * by calling this method the user is always recognized as present and verified + */ + fun buildCreatePublicKeyCredentialResponse( + publicKeyCredentialCreationParameters: PublicKeyCredentialCreationParameters, + backupEligibility: Boolean, + backupState: Boolean + ): CreatePublicKeyCredentialResponse { + + val keyPair = publicKeyCredentialCreationParameters.signatureKey.first + val keyTypeId = publicKeyCredentialCreationParameters.signatureKey.second + val responseJson = FidoPublicKeyCredential( + id = b64Encode(publicKeyCredentialCreationParameters.credentialId), + response = AuthenticatorAttestationResponse( + requestOptions = publicKeyCredentialCreationParameters.publicKeyCredentialCreationOptions, + credentialId = publicKeyCredentialCreationParameters.credentialId, + credentialPublicKey = Cbor().encode( + Signature.convertPublicKeyToMap( + publicKeyIn = keyPair.public, + keyTypeId = keyTypeId + ) ?: mapOf()), + userPresent = true, + userVerified = true, + backupEligibility = backupEligibility, + backupState = backupState, + publicKeyTypeId = keyTypeId, + publicKeyCbor = Signature.convertPublicKey(keyPair.public, keyTypeId)!!, + clientDataResponse = publicKeyCredentialCreationParameters.clientDataResponse + ), + authenticatorAttachment = "platform" + ).json() + // log only the length to prevent logging sensitive information + Log.d(javaClass.simpleName, "Json response for key creation") + return CreatePublicKeyCredentialResponse(responseJson) + } + + /** + * Utility method to use a passkey and create the associated usage request parameters + * [intent] allows to retrieve the request + * [context] context to manage package verification files + * [result] is called asynchronously after the creation of PublicKeyCredentialUsageParameters, the origin associated with it may or may not be verified + */ + suspend fun retrievePasskeyUsageRequestParameters( + intent: Intent, + context: Context, + result: suspend (PublicKeyCredentialUsageParameters) -> Unit + ) { + val getCredentialRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + if (getCredentialRequest == null) + throw CreateCredentialUnknownException("could not retrieve request from intent") + val callingAppInfo = getCredentialRequest.callingAppInfo + val credentialOption = getCredentialRequest.retrievePasskeyUsageComponent() + val clientDataHash = credentialOption.clientDataHash + + val requestOptions = PublicKeyCredentialRequestOptions(credentialOption.requestJson) + + getOrigin( + providedClientDataHash = clientDataHash, + callingAppInfo = callingAppInfo, + context = context, + onOriginRetrieved = { appOrigin, clientDataHash -> + result.invoke( + PublicKeyCredentialUsageParameters( + publicKeyCredentialRequestOptions = requestOptions, + clientDataResponse = ClientDataDefinedResponse(clientDataHash), + appOrigin = appOrigin + ) + ) + }, + onOriginNotRetrieved = { appOrigin, androidOriginString -> + // By default we crate an usage parameter with Android origin + result.invoke( + PublicKeyCredentialUsageParameters( + publicKeyCredentialRequestOptions = requestOptions, + clientDataResponse = ClientDataBuildResponse( + type = ClientDataBuildResponse.Type.GET, + challenge = requestOptions.challenge, + origin = androidOriginString + ), + appOrigin = appOrigin + ) + ) + } + ) + } + + /** + * Build the passkey public key credential response, + * by calling this method the user is always recognized as present and verified + */ + fun buildPasskeyPublicKeyCredential( + requestOptions: PublicKeyCredentialRequestOptions, + clientDataResponse: ClientDataResponse, + passkey: Passkey, + defaultBackupEligibility: Boolean, + defaultBackupState: Boolean + ): PublicKeyCredential { + val getCredentialResponse = FidoPublicKeyCredential( + id = passkey.credentialId, + response = AuthenticatorAssertionResponse( + requestOptions = requestOptions, + userPresent = true, + userVerified = true, + backupEligibility = passkey.backupEligibility ?: defaultBackupEligibility, + backupState = passkey.backupState ?: defaultBackupState, + userHandle = passkey.userHandle, + privateKey = passkey.privateKeyPem, + clientDataResponse = clientDataResponse + ), + authenticatorAttachment = "platform" + ).json() + Log.d(javaClass.simpleName, "Json response for key usage") + return PublicKeyCredential(getCredentialResponse) + } + + + /** + * Verify that the application signature is contained in the [appOrigin] + */ + fun getVerifiedGETClientDataResponse( + usageParameters: PublicKeyCredentialUsageParameters, + appOrigin: AppOrigin + ): ClientDataResponse { + val appToCheck = usageParameters.appOrigin + return if (appToCheck.verified) { + usageParameters.clientDataResponse + } else { + // Origin checked by Android app signature + ClientDataBuildResponse( + type = ClientDataBuildResponse.Type.GET, + challenge = usageParameters.publicKeyCredentialRequestOptions.challenge, + origin = appToCheck.checkAppOrigin(appOrigin) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PrivilegedAllowLists.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PrivilegedAllowLists.kt new file mode 100644 index 000000000..df9b48b4a --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PrivilegedAllowLists.kt @@ -0,0 +1,227 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.util + +import android.content.Context +import android.util.Log +import androidx.credentials.provider.CallingAppInfo +import com.kunzisoft.encrypt.Signature.getAllFingerprints +import com.kunzisoft.keepass.BuildConfig +import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp +import org.json.JSONObject +import java.io.File +import java.io.FileNotFoundException +import java.io.InputStream + +object PrivilegedAllowLists { + + private const val FILE_NAME_PRIVILEGED_APPS_CUSTOM = "passkeys_privileged_apps_custom.json" + private const val FILE_NAME_PRIVILEGED_APPS_COMMUNITY = "passkeys_privileged_apps_community.json" + private const val FILE_NAME_PRIVILEGED_APPS_GOOGLE = "passkeys_privileged_apps_google.json" + + private fun retrieveContentFromStream( + inputStream: InputStream, + ): String { + return inputStream.use { fileInputStream -> + fileInputStream.bufferedReader(Charsets.UTF_8).readText() + } + } + + /** + * Get the origin from a predefined privileged allow list + * + * @param callingAppInfo CallingAppInfo to verify and retrieve the specific Origin + * @param inputStream File input stream containing the origin list as JSON + */ + private fun getOriginFromPrivilegedAllowListStream( + callingAppInfo: CallingAppInfo, + inputStream: InputStream + ): String? { + val privilegedAllowList = retrieveContentFromStream(inputStream) + return callingAppInfo.getOrigin(privilegedAllowList)?.removeSuffix("/") + } + + /** + * Get the origin from the predefined privileged allow lists + * + * @param callingAppInfo CallingAppInfo to verify and retrieve the specific Origin + * @param context Context for file operations. + */ + fun getOriginFromPrivilegedAllowLists( + callingAppInfo: CallingAppInfo, + context: Context + ): String? { + return try { + // Check the custom apps first + getOriginFromPrivilegedAllowListStream( + callingAppInfo = callingAppInfo, + File(context.filesDir, FILE_NAME_PRIVILEGED_APPS_CUSTOM) + .inputStream() + ) + } catch (e: Exception) { + // Then the Google list if allowed + if (BuildConfig.CLOSED_STORE) { + try { + // Check the Google list if allowed + // http://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json + getOriginFromPrivilegedAllowListStream( + callingAppInfo = callingAppInfo, + inputStream = context.assets.open(FILE_NAME_PRIVILEGED_APPS_GOOGLE) + ) + } catch (_: Exception) { + // Then the community apps list + getOriginFromPrivilegedAllowListStream( + callingAppInfo = callingAppInfo, + inputStream = context.assets.open(FILE_NAME_PRIVILEGED_APPS_COMMUNITY) + ) + } + } else { + when (e) { + is FileNotFoundException -> { + val attemptApp = AndroidPrivilegedApp( + packageName = callingAppInfo.packageName, + fingerprints = callingAppInfo.signingInfo + .getAllFingerprints() ?: emptySet() + ) + throw PrivilegedException( + temptingApp = attemptApp, + message = "$attemptApp is not in the allow list" + ) + } + else -> throw e + } + } + } + } + + /** + * Retrieves a list of predefined AndroidPrivilegedApp objects from an asset JSON file. + * + * @param inputStream File input stream containing the origin list as JSON + */ + private fun retrievePrivilegedApps( + inputStream: InputStream + ): List { + val jsonObject = JSONObject(retrieveContentFromStream(inputStream)) + return AndroidPrivilegedApp.extractPrivilegedApps(jsonObject) + } + + /** + * Retrieves a list of predefined AndroidPrivilegedApp objects from a context + * + * @param context Context for file operations. + */ + fun retrievePredefinedPrivilegedApps( + context: Context + ): List { + return try { + val predefinedApps = mutableListOf() + predefinedApps.addAll(retrievePrivilegedApps(context.assets.open(FILE_NAME_PRIVILEGED_APPS_COMMUNITY))) + if (BuildConfig.CLOSED_STORE) { + predefinedApps.addAll(retrievePrivilegedApps(context.assets.open(FILE_NAME_PRIVILEGED_APPS_GOOGLE))) + } + predefinedApps + } catch (e: Exception) { + Log.e(PrivilegedAllowLists::class.simpleName, "Error retrieving privileged apps", e) + emptyList() + } + } + + /** + * Retrieves a list of AndroidPrivilegedApp objects from the custom JSON file. + * + * @param context Context for file operations. + */ + fun retrieveCustomPrivilegedApps( + context: Context + ): List { + return try { + retrievePrivilegedApps(File(context.filesDir, FILE_NAME_PRIVILEGED_APPS_CUSTOM).inputStream()) + } catch (e: Exception) { + Log.i(PrivilegedAllowLists::class.simpleName, "No custom privileged apps", e) + emptyList() + } + } + + /** + * Retrieves a list of all predefined and custom AndroidPrivilegedApp objects. + */ + fun retrieveAllPrivilegedApps( + context: Context + ): List { + return retrievePredefinedPrivilegedApps(context) + retrieveCustomPrivilegedApps(context) + } + + /** + * Saves a list of custom AndroidPrivilegedApp objects to a JSON file. + * + * @param context Context for file operations. + * @param privilegedApps The list of apps to save. + * @return True if saving was successful, false otherwise. + */ + fun saveCustomPrivilegedApps(context: Context, privilegedApps: List): Boolean { + return try { + val jsonToSave = AndroidPrivilegedApp.toJsonObject(privilegedApps) + val file = File(context.filesDir, FILE_NAME_PRIVILEGED_APPS_CUSTOM) + + // Delete existing file before writing to ensure atomicity if needed + if (file.exists()) { + file.delete() + } + + file.outputStream().use { fileOutputStream -> + fileOutputStream.write( + jsonToSave + .toString(4) // toString(4) for pretty print + .toByteArray(Charsets.UTF_8) + ) + } + true + } catch (e: Exception) { + Log.e(PrivilegedAllowLists::class.simpleName, "Error saving privileged apps", e) + false + } + } + + /** + * Deletes the custom JSON file. + * + * @param context Context for file operations. + * @return True if deletion was successful or file didn't exist, false otherwise. + */ + fun deletePrivilegedAppsFile(context: Context): Boolean { + return try { + val file = File(context.filesDir, FILE_NAME_PRIVILEGED_APPS_CUSTOM) + if (file.exists()) { + file.delete() + } else { + true // File didn't exist, so considered "successfully deleted" + } + } catch (e: SecurityException) { + Log.e(PrivilegedAllowLists::class.simpleName, "Error deleting privileged apps file", e) + false + } + } + + class PrivilegedException( + val temptingApp: AndroidPrivilegedApp, + message: String + ) : Exception(message) +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/AutofillLauncherViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/AutofillLauncherViewModel.kt new file mode 100644 index 000000000..b16a552fa --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/AutofillLauncherViewModel.kt @@ -0,0 +1,284 @@ +package com.kunzisoft.keepass.credentialprovider.viewmodel + +import android.app.Activity.RESULT_CANCELED +import android.app.Activity.RESULT_OK +import android.app.Application +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.activity.result.ActivityResult +import androidx.annotation.RequiresApi +import androidx.lifecycle.viewModelScope +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.removeNodesIds +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveNodesIds +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveRegisterInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveSearchInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveSpecialMode +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper.retrieveAutofillComponent +import com.kunzisoft.keepass.credentialprovider.autofill.KeeAutofillService +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.element.node.NodeIdUUID +import com.kunzisoft.keepass.database.helper.SearchHelper +import com.kunzisoft.keepass.model.RegisterInfo +import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.settings.PreferencesUtil +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException + +@RequiresApi(api = Build.VERSION_CODES.O) +class AutofillLauncherViewModel(application: Application): CredentialLauncherViewModel(application) { + + private var mAutofillComponent: AutofillComponent? = null + + private var mLockDatabaseAfterSelection: Boolean = false + + private val mUiState = MutableStateFlow(UIState.Loading) + val uiState: StateFlow = mUiState + + fun initialize() { + mLockDatabaseAfterSelection = PreferencesUtil.isAutofillCloseDatabaseEnable(getApplication()) + } + + override fun onResult() { + super.onResult() + mAutofillComponent = null + } + + override suspend fun launchAction( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase? + ) { + // Retrieve selection mode + when (intent.retrieveSpecialMode()) { + SpecialMode.SELECTION -> { + val searchInfo = intent.retrieveSearchInfo() + if (searchInfo == null) + throw IOException("Search info is null") + mAutofillComponent = intent.retrieveAutofillComponent() + // Build search param + launchSelection(database, mAutofillComponent, searchInfo) + } + SpecialMode.REGISTRATION -> { + // To register info + val registerInfo = intent.retrieveRegisterInfo() + if (registerInfo == null) + throw IOException("Register info is null") + launchRegistration(database, registerInfo) + } + else -> { + // Not an autofill call + cancelResult() + } + } + } + + private suspend fun launchSelection( + database: ContextualDatabase?, + autofillComponent: AutofillComponent?, + searchInfo: SearchInfo + ) { + withContext(Dispatchers.IO) { + if (autofillComponent == null) { + throw IOException("Autofill component is null") + } + if (KeeAutofillService.autofillAllowedFor( + applicationId = searchInfo.applicationId, + webDomain = searchInfo.webDomain, + context = getApplication() + ) + ) { + // If database is open + SearchHelper.checkAutoSearchInfo( + context = getApplication(), + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> + // Items found + if (autofillComponent.compatInlineSuggestionsRequest != null) { + mUiState.value = UIState.ShowAutofillSuggestionMessage + } + AutofillHelper.buildResponse( + context = getApplication(), + autofillComponent = autofillComponent, + database = openedDatabase, + entriesInfo = items + ) { intent -> + setResult(intent, lockDatabase = mLockDatabaseAfterSelection) + } + }, + onItemNotFound = { openedDatabase -> + // Show the database UI to select the entry + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchGroupActivityForSelection( + database = openedDatabase, + searchInfo = searchInfo, + typeMode = TypeMode.AUTOFILL + ) + }, + onDatabaseClosed = { + // If database not open + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForSelection( + searchInfo = searchInfo, + typeMode = TypeMode.AUTOFILL + ) + } + ) + } else { + mUiState.value = UIState.ShowBlockRestartMessage + } + } + } + + override fun manageSelectionResult( + database: ContextualDatabase, + activityResult: ActivityResult + ) { + super.manageSelectionResult(database, activityResult) + val intent = activityResult.data + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + Log.e(TAG, "Unable to create selection response for autofill", e) + showError(e) + }) { + when (activityResult.resultCode) { + RESULT_OK -> { + withContext(Dispatchers.IO) { + Log.d(TAG, "Autofill selection result") + if (intent == null) + throw IOException("Intent is null") + val nodesIds = intent.retrieveNodesIds() + ?: throw IOException("NodesIds is null") + intent.removeNodesIds() + val autofillComponent = mAutofillComponent + if (autofillComponent == null) + throw IOException("Autofill component is null") + val entries = nodesIds.mapNotNull { nodeId -> + database + .getEntryById(NodeIdUUID(nodeId)) + ?.getEntryInfo(database) + } + withContext(Dispatchers.Main) { + AutofillHelper.buildResponse( + context = getApplication(), + autofillComponent = autofillComponent, + database = database, + entriesInfo = entries + ) { intent -> + setResult(intent, lockDatabase = mLockDatabaseAfterSelection) + } + } + } + } + RESULT_CANCELED -> { + withContext(Dispatchers.Main) { + cancelResult() + } + } + } + } + } + + // ------------- + // Registration + // ------------- + + private fun launchRegistration( + database: ContextualDatabase?, + registerInfo: RegisterInfo + ) { + val searchInfo = registerInfo.searchInfo + if (KeeAutofillService.autofillAllowedFor( + applicationId = searchInfo.applicationId, + webDomain = searchInfo.webDomain, + context = getApplication() + )) { + val readOnly = database?.isReadOnly != false + SearchHelper.checkAutoSearchInfo( + context = getApplication(), + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, _ -> + if (!readOnly) { + // Show the database UI to select the entry + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchGroupActivityForRegistration( + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + } else { + mUiState.value = UIState.ShowReadOnlyMessage + } + }, + onItemNotFound = { openedDatabase -> + if (!readOnly) { + // Show the database UI to select the entry + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchGroupActivityForRegistration( + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + } else { + mUiState.value = UIState.ShowReadOnlyMessage + } + }, + onDatabaseClosed = { + // If database not open + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForRegistration( + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + } + ) + } else { + mUiState.value = UIState.ShowBlockRestartMessage + } + } + + override fun manageRegistrationResult(activityResult: ActivityResult) { + isResultLauncherRegistered = false + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + Log.e(TAG, "Unable to create registration response for autofill", e) + showError(e) + }) { + val responseIntent = Intent() + when (activityResult.resultCode) { + RESULT_OK -> { + Log.d(TAG, "Autofill registration result") + withContext(Dispatchers.Main) { + setResult(responseIntent) + } + } + RESULT_CANCELED -> { + withContext(Dispatchers.Main) { + cancelResult() + } + } + } + } + + } + + sealed class UIState { + object Loading: UIState() + object ShowBlockRestartMessage: UIState() + object ShowReadOnlyMessage: UIState() + object ShowAutofillSuggestionMessage: UIState() + } + + companion object { + private val TAG = AutofillLauncherViewModel::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/CredentialLauncherViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/CredentialLauncherViewModel.kt new file mode 100644 index 000000000..7eb876f2a --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/CredentialLauncherViewModel.kt @@ -0,0 +1,151 @@ +package com.kunzisoft.keepass.credentialprovider.viewmodel + +import android.app.Activity.RESULT_CANCELED +import android.app.Activity.RESULT_OK +import android.app.Application +import android.content.Intent +import android.util.Log +import androidx.activity.result.ActivityResult +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.model.RegisterInfo +import com.kunzisoft.keepass.model.SearchInfo +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +abstract class CredentialLauncherViewModel(application: Application): AndroidViewModel(application) { + + protected var mDatabase: ContextualDatabase? = null + + protected var isResultLauncherRegistered: Boolean = false + private var mSelectionResult: ActivityResult? = null + + protected val mCredentialUiState = MutableStateFlow(UIState.Loading) + val credentialUiState: StateFlow = mCredentialUiState + + fun showError(error: Throwable) { + Log.e(TAG, "Error on credential provider launch", error) + mCredentialUiState.value = UIState.ShowError(error) + } + + open fun onResult() { + isResultLauncherRegistered = false + mSelectionResult = null + } + + fun setResult(intent: Intent, lockDatabase: Boolean = false) { + // Remove the launcher register + onResult() + mCredentialUiState.value = UIState.SetActivityResult( + lockDatabase = lockDatabase, + resultCode = RESULT_OK, + data = intent + ) + } + + fun cancelResult(lockDatabase: Boolean = false) { + onResult() + mCredentialUiState.value = UIState.SetActivityResult( + lockDatabase = lockDatabase, + resultCode = RESULT_CANCELED + ) + } + + private fun onDatabaseRetrieved(database: ContextualDatabase) { + mDatabase = database + mSelectionResult?.let { selectionResult -> + manageSelectionResult(database, selectionResult) + } + } + + fun manageSelectionResult(activityResult: ActivityResult) { + // Waiting for the database if needed + when (activityResult.resultCode) { + RESULT_OK -> { + mSelectionResult = activityResult + mDatabase?.let { database -> + manageSelectionResult(database, activityResult) + } + } + RESULT_CANCELED -> { + cancelResult() + } + } + } + + open fun manageSelectionResult(database: ContextualDatabase, activityResult: ActivityResult) { + mSelectionResult = null + } + + open fun manageRegistrationResult(activityResult: ActivityResult) {} + + open fun onExceptionOccurred(e: Throwable) { + showError(e) + } + + open fun launchActionIfNeeded( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase? + ) { + if (database != null) { + onDatabaseRetrieved(database) + } + if (isResultLauncherRegistered.not()) { + isResultLauncherRegistered = true + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + onExceptionOccurred(e) + }) { + launchAction(intent, specialMode, database) + } + } + } + + /** + * Launch the main action + */ + protected abstract suspend fun launchAction( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase? + ) + + sealed class UIState { + object Loading : UIState() + data class LaunchGroupActivityForSelection( + val database: ContextualDatabase, + val searchInfo: SearchInfo?, + val typeMode: TypeMode + ): UIState() + data class LaunchGroupActivityForRegistration( + val database: ContextualDatabase, + val registerInfo: RegisterInfo?, + val typeMode: TypeMode + ): UIState() + data class LaunchFileDatabaseSelectActivityForSelection( + val searchInfo: SearchInfo?, + val typeMode: TypeMode + ): UIState() + data class LaunchFileDatabaseSelectActivityForRegistration( + val registerInfo: RegisterInfo?, + val typeMode: TypeMode + ): UIState() + data class SetActivityResult( + val lockDatabase: Boolean, + val resultCode: Int, + val data: Intent? = null + ): UIState() + data class ShowError( + val error: Throwable + ): UIState() + } + + companion object { + private val TAG = CredentialLauncherViewModel::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/HardwareKeyLauncherViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/HardwareKeyLauncherViewModel.kt new file mode 100644 index 000000000..2ae03f854 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/HardwareKeyLauncherViewModel.kt @@ -0,0 +1,147 @@ +package com.kunzisoft.keepass.credentialprovider.viewmodel + +import android.app.Activity.RESULT_OK +import android.app.Application +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.activity.result.ActivityResult +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.activity.HardwareKeyActivity.Companion.isHardwareKeyAvailable +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.hardware.HardwareKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class HardwareKeyLauncherViewModel(application: Application): CredentialLauncherViewModel(application) { + + private val mUiState = MutableStateFlow(UIState.Loading) + val uiState: StateFlow = mUiState + + override suspend fun launchAction( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase? + ) { + val hardwareKey = HardwareKey.Companion.getHardwareKeyFromString( + intent.getStringExtra(DATA_HARDWARE_KEY) + ) + if (isHardwareKeyAvailable(getApplication(), hardwareKey)) { + when (hardwareKey) { + /* + HardwareKey.FIDO2_SECRET -> { + // TODO FIDO2 under development + throw Exception("FIDO2 not implemented") + } + */ + HardwareKey.CHALLENGE_RESPONSE_YUBIKEY -> { + launchYubikeyChallengeForResponse(intent.getByteArrayExtra(DATA_SEED)) + } + else -> { + UIState.OnChallengeResponded(null) + } + } + } else { + mUiState.value = UIState.ShowHardwareKeyDriverNeeded(hardwareKey) + } + } + + private fun launchYubikeyChallengeForResponse(seed: ByteArray?) { + // Transform the seed before sending + var challenge: ByteArray? = null + if (seed != null) { + challenge = ByteArray(64) + seed.copyInto(challenge, 0, 0, 32) + challenge.fill(32, 32, 64) + } + mUiState.value = UIState.LaunchChallengeActivityForResponse(challenge) + Log.d(TAG, "Challenge sent") + } + + override fun manageSelectionResult( + database: ContextualDatabase, + activityResult: ActivityResult + ) { + super.manageSelectionResult(database, activityResult) + + if (activityResult.resultCode == RESULT_OK) { + val challengeResponse: ByteArray? = + activityResult.data?.getByteArrayExtra(HARDWARE_KEY_RESPONSE_KEY) + Log.d(TAG, "Response form challenge") + mUiState.value = UIState.OnChallengeResponded(challengeResponse) + } else { + Log.e(TAG, "Response from challenge error") + mUiState.value = UIState.OnChallengeResponded(null) + } + } + + sealed class UIState { + object Loading : UIState() + data class ShowHardwareKeyDriverNeeded( + val hardwareKey: HardwareKey? + ): UIState() + data class LaunchChallengeActivityForResponse( + val challenge: ByteArray?, + ): UIState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LaunchChallengeActivityForResponse + + return challenge.contentEquals(other.challenge) + } + + override fun hashCode(): Int { + return challenge?.contentHashCode() ?: 0 + } + } + data class OnChallengeResponded( + val response: ByteArray? + ): UIState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OnChallengeResponded + + return response.contentEquals(other.response) + } + + override fun hashCode(): Int { + return response?.contentHashCode() ?: 0 + } + } + } + + companion object { + private val TAG = HardwareKeyLauncherViewModel::class.java.name + + private const val DATA_HARDWARE_KEY = "DATA_HARDWARE_KEY" + private const val DATA_SEED = "DATA_SEED" + + // Driver call + private const val YUBIKEY_CHALLENGE_RESPONSE_INTENT = "android.yubikey.intent.action.CHALLENGE_RESPONSE" + private const val HARDWARE_KEY_CHALLENGE_KEY = "challenge" + private const val HARDWARE_KEY_RESPONSE_KEY = "response" + + fun isYubikeyDriverAvailable(context: Context): Boolean { + return Intent(YUBIKEY_CHALLENGE_RESPONSE_INTENT) + .resolveActivity(context.packageManager) != null + } + + fun buildHardwareKeyChallenge(challenge: ByteArray?): Intent { + return Intent(YUBIKEY_CHALLENGE_RESPONSE_INTENT).apply { + putExtra(HARDWARE_KEY_CHALLENGE_KEY, challenge) + } + } + + fun Intent.addHardwareKey(hardwareKey: HardwareKey) { + putExtra(DATA_HARDWARE_KEY, hardwareKey.value) + } + + fun Intent.addSeed(seed: ByteArray?) { + putExtra(DATA_SEED, seed) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/PasskeyLauncherViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/PasskeyLauncherViewModel.kt new file mode 100644 index 000000000..138cef430 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/viewmodel/PasskeyLauncherViewModel.kt @@ -0,0 +1,550 @@ +package com.kunzisoft.keepass.credentialprovider.viewmodel + +import android.app.Activity.RESULT_CANCELED +import android.app.Activity.RESULT_OK +import android.app.Application +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.activity.result.ActivityResult +import androidx.annotation.RequiresApi +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.PendingIntentHandler +import androidx.lifecycle.viewModelScope +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.removeInfo +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.removeNodeId +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveNodeId +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.retrieveSearchInfo +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildCreatePublicKeyCredentialResponse +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyPublicKeyCredential +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.checkSecurity +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.getVerifiedGETClientDataResponse +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removeAppOrigin +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removePasskey +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveAppOrigin +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskey +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists +import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.saveCustomPrivilegedApps +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.element.Entry +import com.kunzisoft.keepass.database.element.node.NodeIdUUID +import com.kunzisoft.keepass.database.exception.RegisterInReadOnlyDatabaseException +import com.kunzisoft.keepass.database.helper.SearchHelper +import com.kunzisoft.keepass.model.AppOrigin +import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.model.RegisterInfo +import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.model.SignatureNotFoundException +import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getNewEntry +import com.kunzisoft.keepass.settings.PreferencesUtil +import com.kunzisoft.keepass.tasks.ActionRunnable +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException +import java.io.InvalidObjectException +import java.util.UUID + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeyLauncherViewModel(application: Application): CredentialLauncherViewModel(application) { + + private var mUsageParameters: PublicKeyCredentialUsageParameters? = null + private var mCreationParameters: PublicKeyCredentialCreationParameters? = null + private var mPasskey: Passkey? = null + + private var mLockDatabaseAfterSelection: Boolean = false + private var mBackupEligibility: Boolean = true + private var mBackupState: Boolean = false + + private val mUiState = MutableStateFlow(UIState.Loading) + val uiState: StateFlow = mUiState + + fun initialize() { + mLockDatabaseAfterSelection = PreferencesUtil.isPasskeyCloseDatabaseEnable(getApplication()) + mBackupEligibility = PreferencesUtil.isPasskeyBackupEligibilityEnable(getApplication()) + mBackupState = PreferencesUtil.isPasskeyBackupStateEnable(getApplication()) + } + + fun showAppPrivilegedDialog( + temptingApp: AndroidPrivilegedApp + ) { + mUiState.value = UIState.ShowAppPrivilegedDialog(temptingApp) + } + + fun showAppSignatureDialog( + temptingApp: AppOrigin, + nodeId: UUID + ) { + mUiState.value = UIState.ShowAppSignatureDialog(temptingApp, nodeId) + } + + fun saveCustomPrivilegedApp( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase?, + temptingApp: AndroidPrivilegedApp + ) { + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + showError(e) + }) { + saveCustomPrivilegedApps( + context = getApplication(), + privilegedApps = listOf(temptingApp) + ) + launchAction( + intent = intent, + specialMode = specialMode, + database = database + ) + } + } + + fun saveAppSignature( + database: ContextualDatabase?, + temptingApp: AppOrigin, + nodeId: UUID + ) { + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + showError(e) + }) { + // Update the entry with app signature + val entry = database + ?.getEntryById(NodeIdUUID(nodeId)) + ?: throw GetCredentialUnknownException( + "No passkey with nodeId $nodeId found" + ) + if (database.isReadOnly) + throw RegisterInReadOnlyDatabaseException() + val newEntry = Entry(entry) + val entryInfo = newEntry.getEntryInfo( + database, + raw = true, + removeTemplateConfiguration = false + ) + entryInfo.saveAppOrigin(database, temptingApp) + newEntry.setEntryInfo(database, entryInfo) + mUiState.value = UIState.UpdateEntry( + oldEntry = entry, + newEntry = newEntry + ) + } + } + + override fun onExceptionOccurred(e: Throwable) { + if (e is PrivilegedAllowLists.PrivilegedException) { + showAppPrivilegedDialog(e.temptingApp) + } else { + super.onExceptionOccurred(e) + } + } + + override fun launchActionIfNeeded( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase? + ) { + // Launch with database when a nodeId is present + if ((database != null && database.loaded) || intent.retrieveNodeId() == null) { + super.launchActionIfNeeded(intent, specialMode, database) + } + } + + override suspend fun launchAction( + intent: Intent, + specialMode: SpecialMode, + database: ContextualDatabase? + ) { + val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo() + val appOrigin = intent.retrieveAppOrigin() ?: AppOrigin(verified = false) + val nodeId = intent.retrieveNodeId() + intent.removeInfo() + intent.removeAppOrigin() + intent.removeNodeId() + checkSecurity(intent, nodeId) + when (specialMode) { + SpecialMode.SELECTION -> { + launchSelection( + intent = intent, + database = database, + nodeId = nodeId, + searchInfo = searchInfo, + appOrigin = appOrigin + ) + } + SpecialMode.REGISTRATION -> { + // TODO Registration in predefined group + // launchRegistration(database, nodeId, mSearchInfo) + launchRegistration( + intent = intent, + database = database, + nodeId = null, + searchInfo = searchInfo + ) + } + else -> { + throw InvalidObjectException("Passkey launch mode not supported") + } + } + } + + // ------------- + // Selection + // ------------- + + private suspend fun launchSelection( + intent: Intent, + database: ContextualDatabase?, + nodeId: UUID?, + searchInfo: SearchInfo, + appOrigin: AppOrigin + ) { + withContext(Dispatchers.IO) { + Log.d(TAG, "Launch passkey selection") + retrievePasskeyUsageRequestParameters( + intent = intent, + context = getApplication() + ) { usageParameters -> + // Save the requested parameters + mUsageParameters = usageParameters + // Manage the passkey to use + nodeId?.let { nodeId -> + autoSelectPasskeyAndSetResult(database, nodeId, appOrigin) + } ?: run { + SearchHelper.checkAutoSearchInfo( + context = getApplication(), + database = database, + searchInfo = searchInfo, + onItemsFound = { _, _ -> + Log.w( + TAG, "Passkey found for auto selection, should not append," + + "use PasskeyProviderService instead" + ) + cancelResult() + }, + onItemNotFound = { openedDatabase -> + Log.d( + TAG, "No Passkey found for selection," + + "launch manual selection in opened database" + ) + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchGroupActivityForSelection( + database = openedDatabase, + searchInfo = searchInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onDatabaseClosed = { + Log.d(TAG, "Manual passkey selection in closed database") + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForSelection( + searchInfo = searchInfo, + typeMode = TypeMode.PASSKEY + ) + } + ) + } + } + } + } + + fun autoSelectPasskey( + result: ActionRunnable.Result, + database: ContextualDatabase + ) { + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + showError(e) + }) { + if (result.isSuccess) { + val entry = result.data?.getNewEntry(database) + ?: throw IOException("No passkey entry found") + autoSelectPasskeyAndSetResult( + database = database, + nodeId = entry.nodeId.id, + appOrigin = entry.getAppOrigin() + ?: throw IOException("No App origin found") + ) + } else throw result.exception + ?: IOException("Unable to auto select passkey") + } + } + + private suspend fun autoSelectPasskeyAndSetResult( + database: ContextualDatabase?, + nodeId: UUID, + appOrigin: AppOrigin + ) { + withContext(Dispatchers.IO) { + mUsageParameters?.let { usageParameters -> + // To get the passkey from the database + val passkey = database + ?.getEntryById(NodeIdUUID(nodeId)) + ?.getEntryInfo(database) + ?.passkey + ?: throw IOException( + "No passkey with nodeId $nodeId found" + ) + // Build the response + val result = Intent() + try { + PendingIntentHandler.setGetCredentialResponse( + result, + GetCredentialResponse( + buildPasskeyPublicKeyCredential( + requestOptions = usageParameters.publicKeyCredentialRequestOptions, + clientDataResponse = getVerifiedGETClientDataResponse( + usageParameters = usageParameters, + appOrigin = appOrigin + ), + passkey = passkey, + defaultBackupEligibility = mBackupEligibility, + defaultBackupState = mBackupState + ) + ) + ) + setResult(result, lockDatabase = mLockDatabaseAfterSelection) + } catch (e: SignatureNotFoundException) { + // Request the dialog if signature exception + showAppSignatureDialog(e.temptingApp, nodeId) + } + } ?: throw IOException("Usage parameters is null") + } + } + + override fun manageSelectionResult( + database: ContextualDatabase, + activityResult: ActivityResult + ) { + super.manageSelectionResult(database, activityResult) + val intent = activityResult.data + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + Log.e(TAG, "Unable to create selection response for passkey", e) + if (e is SignatureNotFoundException) { + intent?.retrieveNodeId()?.let { nodeId -> + showAppSignatureDialog(e.temptingApp, nodeId) + } ?: cancelResult() + } else { + showError(e) + } + }) { + // Build a new formatted response from the selection response + val responseIntent = Intent() + when (activityResult.resultCode) { + RESULT_OK -> { + withContext(Dispatchers.IO) { + Log.d(TAG, "Passkey selection result") + if (intent == null) + throw IOException("Intent is null") + val passkey = intent.retrievePasskey() + ?: throw IOException("Passkey is null") + val appOrigin = intent.retrieveAppOrigin() + ?: throw IOException("App origin is null") + intent.removePasskey() + intent.removeAppOrigin() + mUsageParameters?.let { usageParameters -> + // Check verified origin + PendingIntentHandler.setGetCredentialResponse( + responseIntent, + GetCredentialResponse( + buildPasskeyPublicKeyCredential( + requestOptions = usageParameters.publicKeyCredentialRequestOptions, + clientDataResponse = getVerifiedGETClientDataResponse( + usageParameters = usageParameters, + appOrigin = appOrigin + ), + passkey = passkey, + defaultBackupEligibility = mBackupEligibility, + defaultBackupState = mBackupState + ) + ) + ) + } ?: run { + throw IOException("Usage parameters is null") + } + withContext(Dispatchers.Main) { + setResult(responseIntent, lockDatabase = mLockDatabaseAfterSelection) + } + } + } + RESULT_CANCELED -> { + withContext(Dispatchers.Main) { + cancelResult() + } + } + } + } + } + + // ------------- + // Registration + // ------------- + + private suspend fun launchRegistration( + intent: Intent, + database: ContextualDatabase?, + nodeId: UUID?, + searchInfo: SearchInfo + ) { + withContext(Dispatchers.IO) { + Log.d(TAG, "Launch passkey registration") + retrievePasskeyCreationRequestParameters( + intent = intent, + context = getApplication(), + defaultBackupEligibility = mBackupEligibility, + defaultBackupState = mBackupState, + passkeyCreated = { passkey, appInfoToStore, publicKeyCredentialParameters -> + // Save the requested parameters + mPasskey = passkey + mCreationParameters = publicKeyCredentialParameters + // Manage the passkey and create a register info + val registerInfo = RegisterInfo( + searchInfo = searchInfo, + passkey = passkey, + appOrigin = appInfoToStore + ) + // If nodeId already provided + nodeId?.let { nodeId -> + autoRegisterPasskeyAndSetResult(database, nodeId, passkey) + } ?: run { + SearchHelper.checkAutoSearchInfo( + context = getApplication(), + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, _ -> + Log.w( + TAG, "Passkey found for registration, " + + "but launch manual registration for a new entry" + ) + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchGroupActivityForRegistration( + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onItemNotFound = { openedDatabase -> + Log.d(TAG, "Launch new manual registration in opened database") + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchGroupActivityForRegistration( + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onDatabaseClosed = { + Log.d(TAG, "Manual passkey registration in closed database") + mCredentialUiState.value = + CredentialLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForRegistration( + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + } + ) + } + } + ) + } + } + + private suspend fun autoRegisterPasskeyAndSetResult( + database: ContextualDatabase?, + nodeId: UUID, + passkey: Passkey + ) { + withContext(Dispatchers.IO) { + mCreationParameters?.let { creationParameters -> + // To set the passkey to the database + // TODO Overwrite and Register in a predefined group + withContext(Dispatchers.Main) { + setResult(Intent()) + } + } ?: run { + withContext(Dispatchers.Main) { + Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") + cancelResult() + } + } + } + } + + override fun manageRegistrationResult(activityResult: ActivityResult) { + val intent = activityResult.data + viewModelScope.launch(CoroutineExceptionHandler { _, e -> + Log.e(TAG, "Unable to create registration response for passkey", e) + if (e is SignatureNotFoundException) { + intent?.retrieveNodeId()?.let { nodeId -> + showAppSignatureDialog(e.temptingApp, nodeId) + } ?: cancelResult() + } else { + showError(e) + } + }) { + // Build a new formatted response from the creation response + val responseIntent = Intent() + when (activityResult.resultCode) { + RESULT_OK -> { + withContext(Dispatchers.IO) { + Log.d(TAG, "Passkey registration result") + val passkey = intent?.retrievePasskey() + intent?.removePasskey() + intent?.removeAppOrigin() + // If registered passkey is the same as the one we want to validate, + if (mPasskey == passkey) { + mCreationParameters?.let { + PendingIntentHandler.setCreateCredentialResponse( + intent = responseIntent, + response = buildCreatePublicKeyCredentialResponse( + publicKeyCredentialCreationParameters = it, + backupEligibility = passkey?.backupEligibility + ?: mBackupEligibility, + backupState = passkey?.backupState + ?: mBackupState + ) + ) + } + } else { + throw SecurityException("Passkey was modified before registration") + } + withContext(Dispatchers.Main) { + setResult(responseIntent) + } + } + } + RESULT_CANCELED -> { + withContext(Dispatchers.Main) { + cancelResult() + } + } + } + } + } + + sealed class UIState { + object Loading : UIState() + data class ShowAppPrivilegedDialog( + val temptingApp: AndroidPrivilegedApp + ): UIState() + data class ShowAppSignatureDialog( + val temptingApp: AppOrigin, + val nodeId: UUID + ): UIState() + data class UpdateEntry( + val oldEntry: Entry, + val newEntry: Entry + ): UIState() + } + + companion object { + private val TAG = PasskeyLauncherViewModel::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt b/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt index 303a725a6..9b1c9faa2 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt @@ -19,7 +19,6 @@ */ package com.kunzisoft.keepass.database -import android.Manifest import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context @@ -29,23 +28,15 @@ import android.content.Context.BIND_IMPORTANT import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection -import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.IBinder import android.util.Log import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog -import androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.dialogs.DatabaseChangedDialogFragment -import com.kunzisoft.keepass.activities.dialogs.DatabaseChangedDialogFragment.Companion.DATABASE_CHANGED_DIALOG_TAG import com.kunzisoft.keepass.database.crypto.EncryptionAlgorithm import com.kunzisoft.keepass.database.crypto.kdf.KdfEngine import com.kunzisoft.keepass.database.element.Entry @@ -55,7 +46,6 @@ 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.CipherEncryptDatabase -import com.kunzisoft.keepass.model.SnapFileDatabaseInfo import com.kunzisoft.keepass.services.DatabaseTaskNotificationService import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_CHALLENGE_RESPONDED import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_ASSIGN_CREDENTIAL_TASK @@ -89,13 +79,9 @@ import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion. import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_RECYCLE_BIN_TASK import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_TEMPLATES_GROUP_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 import com.kunzisoft.keepass.utils.DATABASE_START_TASK_ACTION import com.kunzisoft.keepass.utils.DATABASE_STOP_TASK_ACTION import com.kunzisoft.keepass.utils.putParcelableList -import kotlinx.coroutines.launch import java.util.UUID /** @@ -103,175 +89,59 @@ import java.util.UUID * Useful to retrieve a database instance and sending tasks commands */ class DatabaseTaskProvider( - private var context: Context, - private var showDialog: Boolean = true + private var context: Context ) { - // To show dialog only if context is an activity - private var activity: FragmentActivity? = try { context as? FragmentActivity? } - catch (_: Exception) { null } - var onDatabaseRetrieved: ((database: ContextualDatabase?) -> Unit)? = null - var onActionFinish: ((database: ContextualDatabase, - actionTask: String, - result: ActionRunnable.Result) -> Unit)? = null - - private var intentDatabaseTask: Intent = Intent( - context.applicationContext, - DatabaseTaskNotificationService::class.java - ) + var onStartActionRequested: ((bundle: Bundle?, actionTask: String) -> Unit)? = null + var actionTaskListener: DatabaseTaskNotificationService.ActionTaskListener? = null + var databaseInfoListener: DatabaseTaskNotificationService.DatabaseInfoListener? = null private var databaseTaskBroadcastReceiver: BroadcastReceiver? = null private var mBinder: DatabaseTaskNotificationService.ActionTaskBinder? = null private var serviceConnection: ServiceConnection? = null - private var progressTaskDialogFragment: ProgressTaskDialogFragment? = null - private var databaseChangedDialogFragment: DatabaseChangedDialogFragment? = null - fun destroy() { - this.activity = null this.onDatabaseRetrieved = null - this.onActionFinish = null this.databaseTaskBroadcastReceiver = null this.mBinder = null this.serviceConnection = null - this.progressTaskDialogFragment = null - this.databaseChangedDialogFragment = null } - private val actionTaskListener = object: DatabaseTaskNotificationService.ActionTaskListener { - override fun onActionStarted( - database: ContextualDatabase, - progressMessage: ProgressMessage - ) { - if (showDialog) - startDialog(progressMessage) - } - - override fun onActionUpdated( - database: ContextualDatabase, - progressMessage: ProgressMessage - ) { - if (showDialog) - updateDialog(progressMessage) - } - - override fun onActionStopped( - database: ContextualDatabase - ) { - // Remove the progress task - stopDialog() - } - - override fun onActionFinished( - database: ContextualDatabase, - actionTask: String, - result: ActionRunnable.Result - ) { - onActionFinish?.invoke(database, actionTask, result) - onActionStopped(database) - } + fun onDatabaseChangeValidated() { + mBinder?.getService()?.saveDatabaseInfo() } - 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, - readOnlyDatabase: Boolean - ) { - activity?.let { activity -> - activity.lifecycleScope.launch { - if (databaseChangedDialogFragment == null) { - databaseChangedDialogFragment = activity.supportFragmentManager - .findFragmentByTag(DATABASE_CHANGED_DIALOG_TAG) as DatabaseChangedDialogFragment? - databaseChangedDialogFragment?.actionDatabaseListener = - mActionDatabaseListener - } - if (progressTaskDialogFragment == null) { - databaseChangedDialogFragment = DatabaseChangedDialogFragment.getInstance( - previousDatabaseInfo, - newDatabaseInfo, - readOnlyDatabase - ) - databaseChangedDialogFragment?.actionDatabaseListener = - mActionDatabaseListener - databaseChangedDialogFragment?.show( - activity.supportFragmentManager, - DATABASE_CHANGED_DIALOG_TAG - ) - } - } - } - } - } - - private var databaseListener = object: DatabaseTaskNotificationService.DatabaseListener { + private var databaseListener = object : DatabaseTaskNotificationService.DatabaseListener { override fun onDatabaseRetrieved(database: ContextualDatabase?) { onDatabaseRetrieved?.invoke(database) } } - private fun startDialog(progressMessage: ProgressMessage) { - activity?.let { activity -> - activity.lifecycleScope.launch { - if (progressTaskDialogFragment == null) { - progressTaskDialogFragment = activity.supportFragmentManager - .findFragmentByTag(PROGRESS_TASK_DIALOG_TAG) as ProgressTaskDialogFragment? - } - if (progressTaskDialogFragment == null) { - progressTaskDialogFragment = ProgressTaskDialogFragment() - progressTaskDialogFragment?.show( - activity.supportFragmentManager, - PROGRESS_TASK_DIALOG_TAG - ) - } - updateDialog(progressMessage) - } - } - } - - private fun updateDialog(progressMessage: ProgressMessage) { - progressTaskDialogFragment?.apply { - updateTitle(progressMessage.titleId) - updateMessage(progressMessage.messageId) - updateWarning(progressMessage.warningId) - setCancellable(progressMessage.cancelable) - } - } - - private fun stopDialog() { - progressTaskDialogFragment?.dismissAllowingStateLoss() - progressTaskDialogFragment = null - } - private fun initServiceConnection() { - stopDialog() + actionTaskListener?.onActionStopped() if (serviceConnection == null) { serviceConnection = object : ServiceConnection { override fun onBindingDied(name: ComponentName?) { - stopDialog() + actionTaskListener?.onActionStopped() + onDatabaseRetrieved?.invoke(null) } override fun onNullBinding(name: ComponentName?) { - stopDialog() + actionTaskListener?.onActionStopped() + onDatabaseRetrieved?.invoke(null) } override fun onServiceConnected(name: ComponentName?, serviceBinder: IBinder?) { - mBinder = (serviceBinder as DatabaseTaskNotificationService.ActionTaskBinder?)?.apply { - addServiceListeners(this) - getService().checkDatabase() - getService().checkDatabaseInfo() - getService().checkAction() - } + mBinder = + (serviceBinder as DatabaseTaskNotificationService.ActionTaskBinder?)?.apply { + addServiceListeners(this) + getService().checkDatabase() + getService().checkDatabaseInfo() + getService().checkAction() + } } override fun onServiceDisconnected(name: ComponentName?) { @@ -284,20 +154,36 @@ class DatabaseTaskProvider( private fun addServiceListeners(service: DatabaseTaskNotificationService.ActionTaskBinder?) { service?.addDatabaseListener(databaseListener) - service?.addDatabaseFileInfoListener(databaseInfoListener) - service?.addActionTaskListener(actionTaskListener) + databaseInfoListener?.let { infoListener -> + service?.addDatabaseFileInfoListener(infoListener) + } + actionTaskListener?.let { taskListener -> + service?.addActionTaskListener(taskListener) + } } private fun removeServiceListeners(service: DatabaseTaskNotificationService.ActionTaskBinder?) { - service?.removeActionTaskListener(actionTaskListener) - service?.removeDatabaseFileInfoListener(databaseInfoListener) + actionTaskListener?.let { taskListener -> + service?.removeActionTaskListener(taskListener) + } + databaseInfoListener?.let { infoListener -> + service?.removeDatabaseFileInfoListener(infoListener) + } service?.removeDatabaseListener(databaseListener) + onDatabaseRetrieved?.invoke(null) } private fun bindService() { initServiceConnection() serviceConnection?.let { - context.bindService(intentDatabaseTask, it, BIND_AUTO_CREATE or BIND_IMPORTANT or BIND_ABOVE_CLIENT) + context.bindService( + Intent( + context.applicationContext, + DatabaseTaskNotificationService::class.java + ), + it, + BIND_AUTO_CREATE or BIND_IMPORTANT or BIND_ABOVE_CLIENT + ) } } @@ -325,6 +211,7 @@ class DatabaseTaskProvider( // Bind to the service when is starting bindService() } + DATABASE_STOP_TASK_ACTION -> { // Remove the progress task unBindService() @@ -332,7 +219,8 @@ class DatabaseTaskProvider( } } } - ContextCompat.registerReceiver(context, databaseTaskBroadcastReceiver, + ContextCompat.registerReceiver( + context, databaseTaskBroadcastReceiver, IntentFilter().apply { addAction(DATABASE_START_TASK_ACTION) addAction(DATABASE_STOP_TASK_ACTION) @@ -356,58 +244,9 @@ class DatabaseTaskProvider( } } - private val tempServiceParameters = mutableListOf>() - private val requestPermissionLauncher = activity?.registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { _ -> - // Whether or not the user has accepted, the service can be started, - // There just won't be any notification if it's not allowed. - tempServiceParameters.removeFirstOrNull()?.let { - startService(it.first, it.second) - } - } - private fun start(bundle: Bundle? = null, actionTask: String) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val contextActivity = activity - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) - == PackageManager.PERMISSION_GRANTED - ) { - startService(bundle, actionTask) - } else if (contextActivity != null && shouldShowRequestPermissionRationale( - contextActivity, - Manifest.permission.POST_NOTIFICATIONS - ) - ) { - // it's not the first time, so the user deliberately chooses not to display the notification - startService(bundle, actionTask) - } else { - AlertDialog.Builder(context) - .setMessage(R.string.warning_database_notification_permission) - .setNegativeButton(R.string.later) { _, _ -> - // Refuses the notification, so start the service - startService(bundle, actionTask) - } - .setPositiveButton(R.string.ask) { _, _ -> - // Save the temp parameters to ask the permission - tempServiceParameters.add(Pair(bundle, actionTask)) - requestPermissionLauncher?.launch(Manifest.permission.POST_NOTIFICATIONS) - }.create().show() - } - } else { - startService(bundle, actionTask) - } - } - - private fun startService(bundle: Bundle? = null, actionTask: String) { - try { - if (bundle != null) - intentDatabaseTask.putExtras(bundle) - intentDatabaseTask.action = actionTask - context.startService(intentDatabaseTask) - } catch (e: Exception) { - Log.e(TAG, "Unable to perform database action", e) - Toast.makeText(context, R.string.error_start_database_action, Toast.LENGTH_LONG).show() + onStartActionRequested?.invoke(bundle, actionTask) ?: run { + context.startDatabaseService(bundle, actionTask) } } @@ -417,47 +256,51 @@ class DatabaseTaskProvider( ---- */ - fun startDatabaseCreate(databaseUri: Uri, - mainCredential: MainCredential + fun startDatabaseCreate( + databaseUri: Uri, + mainCredential: MainCredential ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, databaseUri) putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential) - } - , ACTION_DATABASE_CREATE_TASK) + }, ACTION_DATABASE_CREATE_TASK) } - fun startDatabaseLoad(databaseUri: Uri, - mainCredential: MainCredential, - readOnly: Boolean, - cipherEncryptDatabase: CipherEncryptDatabase?, - fixDuplicateUuid: Boolean) { + fun startDatabaseLoad( + databaseUri: Uri, + mainCredential: MainCredential, + readOnly: Boolean, + cipherEncryptDatabase: CipherEncryptDatabase?, + fixDuplicateUuid: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, databaseUri) putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential) putBoolean(DatabaseTaskNotificationService.READ_ONLY_KEY, readOnly) - putParcelable(DatabaseTaskNotificationService.CIPHER_DATABASE_KEY, cipherEncryptDatabase) + putParcelable( + DatabaseTaskNotificationService.CIPHER_DATABASE_KEY, + cipherEncryptDatabase + ) putBoolean(DatabaseTaskNotificationService.FIX_DUPLICATE_UUID_KEY, fixDuplicateUuid) - } - , ACTION_DATABASE_LOAD_TASK) + }, ACTION_DATABASE_LOAD_TASK) } - fun startDatabaseMerge(save: Boolean, - fromDatabaseUri: Uri? = null, - mainCredential: MainCredential? = null) { + fun startDatabaseMerge( + save: Boolean, + fromDatabaseUri: Uri? = null, + mainCredential: MainCredential? = null + ) { start(Bundle().apply { putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, fromDatabaseUri) putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential) - } - , ACTION_DATABASE_MERGE_TASK) + }, ACTION_DATABASE_MERGE_TASK) } fun startDatabaseReload(fixDuplicateUuid: Boolean) { start(Bundle().apply { putBoolean(DatabaseTaskNotificationService.FIX_DUPLICATE_UUID_KEY, fixDuplicateUuid) - } - , ACTION_DATABASE_RELOAD_TASK) + }, ACTION_DATABASE_RELOAD_TASK) } fun askToStartDatabaseReload(conditionToAsk: Boolean, approved: () -> Unit) { @@ -473,15 +316,15 @@ class DatabaseTaskProvider( } } - fun startDatabaseAssignCredential(databaseUri: Uri, - mainCredential: MainCredential + fun startDatabaseAssignCredential( + databaseUri: Uri, + mainCredential: MainCredential ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, databaseUri) putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential) - } - , ACTION_DATABASE_ASSIGN_CREDENTIAL_TASK) + }, ACTION_DATABASE_ASSIGN_CREDENTIAL_TASK) } /* @@ -490,54 +333,60 @@ class DatabaseTaskProvider( ---- */ - fun startDatabaseCreateGroup(newGroup: Group, - parent: Group, - save: Boolean) { + fun startDatabaseCreateGroup( + newGroup: Group, + parent: Group, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.GROUP_KEY, newGroup) putParcelable(DatabaseTaskNotificationService.PARENT_ID_KEY, parent.nodeId) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_CREATE_GROUP_TASK) + }, ACTION_DATABASE_CREATE_GROUP_TASK) } - fun startDatabaseUpdateGroup(oldGroup: Group, - groupToUpdate: Group, - save: Boolean) { + fun startDatabaseUpdateGroup( + oldGroup: Group, + groupToUpdate: Group, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.GROUP_ID_KEY, oldGroup.nodeId) putParcelable(DatabaseTaskNotificationService.GROUP_KEY, groupToUpdate) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_GROUP_TASK) + }, ACTION_DATABASE_UPDATE_GROUP_TASK) } - fun startDatabaseCreateEntry(newEntry: Entry, - parent: Group, - save: Boolean) { + fun startDatabaseCreateEntry( + newEntry: Entry, + parent: Group, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.ENTRY_KEY, newEntry) putParcelable(DatabaseTaskNotificationService.PARENT_ID_KEY, parent.nodeId) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_CREATE_ENTRY_TASK) + }, ACTION_DATABASE_CREATE_ENTRY_TASK) } - fun startDatabaseUpdateEntry(oldEntry: Entry, - entryToUpdate: Entry, - save: Boolean) { + fun startDatabaseUpdateEntry( + oldEntry: Entry, + entryToUpdate: Entry, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.ENTRY_ID_KEY, oldEntry.nodeId) putParcelable(DatabaseTaskNotificationService.ENTRY_KEY, entryToUpdate) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_ENTRY_TASK) + }, ACTION_DATABASE_UPDATE_ENTRY_TASK) } - private fun startDatabaseActionListNodes(actionTask: String, - nodesPaste: List, - newParent: Group?, - save: Boolean) { + private fun startDatabaseActionListNodes( + actionTask: String, + nodesPaste: List, + newParent: Group?, + save: Boolean + ) { val groupsIdToCopy = ArrayList>() val entriesIdToCopy = ArrayList>() nodesPaste.forEach { nodeVersioned -> @@ -545,6 +394,7 @@ class DatabaseTaskProvider( Type.GROUP -> { groupsIdToCopy.add((nodeVersioned as Group).nodeId) } + Type.ENTRY -> { entriesIdToCopy.add((nodeVersioned as Entry).nodeId) } @@ -559,24 +409,29 @@ class DatabaseTaskProvider( if (newParentId != null) putParcelable(DatabaseTaskNotificationService.PARENT_ID_KEY, newParentId) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , actionTask) + }, actionTask) } - fun startDatabaseCopyNodes(nodesToCopy: List, - newParent: Group, - save: Boolean) { + fun startDatabaseCopyNodes( + nodesToCopy: List, + newParent: Group, + save: Boolean + ) { startDatabaseActionListNodes(ACTION_DATABASE_COPY_NODES_TASK, nodesToCopy, newParent, save) } - fun startDatabaseMoveNodes(nodesToMove: List, - newParent: Group, - save: Boolean) { + fun startDatabaseMoveNodes( + nodesToMove: List, + newParent: Group, + save: Boolean + ) { startDatabaseActionListNodes(ACTION_DATABASE_MOVE_NODES_TASK, nodesToMove, newParent, save) } - fun startDatabaseDeleteNodes(nodesToDelete: List, - save: Boolean) { + fun startDatabaseDeleteNodes( + nodesToDelete: List, + save: Boolean + ) { startDatabaseActionListNodes(ACTION_DATABASE_DELETE_NODES_TASK, nodesToDelete, null, save) } @@ -586,26 +441,28 @@ class DatabaseTaskProvider( ----------------- */ - fun startDatabaseRestoreEntryHistory(mainEntryId: NodeId, - entryHistoryPosition: Int, - save: Boolean) { + fun startDatabaseRestoreEntryHistory( + mainEntryId: NodeId, + entryHistoryPosition: Int, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.ENTRY_ID_KEY, mainEntryId) putInt(DatabaseTaskNotificationService.ENTRY_HISTORY_POSITION_KEY, entryHistoryPosition) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_RESTORE_ENTRY_HISTORY) + }, ACTION_DATABASE_RESTORE_ENTRY_HISTORY) } - fun startDatabaseDeleteEntryHistory(mainEntryId: NodeId, - entryHistoryPosition: Int, - save: Boolean) { + fun startDatabaseDeleteEntryHistory( + mainEntryId: NodeId, + entryHistoryPosition: Int, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.ENTRY_ID_KEY, mainEntryId) putInt(DatabaseTaskNotificationService.ENTRY_HISTORY_POSITION_KEY, entryHistoryPosition) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_DELETE_ENTRY_HISTORY) + }, ACTION_DATABASE_DELETE_ENTRY_HISTORY) } /* @@ -614,110 +471,118 @@ class DatabaseTaskProvider( ----------------- */ - fun startDatabaseSaveName(oldName: String, - newName: String, - save: Boolean) { + fun startDatabaseSaveName( + oldName: String, + newName: String, + save: Boolean + ) { start(Bundle().apply { putString(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldName) putString(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newName) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_NAME_TASK) + }, ACTION_DATABASE_UPDATE_NAME_TASK) } - fun startDatabaseSaveDescription(oldDescription: String, - newDescription: String, - save: Boolean) { + fun startDatabaseSaveDescription( + oldDescription: String, + newDescription: String, + save: Boolean + ) { start(Bundle().apply { putString(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldDescription) putString(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newDescription) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_DESCRIPTION_TASK) + }, ACTION_DATABASE_UPDATE_DESCRIPTION_TASK) } - fun startDatabaseSaveDefaultUsername(oldDefaultUsername: String, - newDefaultUsername: String, - save: Boolean) { + fun startDatabaseSaveDefaultUsername( + oldDefaultUsername: String, + newDefaultUsername: String, + save: Boolean + ) { start(Bundle().apply { putString(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldDefaultUsername) putString(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newDefaultUsername) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_DEFAULT_USERNAME_TASK) + }, ACTION_DATABASE_UPDATE_DEFAULT_USERNAME_TASK) } - fun startDatabaseSaveColor(oldColor: String, - newColor: String, - save: Boolean) { + fun startDatabaseSaveColor( + oldColor: String, + newColor: String, + save: Boolean + ) { start(Bundle().apply { putString(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldColor) putString(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newColor) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_COLOR_TASK) + }, ACTION_DATABASE_UPDATE_COLOR_TASK) } - fun startDatabaseSaveCompression(oldCompression: CompressionAlgorithm, - newCompression: CompressionAlgorithm, - save: Boolean) { + fun startDatabaseSaveCompression( + oldCompression: CompressionAlgorithm, + newCompression: CompressionAlgorithm, + save: Boolean + ) { start(Bundle().apply { putSerializable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldCompression) putSerializable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newCompression) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_COMPRESSION_TASK) + }, ACTION_DATABASE_UPDATE_COMPRESSION_TASK) } fun startDatabaseRemoveUnlinkedData(save: Boolean) { start(Bundle().apply { putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_REMOVE_UNLINKED_DATA_TASK) + }, ACTION_DATABASE_REMOVE_UNLINKED_DATA_TASK) } - fun startDatabaseSaveRecycleBin(oldRecycleBin: Group?, - newRecycleBin: Group?, - save: Boolean) { + fun startDatabaseSaveRecycleBin( + oldRecycleBin: Group?, + newRecycleBin: Group?, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldRecycleBin) putParcelable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newRecycleBin) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_RECYCLE_BIN_TASK) + }, ACTION_DATABASE_UPDATE_RECYCLE_BIN_TASK) } - fun startDatabaseSaveTemplatesGroup(oldTemplatesGroup: Group?, - newTemplatesGroup: Group?, - save: Boolean) { + fun startDatabaseSaveTemplatesGroup( + oldTemplatesGroup: Group?, + newTemplatesGroup: Group?, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldTemplatesGroup) putParcelable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newTemplatesGroup) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_TEMPLATES_GROUP_TASK) + }, ACTION_DATABASE_UPDATE_TEMPLATES_GROUP_TASK) } - fun startDatabaseSaveMaxHistoryItems(oldMaxHistoryItems: Int, - newMaxHistoryItems: Int, - save: Boolean) { + fun startDatabaseSaveMaxHistoryItems( + oldMaxHistoryItems: Int, + newMaxHistoryItems: Int, + save: Boolean + ) { start(Bundle().apply { putInt(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldMaxHistoryItems) putInt(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newMaxHistoryItems) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_MAX_HISTORY_ITEMS_TASK) + }, ACTION_DATABASE_UPDATE_MAX_HISTORY_ITEMS_TASK) } - fun startDatabaseSaveMaxHistorySize(oldMaxHistorySize: Long, - newMaxHistorySize: Long, - save: Boolean) { + fun startDatabaseSaveMaxHistorySize( + oldMaxHistorySize: Long, + newMaxHistorySize: Long, + save: Boolean + ) { start(Bundle().apply { putLong(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldMaxHistorySize) putLong(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newMaxHistorySize) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_MAX_HISTORY_SIZE_TASK) + }, ACTION_DATABASE_UPDATE_MAX_HISTORY_SIZE_TASK) } /* @@ -726,59 +591,64 @@ class DatabaseTaskProvider( ------------------- */ - fun startDatabaseSaveEncryption(oldEncryption: EncryptionAlgorithm, - newEncryption: EncryptionAlgorithm, - save: Boolean) { + fun startDatabaseSaveEncryption( + oldEncryption: EncryptionAlgorithm, + newEncryption: EncryptionAlgorithm, + save: Boolean + ) { start(Bundle().apply { putSerializable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldEncryption) putSerializable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newEncryption) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_ENCRYPTION_TASK) + }, ACTION_DATABASE_UPDATE_ENCRYPTION_TASK) } - fun startDatabaseSaveKeyDerivation(oldKeyDerivation: KdfEngine, - newKeyDerivation: KdfEngine, - save: Boolean) { + fun startDatabaseSaveKeyDerivation( + oldKeyDerivation: KdfEngine, + newKeyDerivation: KdfEngine, + save: Boolean + ) { start(Bundle().apply { putSerializable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldKeyDerivation) putSerializable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newKeyDerivation) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_KEY_DERIVATION_TASK) + }, ACTION_DATABASE_UPDATE_KEY_DERIVATION_TASK) } - fun startDatabaseSaveIterations(oldIterations: Long, - newIterations: Long, - save: Boolean) { + fun startDatabaseSaveIterations( + oldIterations: Long, + newIterations: Long, + save: Boolean + ) { start(Bundle().apply { putLong(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldIterations) putLong(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newIterations) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_ITERATIONS_TASK) + }, ACTION_DATABASE_UPDATE_ITERATIONS_TASK) } - fun startDatabaseSaveMemoryUsage(oldMemoryUsage: Long, - newMemoryUsage: Long, - save: Boolean) { + fun startDatabaseSaveMemoryUsage( + oldMemoryUsage: Long, + newMemoryUsage: Long, + save: Boolean + ) { start(Bundle().apply { putLong(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldMemoryUsage) putLong(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newMemoryUsage) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_MEMORY_USAGE_TASK) + }, ACTION_DATABASE_UPDATE_MEMORY_USAGE_TASK) } - fun startDatabaseSaveParallelism(oldParallelism: Long, - newParallelism: Long, - save: Boolean) { + fun startDatabaseSaveParallelism( + oldParallelism: Long, + newParallelism: Long, + save: Boolean + ) { start(Bundle().apply { putLong(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldParallelism) putLong(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newParallelism) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_PARALLELISM_TASK) + }, ACTION_DATABASE_UPDATE_PARALLELISM_TASK) } /** @@ -788,18 +658,32 @@ class DatabaseTaskProvider( start(Bundle().apply { putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, saveToUri) - } - , ACTION_DATABASE_SAVE) + }, ACTION_DATABASE_SAVE) } fun startChallengeResponded(response: ByteArray?) { start(Bundle().apply { putByteArray(DatabaseTaskNotificationService.DATA_BYTES, response) - } - , ACTION_CHALLENGE_RESPONDED) + }, ACTION_CHALLENGE_RESPONDED) } companion object { private val TAG = DatabaseTaskProvider::class.java.name + + fun Context.startDatabaseService(bundle: Bundle? = null, actionTask: String) { + try { + val intentDatabaseTask = Intent( + applicationContext, + DatabaseTaskNotificationService::class.java + ) + if (bundle != null) + intentDatabaseTask.putExtras(bundle) + intentDatabaseTask.action = actionTask + startService(intentDatabaseTask) + } catch (e: Exception) { + Log.e(TAG, "Unable to perform database action", e) + Toast.makeText(this, R.string.error_start_database_action, Toast.LENGTH_LONG).show() + } + } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/database/helper/LocalizedHelper.kt b/app/src/main/java/com/kunzisoft/keepass/database/helper/LocalizedHelper.kt index 7ebe3aa0f..82791b2a6 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/helper/LocalizedHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/helper/LocalizedHelper.kt @@ -24,14 +24,44 @@ import com.kunzisoft.keepass.R import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm import com.kunzisoft.keepass.database.element.template.TemplateEngine import com.kunzisoft.keepass.database.element.template.TemplateField -import com.kunzisoft.keepass.database.exception.* +import com.kunzisoft.keepass.database.exception.CopyEntryDatabaseException +import com.kunzisoft.keepass.database.exception.CopyGroupDatabaseException +import com.kunzisoft.keepass.database.exception.CorruptedDatabaseException +import com.kunzisoft.keepass.database.exception.DatabaseInputException +import com.kunzisoft.keepass.database.exception.DatabaseOutputException +import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException +import com.kunzisoft.keepass.database.exception.EmptyKeyDatabaseException +import com.kunzisoft.keepass.database.exception.FileNotFoundDatabaseException +import com.kunzisoft.keepass.database.exception.HardwareKeyDatabaseException +import com.kunzisoft.keepass.database.exception.InvalidAlgorithmDatabaseException +import com.kunzisoft.keepass.database.exception.InvalidCredentialsDatabaseException +import com.kunzisoft.keepass.database.exception.KDFMemoryDatabaseException +import com.kunzisoft.keepass.database.exception.LocalizedException +import com.kunzisoft.keepass.database.exception.MergeDatabaseKDBException +import com.kunzisoft.keepass.database.exception.MoveEntryDatabaseException +import com.kunzisoft.keepass.database.exception.MoveGroupDatabaseException +import com.kunzisoft.keepass.database.exception.NoMemoryDatabaseException +import com.kunzisoft.keepass.database.exception.RegisterInReadOnlyDatabaseException +import com.kunzisoft.keepass.database.exception.SignatureDatabaseException +import com.kunzisoft.keepass.database.exception.UnknownDatabaseLocationException +import com.kunzisoft.keepass.database.exception.VersionDatabaseException +import com.kunzisoft.keepass.database.exception.XMLMalformedDatabaseException +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_CREDENTIAL_ID +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_FLAG_BE +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_FLAG_BS +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_PRIVATE_KEY +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_RELYING_PARTY +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_USERNAME +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_USER_HANDLE +import com.kunzisoft.keepass.model.PasskeyEntryFields.PASSKEY_FIELD -fun DatabaseException.getLocalizedMessage(resources: Resources): String? = +fun LocalizedException.getLocalizedMessage(resources: Resources): String? = when (this) { is FileNotFoundDatabaseException -> resources.getString(R.string.file_not_found_content) is CorruptedDatabaseException -> resources.getString(R.string.corrupted_file) is InvalidAlgorithmDatabaseException -> resources.getString(R.string.invalid_algorithm) is UnknownDatabaseLocationException -> resources.getString(R.string.error_location_unknown) + is RegisterInReadOnlyDatabaseException -> resources.getString(R.string.error_save_read_only) is HardwareKeyDatabaseException -> resources.getString(R.string.error_hardware_key_unsupported) is EmptyKeyDatabaseException -> resources.getString(R.string.error_empty_key) is SignatureDatabaseException -> resources.getString(R.string.invalid_db_sig) @@ -63,6 +93,11 @@ fun TemplateField.isStandardPasswordName(context: Context, name: String): Boolea || name == getLocalizedName(context, LABEL_PASSWORD) } +fun TemplateField.isPasskeyLabel(context: Context, name: String): Boolean { + return name.equals(PASSKEY_FIELD, true) + || name == getLocalizedName(context, PASSKEY_FIELD) +} + fun TemplateField.getLocalizedName(context: Context?, name: String): String { if (context == null || TemplateEngine.containsTemplateDecorator(name) @@ -107,6 +142,15 @@ fun TemplateField.getLocalizedName(context: Context?, name: String): String { LABEL_SECURE_NOTE.equals(name, true) -> context.getString(R.string.secure_note) LABEL_MEMBERSHIP.equals(name, true) -> context.getString(R.string.membership) + PASSKEY_FIELD.equals(name, true) -> context.getString(R.string.passkey) + FIELD_USERNAME.equals(name, true) -> context.getString(R.string.passkey_username) + FIELD_PRIVATE_KEY.equals(name, true) -> context.getString(R.string.passkey_private_key) + FIELD_CREDENTIAL_ID.equals(name, true) -> context.getString(R.string.passkey_credential_id) + FIELD_USER_HANDLE.equals(name, true) -> context.getString(R.string.passkey_user_handle) + FIELD_RELYING_PARTY.equals(name, true) -> context.getString(R.string.passkey_relying_party) + FIELD_FLAG_BE.equals(name, true) -> context.getString(R.string.passkey_backup_eligibility) + FIELD_FLAG_BS.equals(name, true) -> context.getString(R.string.passkey_backup_state) + else -> name } } diff --git a/app/src/main/java/com/kunzisoft/keepass/database/helper/SearchHelper.kt b/app/src/main/java/com/kunzisoft/keepass/database/helper/SearchHelper.kt index 232b88e17..ade5ff023 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/helper/SearchHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/helper/SearchHelper.kt @@ -21,9 +21,16 @@ package com.kunzisoft.keepass.database.helper import android.content.Context import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.search.SearchParameters import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.settings.PreferencesUtil.searchSubDomains import com.kunzisoft.keepass.timeout.TimeoutHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.lib.publicsuffixlist.PublicSuffixList object SearchHelper { @@ -41,38 +48,112 @@ object SearchHelper { } /** - * Utility method to perform actions if item is found or not after an auto search in [database] + * Get the concrete web domain AKA without sub domain if needed */ - fun checkAutoSearchInfo(context: Context, - database: ContextualDatabase?, - searchInfo: SearchInfo?, - onItemsFound: (openedDatabase: ContextualDatabase, - items: List) -> Unit, - onItemNotFound: (openedDatabase: ContextualDatabase) -> Unit, - onDatabaseClosed: () -> Unit) { - if (database == null || !database.loaded) { - onDatabaseClosed.invoke() - } else if (TimeoutHelper.checkTime(context)) { - var searchWithoutUI = false - if (searchInfo != null - && !searchInfo.manualSelection - && !searchInfo.containsOnlyNullValues()) { - // If search provide results - database.createVirtualGroupFromSearchInfo( - searchInfoString = searchInfo.toString(), - searchInfoByDomain = searchInfo.isASearchByDomain(), - max = MAX_SEARCH_ENTRY - )?.let { searchGroup -> - if (searchGroup.numberOfChildEntries > 0) { - searchWithoutUI = true - onItemsFound.invoke(database, - searchGroup.getChildEntriesInfo(database)) + private fun getConcreteWebDomain( + context: Context, + webDomain: String?, + concreteWebDomain: (searchSubDomains: Boolean, concreteWebDomain: String?) -> Unit + ) { + val domain = webDomain + val searchSubDomains = searchSubDomains(context) + if (domain != null) { + // Warning, web domain can contains IP, don't crop in this case + if (searchSubDomains + || Regex(SearchInfo.WEB_IP_REGEX).matches(domain)) { + concreteWebDomain.invoke(searchSubDomains, webDomain) + } else { + CoroutineScope(Dispatchers.IO).launch { + val publicSuffixList = PublicSuffixList(context) + val publicSuffix = publicSuffixList + .getPublicSuffixPlusOne(domain).await() + withContext(Dispatchers.Main) { + concreteWebDomain.invoke(false, publicSuffix) } } } - if (!searchWithoutUI) { + } else { + concreteWebDomain.invoke(searchSubDomains, null) + } + } + + /** + * Create search parameters asynchronously from [SearchInfo] + */ + fun SearchInfo.getSearchParametersFromSearchInfo( + context: Context, + callback: (SearchParameters) -> Unit + ) { + getConcreteWebDomain( + context, + webDomain + ) { searchSubDomains, concreteDomain -> + var query = this.toString() + if (isDomainSearch && concreteDomain != null) + query = concreteDomain + callback.invoke( + SearchParameters().apply { + searchQuery = query + allowEmptyQuery = false + searchInTitles = false + searchInUsernames = false + searchInPasswords = false + searchInAppIds = isAppIdSearch + searchInUrls = isDomainSearch + searchByDomain = true + searchBySubDomain = searchSubDomains + searchInRelyingParty = isPasskeySearch + searchInNotes = false + searchInOTP = isOTPSearch + searchInOther = false + searchInUUIDs = false + searchInTags = isTagSearch + searchInCurrentGroup = false + searchInSearchableGroup = true + searchInRecycleBin = false + searchInTemplates = false + } + ) + } + } + + /** + * Utility method to perform actions if item is found or not after an auto search in [database] + */ + fun checkAutoSearchInfo( + context: Context, + database: ContextualDatabase?, + searchInfo: SearchInfo?, + onItemsFound: (openedDatabase: ContextualDatabase, + items: List) -> Unit, + onItemNotFound: (openedDatabase: ContextualDatabase) -> Unit, + onDatabaseClosed: () -> Unit + ) { + // Do not place coroutine at start, bug in Passkey implementation + if (database == null || !database.loaded) { + onDatabaseClosed.invoke() + } else if (TimeoutHelper.checkTime(context)) { + if (searchInfo != null + && !searchInfo.manualSelection + && !searchInfo.containsOnlyNullValues() + ) { + searchInfo.getSearchParametersFromSearchInfo(context) { searchParameters -> + // If search provide results + database.createVirtualGroupFromSearchInfo( + searchParameters = searchParameters, + max = MAX_SEARCH_ENTRY + )?.let { searchGroup -> + if (searchGroup.numberOfChildEntries > 0) { + onItemsFound.invoke( + database, + searchGroup.getChildEntriesInfo(database) + ) + } else + onItemNotFound.invoke(database) + } ?: onItemNotFound.invoke(database) + } + } else onItemNotFound.invoke(database) - } } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/hardware/HardwareKeyActivity.kt b/app/src/main/java/com/kunzisoft/keepass/hardware/HardwareKeyActivity.kt deleted file mode 100644 index 77d802025..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/hardware/HardwareKeyActivity.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.kunzisoft.keepass.hardware - -import android.app.Activity -import android.content.Context -import android.content.DialogInterface -import android.content.Intent -import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.util.Log -import androidx.activity.result.ActivityResult -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity -import com.kunzisoft.keepass.database.ContextualDatabase -import com.kunzisoft.keepass.utils.UriUtil.openExternalApp - -/** - * Special activity to deal with hardware key drivers, - * return the response to the database service once finished - */ -class HardwareKeyActivity: DatabaseModeActivity(){ - - // To manage hardware key challenge response - private val resultCallback = ActivityResultCallback { result -> - if (result.resultCode == Activity.RESULT_OK) { - val challengeResponse: ByteArray? = result.data?.getByteArrayExtra(HARDWARE_KEY_RESPONSE_KEY) - Log.d(TAG, "Response form challenge") - mDatabaseTaskProvider?.startChallengeResponded(challengeResponse ?: ByteArray(0)) - } else { - Log.e(TAG, "Response from challenge error") - mDatabaseTaskProvider?.startChallengeResponded(ByteArray(0)) - } - finish() - } - - private var activityResultLauncher: ActivityResultLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), - resultCallback - ) - - override fun applyCustomStyle(): Boolean { - return false - } - - override fun showDatabaseDialog(): Boolean { - return false - } - - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - - val hardwareKey = HardwareKey.getHardwareKeyFromString( - intent.getStringExtra(DATA_HARDWARE_KEY) - ) - if (isHardwareKeyAvailable(this, hardwareKey, true) { - mDatabaseTaskProvider?.startChallengeResponded(ByteArray(0)) - }) { - when (hardwareKey) { - /* - HardwareKey.FIDO2_SECRET -> { - // TODO FIDO2 under development - throw Exception("FIDO2 not implemented") - } - */ - HardwareKey.CHALLENGE_RESPONSE_YUBIKEY -> { - launchYubikeyChallengeForResponse(intent.getByteArrayExtra(DATA_SEED)) - } - else -> { - finish() - } - } - } - } - - private fun launchYubikeyChallengeForResponse(seed: ByteArray?) { - // Transform the seed before sending - var challenge: ByteArray? = null - if (seed != null) { - challenge = ByteArray(64) - seed.copyInto(challenge, 0, 0, 32) - challenge.fill(32, 32, 64) - } - // Send to the driver - activityResultLauncher.launch( - Intent(YUBIKEY_CHALLENGE_RESPONSE_INTENT).apply { - putExtra(HARDWARE_KEY_CHALLENGE_KEY, challenge) - } - ) - Log.d(TAG, "Challenge sent") - } - - companion object { - private val TAG = HardwareKeyActivity::class.java.simpleName - - private const val DATA_HARDWARE_KEY = "DATA_HARDWARE_KEY" - private const val DATA_SEED = "DATA_SEED" - private const val YUBIKEY_CHALLENGE_RESPONSE_INTENT = "android.yubikey.intent.action.CHALLENGE_RESPONSE" - private const val HARDWARE_KEY_CHALLENGE_KEY = "challenge" - private const val HARDWARE_KEY_RESPONSE_KEY = "response" - - fun launchHardwareKeyActivity( - context: Context, - hardwareKey: HardwareKey, - seed: ByteArray? - ) { - context.startActivity(Intent(context, HardwareKeyActivity::class.java).apply { - flags = FLAG_ACTIVITY_NEW_TASK - putExtra(DATA_HARDWARE_KEY, hardwareKey.value) - putExtra(DATA_SEED, seed) - }) - } - - fun isHardwareKeyAvailable( - context: Context, - hardwareKey: HardwareKey?, - showDialog: Boolean = true, - onDialogDismissed: DialogInterface.OnDismissListener? = null - ): Boolean { - if (hardwareKey == null) - return false - return when (hardwareKey) { - /* - HardwareKey.FIDO2_SECRET -> { - // TODO FIDO2 under development - if (showDialog) - UnderDevelopmentFeatureDialogFragment() - .show(activity.supportFragmentManager, "underDevFeatureDialog") - false - } - */ - HardwareKey.CHALLENGE_RESPONSE_YUBIKEY -> { - // Check available intent - val yubikeyDriverAvailable = - Intent(YUBIKEY_CHALLENGE_RESPONSE_INTENT) - .resolveActivity(context.packageManager) != null - if (showDialog && !yubikeyDriverAvailable - && context is Activity) - showHardwareKeyDriverNeeded(context, hardwareKey) { - onDialogDismissed?.onDismiss(it) - context.finish() - } - yubikeyDriverAvailable - } - } - } - - private fun showHardwareKeyDriverNeeded( - context: Context, - hardwareKey: HardwareKey, - onDialogDismissed: DialogInterface.OnDismissListener - ) { - val builder = AlertDialog.Builder(context) - builder - .setMessage( - context.getString(R.string.error_driver_required, hardwareKey.toString()) - ) - .setPositiveButton(R.string.download) { _, _ -> - context.openExternalApp( - context.getString(R.string.key_driver_app_id), - context.getString(R.string.key_driver_url) - ) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .setOnDismissListener(onDialogDismissed) - builder.create().show() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/hardware/HardwareKeyResponseHelper.kt b/app/src/main/java/com/kunzisoft/keepass/hardware/HardwareKeyResponseHelper.kt deleted file mode 100644 index 5f00e2138..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/hardware/HardwareKeyResponseHelper.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.kunzisoft.keepass.hardware - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.util.Log -import androidx.activity.result.ActivityResult -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope -import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.utils.UriUtil.openExternalApp -import kotlinx.coroutines.launch - -class HardwareKeyResponseHelper { - - private var activity: FragmentActivity? = null - private var fragment: Fragment? = null - - private var getChallengeResponseResultLauncher: ActivityResultLauncher? = null - - constructor(context: FragmentActivity) { - this.activity = context - this.fragment = null - } - - constructor(context: Fragment) { - this.activity = context.activity - this.fragment = context - } - - fun buildHardwareKeyResponse(onChallengeResponded: (challengeResponse: ByteArray?, - extra: Bundle?) -> Unit) { - val resultCallback = ActivityResultCallback { result -> - if (result.resultCode == Activity.RESULT_OK) { - val challengeResponse: ByteArray? = result.data?.getByteArrayExtra(HARDWARE_KEY_RESPONSE_KEY) - Log.d(TAG, "Response form challenge") - onChallengeResponded.invoke(challengeResponse, - result.data?.getBundleExtra(EXTRA_BUNDLE_KEY)) - } else { - Log.e(TAG, "Response from challenge error") - onChallengeResponded.invoke(null, - result.data?.getBundleExtra(EXTRA_BUNDLE_KEY)) - } - } - - getChallengeResponseResultLauncher = if (fragment != null) { - fragment?.registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), - resultCallback - ) - } else { - activity?.registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), - resultCallback - ) - } - } - - fun launchChallengeForResponse(hardwareKey: HardwareKey, seed: ByteArray?) { - when (hardwareKey) { - /* - HardwareKey.FIDO2_SECRET -> { - // TODO FIDO2 under development - throw Exception("FIDO2 not implemented") - } - */ - HardwareKey.CHALLENGE_RESPONSE_YUBIKEY -> { - // Transform the seed before sending - var challenge: ByteArray? = null - if (seed != null) { - challenge = ByteArray(64) - seed.copyInto(challenge, 0, 0, 32) - challenge.fill(32, 32, 64) - } - // Send to the driver - getChallengeResponseResultLauncher!!.launch( - Intent(YUBIKEY_CHALLENGE_RESPONSE_INTENT).apply { - putExtra(HARDWARE_KEY_CHALLENGE_KEY, challenge) - } - ) - Log.d(TAG, "Challenge sent") - } - } - } - - companion object { - private val TAG = HardwareKeyResponseHelper::class.java.simpleName - - private const val YUBIKEY_CHALLENGE_RESPONSE_INTENT = "android.yubikey.intent.action.CHALLENGE_RESPONSE" - private const val HARDWARE_KEY_CHALLENGE_KEY = "challenge" - private const val HARDWARE_KEY_RESPONSE_KEY = "response" - private const val EXTRA_BUNDLE_KEY = "EXTRA_BUNDLE_KEY" - - fun isHardwareKeyAvailable( - activity: FragmentActivity, - hardwareKey: HardwareKey, - showDialog: Boolean = true - ): Boolean { - return when (hardwareKey) { - /* - HardwareKey.FIDO2_SECRET -> { - // TODO FIDO2 under development - if (showDialog) - UnderDevelopmentFeatureDialogFragment() - .show(activity.supportFragmentManager, "underDevFeatureDialog") - false - } - */ - HardwareKey.CHALLENGE_RESPONSE_YUBIKEY -> { - // Check available intent - val yubikeyDriverAvailable = - Intent(YUBIKEY_CHALLENGE_RESPONSE_INTENT) - .resolveActivity(activity.packageManager) != null - if (showDialog && !yubikeyDriverAvailable) - showHardwareKeyDriverNeeded(activity, hardwareKey) - yubikeyDriverAvailable - } - } - } - - private fun showHardwareKeyDriverNeeded( - activity: FragmentActivity, - hardwareKey: HardwareKey - ) { - activity.lifecycleScope.launch { - val builder = AlertDialog.Builder(activity) - builder - .setMessage( - activity.getString(R.string.error_driver_required, hardwareKey.toString()) - ) - .setPositiveButton(R.string.download) { _, _ -> - activity.openExternalApp(activity.getString(R.string.key_driver_app_id)) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - builder.create().show() - } - } - } -} diff --git a/app/src/main/java/com/kunzisoft/keepass/password/PasswordGenerator.kt b/app/src/main/java/com/kunzisoft/keepass/password/PasswordGenerator.kt index 692f33557..3a1355768 100644 --- a/app/src/main/java/com/kunzisoft/keepass/password/PasswordGenerator.kt +++ b/app/src/main/java/com/kunzisoft/keepass/password/PasswordGenerator.kt @@ -33,20 +33,22 @@ import java.util.* class PasswordGenerator(private val resources: Resources) { @Throws(IllegalArgumentException::class) - fun generatePassword(length: Int, - upperCase: Boolean, - lowerCase: Boolean, - digits: Boolean, - minus: Boolean, - underline: Boolean, - space: Boolean, - specials: Boolean, - brackets: Boolean, - extended: Boolean, - considerChars: String, - ignoreChars: String, - atLeastOneFromEach: Boolean, - excludeAmbiguousChar: Boolean): String { + fun generatePassword( + length: Int, + upperCase: Boolean, + lowerCase: Boolean, + digits: Boolean, + minus: Boolean, + underline: Boolean, + space: Boolean, + specials: Boolean, + brackets: Boolean, + extended: Boolean, + considerChars: String, + ignoreChars: String, + atLeastOneFromEach: Boolean, + excludeAmbiguousChar: Boolean + ): String { // Desired password length is 0 or less if (length <= 0) { throw IllegalArgumentException(resources.getString(R.string.error_wrong_length)) @@ -228,7 +230,7 @@ class PasswordGenerator(private val resources: Resources) { private const val MINUS_CHAR = "-" private const val UNDERLINE_CHAR = "_" private const val SPACE_CHAR = " " - private const val SPECIAL_CHARS = "!\"#$%&'*+,./:;=?@\\^`" + private const val SPECIAL_CHARS = "&/,^@.#:%\\='$!?*`;+\"|~" private const val BRACKET_CHARS = "[]{}()<>" private const val AMBIGUOUS_CHARS = "iI|lLoO01" diff --git a/app/src/main/java/com/kunzisoft/keepass/receivers/DexModeReceiver.kt b/app/src/main/java/com/kunzisoft/keepass/receivers/DexModeReceiver.kt index 7844c4251..c2c4c5334 100644 --- a/app/src/main/java/com/kunzisoft/keepass/receivers/DexModeReceiver.kt +++ b/app/src/main/java/com/kunzisoft/keepass/receivers/DexModeReceiver.kt @@ -1,13 +1,9 @@ package com.kunzisoft.keepass.receivers import android.content.BroadcastReceiver -import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.util.Log -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService -import com.kunzisoft.keepass.utils.DexUtil import com.kunzisoft.keepass.utils.MagikeyboardUtil class DexModeReceiver : BroadcastReceiver() { diff --git a/app/src/main/java/com/kunzisoft/keepass/services/AttachmentFileNotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/services/AttachmentFileNotificationService.kt index 974db311c..00fa14a9e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/services/AttachmentFileNotificationService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/services/AttachmentFileNotificationService.kt @@ -36,6 +36,7 @@ import com.kunzisoft.keepass.model.AttachmentState import com.kunzisoft.keepass.model.EntryAttachmentState import com.kunzisoft.keepass.model.StreamDirection import com.kunzisoft.keepass.tasks.BinaryDatabaseManager +import com.kunzisoft.keepass.utils.AppUtil.randomRequestCode import com.kunzisoft.keepass.utils.UriUtil.getDocumentFile import com.kunzisoft.keepass.utils.getParcelableExtraCompat import kotlinx.coroutines.CoroutineScope @@ -194,7 +195,7 @@ class AttachmentFileNotificationService: LockNotificationService() { private fun newNotification(attachmentNotification: AttachmentNotification) { val pendingContentIntent = PendingIntent.getActivity(this, - 0, + randomRequestCode(), Intent().apply { action = Intent.ACTION_VIEW setDataAndType(attachmentNotification.uri, @@ -208,7 +209,7 @@ class AttachmentFileNotificationService: LockNotificationService() { ) val pendingDeleteIntent = PendingIntent.getService(this, - 0, + randomRequestCode(), Intent(this, AttachmentFileNotificationService::class.java).apply { // No action to delete the service putExtra(FILE_URI_KEY, attachmentNotification.uri) diff --git a/app/src/main/java/com/kunzisoft/keepass/services/ClipboardEntryNotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/services/ClipboardEntryNotificationService.kt index b33213891..98cf199c6 100644 --- a/app/src/main/java/com/kunzisoft/keepass/services/ClipboardEntryNotificationService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/services/ClipboardEntryNotificationService.kt @@ -274,10 +274,12 @@ class ClipboardEntryNotificationService : LockNotificationService() { val containsPasswordToCopy = entry.password.isNotEmpty() && PreferencesUtil.allowCopyProtectedFields(context) val containsOTPToCopy = entry.containsCustomField(OTP_TOKEN_FIELD) - val containsExtraFieldToCopy = entry.customFields.isNotEmpty() - && (entry.containsCustomFieldsNotProtected() + val customFields = entry.getCustomFieldsForFilling() + val containsExtraFieldToCopy = customFields.isNotEmpty() + && (customFields.any { !it.protectedValue.isProtected } || - (entry.containsCustomFieldsProtected() && PreferencesUtil.allowCopyProtectedFields(context)) + (customFields.any { it.protectedValue.isProtected } + && PreferencesUtil.allowCopyProtectedFields(context)) ) var startService = false @@ -320,7 +322,7 @@ class ClipboardEntryNotificationService : LockNotificationService() { if (containsExtraFieldToCopy) { try { var anonymousFieldNumber = 0 - entry.customFields.forEach { field -> + entry.getCustomFieldsForFilling().forEach { field -> //If value is not protected or allowed if ((!field.protectedValue.isProtected || PreferencesUtil.allowCopyProtectedFields(context)) diff --git a/app/src/main/java/com/kunzisoft/keepass/services/DatabaseTaskNotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/services/DatabaseTaskNotificationService.kt index 9d6c0baf3..b42b07f27 100644 --- a/app/src/main/java/com/kunzisoft/keepass/services/DatabaseTaskNotificationService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/services/DatabaseTaskNotificationService.kt @@ -61,13 +61,14 @@ 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.hardware.HardwareKey -import com.kunzisoft.keepass.hardware.HardwareKeyActivity +import com.kunzisoft.keepass.credentialprovider.activity.HardwareKeyActivity import com.kunzisoft.keepass.model.CipherEncryptDatabase import com.kunzisoft.keepass.model.SnapFileDatabaseInfo import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.tasks.ActionRunnable import com.kunzisoft.keepass.tasks.ProgressTaskUpdater import com.kunzisoft.keepass.timeout.TimeoutHelper +import com.kunzisoft.keepass.utils.AppUtil.randomRequestCode 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 @@ -175,7 +176,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress progressMessage: ProgressMessage ) fun onActionStopped( - database: ContextualDatabase + database: ContextualDatabase? = null ) fun onActionFinished( database: ContextualDatabase, @@ -218,7 +219,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress Log.i(TAG, "Database file modified " + "$previousDatabaseInfo != $lastFileDatabaseInfo ") // Call listener to indicate a change in database info - if (!mSaveState && previousDatabaseInfo != null) { + if (!mSaveState) { mDatabaseInfoListeners.forEach { listener -> listener.onDatabaseInfoChanged( previousDatabaseInfo, @@ -550,7 +551,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress // Build Intents for notification action val pendingDatabaseIntent = PendingIntent.getActivity( this, - 0, + randomRequestCode(), Intent(this, GroupActivity::class.java), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT @@ -675,6 +676,12 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress override fun actionOnLock() { if (!TimeoutHelper.temporarilyDisableLock) { closeDatabase(mDatabase) + // Remove the database during the lock + // And notify each subscriber + mDatabase = null + mDatabaseListeners.forEach { listener -> + listener.onDatabaseRetrieved(null) + } // Remove the lock timer (no more needed if it exists) TimeoutHelper.cancelLockTimer(this) // Service is stopped after receive the broadcast @@ -709,9 +716,9 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress notifyProgressMessage() HardwareKeyActivity .launchHardwareKeyActivity( - this@DatabaseTaskNotificationService, - hardwareKey, - seed + context = this@DatabaseTaskNotificationService, + hardwareKey = hardwareKey, + seed = seed ) // Wait the response mProgressMessage.apply { @@ -1385,6 +1392,15 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress return nodesAction } + fun Bundle.getNewEntry(database: ContextualDatabase): Entry? { + getBundle(NEW_NODES_KEY) + ?.getParcelableList>(ENTRIES_ID_KEY) + ?.get(0)?.let { + return database.getEntryById(it) + } + return null + } + fun getBundleFromListNodes(nodes: List): Bundle { val groupsId = mutableListOf>() val entriesId = mutableListOf>() diff --git a/app/src/main/java/com/kunzisoft/keepass/services/KeyboardEntryNotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/services/KeyboardEntryNotificationService.kt index 9986b304a..557f00cd7 100644 --- a/app/src/main/java/com/kunzisoft/keepass/services/KeyboardEntryNotificationService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/services/KeyboardEntryNotificationService.kt @@ -27,7 +27,7 @@ import android.util.Log import android.widget.Toast import androidx.preference.PreferenceManager import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.timeout.TimeoutHelper diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsActivity.kt b/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsActivity.kt index 048e06b7a..3d53116a3 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsActivity.kt @@ -19,9 +19,12 @@ */ package com.kunzisoft.keepass.settings +import android.os.Build +import androidx.annotation.RequiresApi import androidx.preference.PreferenceFragmentCompat import com.kunzisoft.keepass.R +@RequiresApi(Build.VERSION_CODES.O) class AutofillSettingsActivity : ExternalSettingsActivity() { override fun retrieveTitle(): Int { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsFragment.kt index abd8445e9..6e0beb5d8 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsFragment.kt @@ -21,6 +21,7 @@ package com.kunzisoft.keepass.settings import android.os.Build import android.os.Bundle +import androidx.annotation.RequiresApi import androidx.fragment.app.DialogFragment import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat @@ -29,6 +30,7 @@ import com.kunzisoft.keepass.R import com.kunzisoft.keepass.settings.preferencedialogfragment.AutofillBlocklistAppIdPreferenceDialogFragmentCompat import com.kunzisoft.keepass.settings.preferencedialogfragment.AutofillBlocklistWebDomainPreferenceDialogFragmentCompat +@RequiresApi(Build.VERSION_CODES.O) class AutofillSettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -39,11 +41,14 @@ class AutofillSettingsFragment : PreferenceFragmentCompat() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { autofillInlineSuggestionsPreference?.isVisible = false } + + val autofillAskSaveDataPreference: TwoStatePreference? = findPreference(getString(R.string.autofill_ask_to_save_data_key)) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + autofillAskSaveDataPreference?.isVisible = false + } } override fun onDisplayPreferenceDialog(preference: Preference) { - var otherDialogFragment = false - var dialogFragment: DialogFragment? = null when (preference.key) { @@ -53,7 +58,7 @@ class AutofillSettingsFragment : PreferenceFragmentCompat() { getString(R.string.autofill_web_domain_blocklist_key) -> { dialogFragment = AutofillBlocklistWebDomainPreferenceDialogFragmentCompat.newInstance(preference.key) } - else -> otherDialogFragment = true + else -> {} } if (dialogFragment != null) { @@ -62,7 +67,7 @@ class AutofillSettingsFragment : PreferenceFragmentCompat() { dialogFragment.show(parentFragmentManager, TAG_AUTOFILL_PREF_FRAGMENT) } // Could not be handled here. Try with the super method. - else if (otherDialogFragment) { + else { super.onDisplayPreferenceDialog(preference) } } diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/MainPreferenceFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/MainPreferenceFragment.kt index 9fa58d188..4f95e03ac 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/MainPreferenceFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/MainPreferenceFragment.kt @@ -21,20 +21,25 @@ package com.kunzisoft.keepass.settings import android.content.Context import android.os.Bundle -import android.view.View import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.viewmodels.DatabaseViewModel +import kotlinx.coroutines.launch class MainPreferenceFragment : PreferenceFragmentCompat() { private var mCallback: Callback? = null private val mDatabaseViewModel: DatabaseViewModel by activityViewModels() - private var mDatabaseLoaded: Boolean = false + private val mDatabase: ContextualDatabase? + get() = mDatabaseViewModel.database override fun onAttach(context: Context) { super.onAttach(context) @@ -50,20 +55,24 @@ class MainPreferenceFragment : PreferenceFragmentCompat() { mCallback = null super.onDetach() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - mDatabaseViewModel.database.observe(viewLifecycleOwner) { database -> - mDatabaseLoaded = database?.loaded == true - checkDatabaseLoaded() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + mDatabaseViewModel.databaseState.collect { database -> + checkDatabaseLoaded(database?.loaded == true) + } + } } - super.onViewCreated(view, savedInstanceState) } - private fun checkDatabaseLoaded() { + private fun checkDatabaseLoaded(isDatabaseLoaded: Boolean) { findPreference(getString(R.string.settings_database_key)) - ?.isEnabled = mDatabaseLoaded + ?.isEnabled = isDatabaseLoaded findPreference(getString(R.string.settings_database_category_key)) - ?.isVisible = mDatabaseLoaded + ?.isVisible = isDatabaseLoaded } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -119,7 +128,7 @@ class MainPreferenceFragment : PreferenceFragmentCompat() { } } - checkDatabaseLoaded() + checkDatabaseLoaded(mDatabase?.loaded == true) } interface Callback { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt index e22a6d042..7512a27d8 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt @@ -21,7 +21,6 @@ package com.kunzisoft.keepass.settings import android.content.ActivityNotFoundException import android.content.Intent -import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings @@ -30,6 +29,7 @@ import android.view.autofill.AutofillManager import android.widget.Toast import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentActivity import androidx.preference.ListPreference @@ -47,7 +47,7 @@ import com.kunzisoft.keepass.icons.IconPackChooser import com.kunzisoft.keepass.services.ClipboardEntryNotificationService import com.kunzisoft.keepass.settings.preference.IconPackListPreference import com.kunzisoft.keepass.settings.preferencedialogfragment.DurationDialogFragmentCompat -import com.kunzisoft.keepass.utils.UriUtil.isContributingUser +import com.kunzisoft.keepass.utils.AppUtil.isContributingUser import com.kunzisoft.keepass.utils.UriUtil.openUrl import com.kunzisoft.keepass.utils.UriUtil.releaseAllUnnecessaryPermissionUris @@ -119,7 +119,16 @@ class NestedAppSettingsFragment : NestedSettingsFragment() { activity?.let { activity -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val autoFillEnablePreference: TwoStatePreference? = findPreference(getString(R.string.settings_autofill_enable_key)) + + // Hide Passkeys settings if needed + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + findPreference(getString(R.string.passkeys_explanation_key)) + ?.isVisible = false + findPreference(getString(R.string.settings_passkeys_key)) + ?.isVisible = false + } + + val autoFillEnablePreference: TwoStatePreference? = findPreference(getString(R.string.settings_credential_provider_enable_key)) activity.getSystemService(AutofillManager::class.java)?.let { autofillManager -> if (autofillManager.hasEnabledAutofillServices()) autoFillEnablePreference?.isChecked = autofillManager.hasEnabledAutofillServices() @@ -161,7 +170,7 @@ class NestedAppSettingsFragment : NestedSettingsFragment() { val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE) intent.data = - Uri.parse("package:com.kunzisoft.keepass.autofill.KeeAutofillService") + "package:com.kunzisoft.keepass.autofill.KeeAutofillService".toUri() Log.d(javaClass.name, "Autofill enable service: intent=$intent") startActivity(intent) } else { @@ -171,7 +180,7 @@ class NestedAppSettingsFragment : NestedSettingsFragment() { } } } else { - findPreference(getString(R.string.autofill_key))?.isVisible = false + findPreference(getString(R.string.credential_provider_key))?.isVisible = false } } @@ -192,14 +201,28 @@ class NestedAppSettingsFragment : NestedSettingsFragment() { false } - findPreference(getString(R.string.autofill_explanation_key))?.setOnPreferenceClickListener { - context?.openUrl(R.string.autofill_explanation_url) - false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + findPreference(getString(R.string.passkeys_explanation_key))?.setOnPreferenceClickListener { + context?.openUrl(R.string.passkeys_explanation_url) + false + } + + findPreference(getString(R.string.settings_passkeys_key))?.setOnPreferenceClickListener { + startActivity(Intent(context, PasskeysSettingsActivity::class.java)) + false + } } - findPreference(getString(R.string.settings_autofill_key))?.setOnPreferenceClickListener { - startActivity(Intent(context, AutofillSettingsActivity::class.java)) - false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + findPreference(getString(R.string.autofill_explanation_key))?.setOnPreferenceClickListener { + context?.openUrl(R.string.autofill_explanation_url) + false + } + + findPreference(getString(R.string.settings_autofill_key))?.setOnPreferenceClickListener { + startActivity(Intent(context, AutofillSettingsActivity::class.java)) + false + } } findPreference(getString(R.string.clipboard_notifications_key))?.setOnPreferenceChangeListener { _, newValue -> @@ -530,7 +553,7 @@ class NestedAppSettingsFragment : NestedSettingsFragment() { super.onResume() activity?.let { activity -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - findPreference(getString(R.string.settings_autofill_enable_key))?.let { autoFillEnablePreference -> + findPreference(getString(R.string.settings_credential_provider_enable_key))?.let { autoFillEnablePreference -> val autofillManager = activity.getSystemService(AutofillManager::class.java) autoFillEnablePreference.isChecked = autofillManager != null && autofillManager.hasEnabledAutofillServices() diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/NestedDatabaseSettingsFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/NestedDatabaseSettingsFragment.kt index 080ece82f..8cb5821e9 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/NestedDatabaseSettingsFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/NestedDatabaseSettingsFragment.kt @@ -19,13 +19,21 @@ */ package com.kunzisoft.keepass.settings -import android.graphics.Color import android.os.Bundle import android.util.Log -import android.view.* +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.core.graphics.toColorInt import androidx.core.view.MenuProvider import androidx.fragment.app.DialogFragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.TwoStatePreference @@ -39,19 +47,40 @@ import com.kunzisoft.keepass.database.crypto.EncryptionAlgorithm import com.kunzisoft.keepass.database.crypto.kdf.KdfEngine import com.kunzisoft.keepass.database.element.Group import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm -import com.kunzisoft.keepass.database.helper.* +import com.kunzisoft.keepass.database.helper.getLocalizedName import com.kunzisoft.keepass.services.DatabaseTaskNotificationService -import com.kunzisoft.keepass.settings.preference.* -import com.kunzisoft.keepass.settings.preferencedialogfragment.* +import com.kunzisoft.keepass.settings.preference.DialogColorPreference +import com.kunzisoft.keepass.settings.preference.DialogListExplanationPreference +import com.kunzisoft.keepass.settings.preference.InputKdfNumberPreference +import com.kunzisoft.keepass.settings.preference.InputKdfSizePreference +import com.kunzisoft.keepass.settings.preference.InputNumberPreference +import com.kunzisoft.keepass.settings.preference.InputTextPreference +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseColorPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseDataCompressionPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseDefaultUsernamePreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseDescriptionPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseEncryptionAlgorithmPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseKeyDerivationPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseMaxHistoryItemsPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseMaxHistorySizePreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseMemoryUsagePreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseNamePreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseParallelismPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseRecycleBinGroupPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseRemoveUnlinkedDataPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseRoundsPreferenceDialogFragmentCompat +import com.kunzisoft.keepass.settings.preferencedialogfragment.DatabaseTemplatesGroupPreferenceDialogFragmentCompat import com.kunzisoft.keepass.tasks.ActionRunnable import com.kunzisoft.keepass.utils.getParcelableCompat import com.kunzisoft.keepass.utils.getSerializableCompat import com.kunzisoft.keepass.viewmodels.DatabaseViewModel +import kotlinx.coroutines.launch class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetrieval { private val mDatabaseViewModel: DatabaseViewModel by activityViewModels() - private var mDatabase: ContextualDatabase? = null + private val mDatabase: ContextualDatabase? + get() = mDatabaseViewModel.database private var mDatabaseReadOnly: Boolean = false private var mMergeDataAllowed: Boolean = false private var mDatabaseAutoSaveEnabled: Boolean = true @@ -114,19 +143,46 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mDatabaseViewModel.actionState.collect { uiState -> + when (uiState) { + is DatabaseViewModel.ActionState.OnDatabaseActionFinished -> { + onDatabaseActionFinished( + uiState.database, + uiState.actionTask, + uiState.result + ) + } + + else -> {} + } + } + } + } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + mDatabaseViewModel.databaseState.collect { database -> + database?.let { + onDatabaseRetrieved(database) + } + } + } + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - activity?.addMenuProvider(menuProvider, viewLifecycleOwner) - - mDatabaseViewModel.database.observe(viewLifecycleOwner) { database -> - mDatabase = database - view.resetAppTimeoutWhenViewTouchedOrFocused(requireContext(), database?.loaded) - onDatabaseRetrieved(database) - } - - mDatabaseViewModel.actionFinished.observe(viewLifecycleOwner) { - onDatabaseActionFinished(it.database, it.actionTask, it.result) + viewLifecycleOwner.lifecycleScope.launch { + mDatabaseViewModel.databaseState.collect { database -> + view.resetAppTimeoutWhenViewTouchedOrFocused( + context = requireContext(), + databaseLoaded = database?.loaded + ) + } } } @@ -167,29 +223,26 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev mDatabaseViewModel.reloadDatabase(false) } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - mDatabase = database - mDatabaseReadOnly = database?.isReadOnly == true - mMergeDataAllowed = database?.isMergeDataAllowed() == true + override fun onDatabaseRetrieved(database: ContextualDatabase) { + mDatabaseReadOnly = database.isReadOnly + mMergeDataAllowed = database.isMergeDataAllowed() - mDatabase?.let { - if (it.loaded) { - when (mScreen) { - Screen.DATABASE -> { - onCreateDatabasePreference(it) - } - Screen.DATABASE_SECURITY -> { - onCreateDatabaseSecurityPreference(it) - } - Screen.DATABASE_MASTER_KEY -> { - onCreateDatabaseMasterKeyPreference(it) - } - else -> { - } + if (database.loaded) { + when (mScreen) { + Screen.DATABASE -> { + onCreateDatabasePreference(database) + } + Screen.DATABASE_SECURITY -> { + onCreateDatabaseSecurityPreference(database) + } + Screen.DATABASE_MASTER_KEY -> { + onCreateDatabaseMasterKeyPreference(database) + } + else -> { } - } else { - Log.e(javaClass.name, "Database isn't ready") } + } else { + Log.e(javaClass.name, "Database isn't ready") } } @@ -458,7 +511,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newDefaultUsername } else { - mDatabase?.defaultUsername = oldDefaultUsername + database.defaultUsername = oldDefaultUsername oldDefaultUsername } dbDefaultUsernamePref?.summary = defaultUsernameToShow @@ -471,7 +524,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newColor } else { - mDatabase?.customColor = Color.parseColor(oldColor) + database.customColor = oldColor.toColorInt() oldColor } dbCustomColorPref?.summary = defaultColorToShow @@ -483,7 +536,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newCompression } else { - mDatabase?.compressionAlgorithm = oldCompression + database.compressionAlgorithm = oldCompression oldCompression } dbDataCompressionPref?.summary = algorithmToShow?.getLocalizedName(resources) @@ -497,7 +550,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev } else { oldRecycleBin } - mDatabase?.setRecycleBin(recycleBinToShow) + database.setRecycleBin(recycleBinToShow) refreshRecycleBinGroup(database) } DatabaseTaskNotificationService.ACTION_DATABASE_UPDATE_TEMPLATES_GROUP_TASK -> { @@ -509,7 +562,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev } else { oldTemplatesGroup } - mDatabase?.setTemplatesGroup(templatesGroupToShow) + database.setTemplatesGroup(templatesGroupToShow) refreshTemplatesGroup(database) } DatabaseTaskNotificationService.ACTION_DATABASE_UPDATE_MAX_HISTORY_ITEMS_TASK -> { @@ -519,7 +572,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newMaxHistoryItems } else { - mDatabase?.historyMaxItems = oldMaxHistoryItems + database.historyMaxItems = oldMaxHistoryItems oldMaxHistoryItems } dbMaxHistoryItemsPref?.summary = maxHistoryItemsToShow.toString() @@ -531,7 +584,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newMaxHistorySize } else { - mDatabase?.historyMaxSize = oldMaxHistorySize + database.historyMaxSize = oldMaxHistorySize oldMaxHistorySize } dbMaxHistorySizePref?.summary = maxHistorySizeToShow.toString() @@ -549,7 +602,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newEncryption } else { - mDatabase?.encryptionAlgorithm = oldEncryption + database.encryptionAlgorithm = oldEncryption oldEncryption } mEncryptionAlgorithmPref?.summary = algorithmToShow.toString() @@ -561,7 +614,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newKeyDerivationEngine } else { - mDatabase?.kdfEngine = oldKeyDerivationEngine + database.kdfEngine = oldKeyDerivationEngine oldKeyDerivationEngine } mKeyDerivationPref?.summary = kdfEngineToShow.toString() @@ -578,7 +631,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newIterations } else { - mDatabase?.numberKeyEncryptionRounds = oldIterations + database.numberKeyEncryptionRounds = oldIterations oldIterations } mRoundPref?.summary = roundsToShow.toString() @@ -590,7 +643,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newMemoryUsage } else { - mDatabase?.memoryUsage = oldMemoryUsage + database.memoryUsage = oldMemoryUsage oldMemoryUsage } mMemoryPref?.summary = memoryToShow.toString() @@ -602,7 +655,7 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment(), DatabaseRetriev if (result.isSuccess) { newParallelism } else { - mDatabase?.parallelism = oldParallelism + database.parallelism = oldParallelism oldParallelism } mParallelismPref?.summary = parallelismToShow.toString() diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/PasskeysSettingsActivity.kt b/app/src/main/java/com/kunzisoft/keepass/settings/PasskeysSettingsActivity.kt new file mode 100644 index 000000000..3d7668a93 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/settings/PasskeysSettingsActivity.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.settings + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.preference.PreferenceFragmentCompat +import com.kunzisoft.keepass.R + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeysSettingsActivity : ExternalSettingsActivity() { + + override fun retrieveTitle(): Int { + return R.string.passkeys_preference_title + } + + override fun retrievePreferenceFragment(): PreferenceFragmentCompat { + return PasskeysSettingsFragment() + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/PasskeysSettingsFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/PasskeysSettingsFragment.kt new file mode 100644 index 000000000..4a9a6dec8 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/settings/PasskeysSettingsFragment.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.settings + +import android.os.Build +import android.os.Bundle +import androidx.annotation.RequiresApi +import androidx.fragment.app.DialogFragment +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.settings.preferencedialogfragment.PasskeysPrivilegedAppsPreferenceDialogFragmentCompat + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeysSettingsFragment : PreferenceFragmentCompat() { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + // Load the preferences from an XML resource + setPreferencesFromResource(R.xml.preferences_passkeys, rootKey) + } + + @Suppress("DEPRECATION") + override fun onDisplayPreferenceDialog(preference: Preference) { + var dialogFragment: DialogFragment? = null + + when (preference.key) { + getString(R.string.passkeys_privileged_apps_key) -> { + dialogFragment = PasskeysPrivilegedAppsPreferenceDialogFragmentCompat.newInstance(preference.key) + } + else -> {} + } + + if (dialogFragment != null) { + dialogFragment.setTargetFragment(this, 0) + dialogFragment.show(parentFragmentManager, TAG_PASSKEYS_PREF_FRAGMENT) + } else { + super.onDisplayPreferenceDialog(preference) + } + } + + companion object { + + private const val TAG_PASSKEYS_PREF_FRAGMENT = "TAG_PASSKEYS_PREF_FRAGMENT" + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt b/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt index 9bc6ae1c6..5604a4a6c 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt @@ -35,8 +35,8 @@ import com.kunzisoft.keepass.database.search.SearchParameters import com.kunzisoft.keepass.education.Education import com.kunzisoft.keepass.password.PassphraseGenerator import com.kunzisoft.keepass.timeout.TimeoutHelper +import com.kunzisoft.keepass.utils.AppUtil.isContributingUser import com.kunzisoft.keepass.utils.KeyboardUtil.isKeyboardActivatedInSettings -import com.kunzisoft.keepass.utils.UriUtil.isContributingUser import java.util.Properties object PreferencesUtil { @@ -108,7 +108,7 @@ object PreferencesUtil { context.resources.getBoolean(R.bool.auto_focus_search_default)) } - fun searchSubdomains(context: Context): Boolean { + fun searchSubDomains(context: Context): Boolean { val prefs = PreferenceManager.getDefaultSharedPreferences(context) return prefs.getBoolean(context.getString(R.string.subdomain_search_key), context.resources.getBoolean(R.bool.subdomain_search_default)) @@ -352,6 +352,8 @@ object PreferencesUtil { context.resources.getBoolean(R.bool.search_option_username_default)) searchInPasswords = prefs.getBoolean(context.getString(R.string.search_option_password_key), context.resources.getBoolean(R.bool.search_option_password_default)) + searchInAppIds = prefs.getBoolean(context.getString(R.string.search_option_application_id_key), + context.resources.getBoolean(R.bool.search_option_application_id_default)) searchInUrls = prefs.getBoolean(context.getString(R.string.search_option_url_key), context.resources.getBoolean(R.bool.search_option_url_default)) searchInExpired = prefs.getBoolean(context.getString(R.string.search_option_expired_key), @@ -389,6 +391,8 @@ object PreferencesUtil { searchParameters.searchInUsernames) putBoolean(context.getString(R.string.search_option_password_key), searchParameters.searchInPasswords) + putBoolean(context.getString(R.string.search_option_application_id_key), + searchParameters.searchInAppIds) putBoolean(context.getString(R.string.search_option_url_key), searchParameters.searchInUrls) putBoolean(context.getString(R.string.search_option_expired_key), @@ -686,6 +690,32 @@ object PreferencesUtil { context.resources.getBoolean(R.bool.keyboard_previous_lock_default)) } + fun isPasskeyCloseDatabaseEnable(context: Context): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return prefs.getBoolean(context.getString(R.string.passkeys_close_database_key), + context.resources.getBoolean(R.bool.passkeys_close_database_default)) + } + + fun isPasskeyBackupEligibilityEnable(context: Context): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return prefs.getBoolean(context.getString(R.string.passkeys_backup_eligibility_key), + context.resources.getBoolean(R.bool.passkeys_backup_eligibility_default)) + } + + fun isPasskeyAutoSelectEnable(context: Context): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return prefs.getBoolean(context.getString(R.string.passkeys_auto_select_key), + context.resources.getBoolean(R.bool.passkeys_auto_select_default)) + } + + fun isPasskeyBackupStateEnable(context: Context): Boolean { + if (!isPasskeyBackupEligibilityEnable(context)) + return false + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return prefs.getBoolean(context.getString(R.string.passkeys_backup_state_key), + context.resources.getBoolean(R.bool.passkeys_backup_state_default)) + } + fun isAutofillCloseDatabaseEnable(context: Context): Boolean { val prefs = PreferenceManager.getDefaultSharedPreferences(context) return prefs.getBoolean(context.getString(R.string.autofill_close_database_key), @@ -821,7 +851,7 @@ object PreferencesUtil { context.getString(R.string.clipboard_notifications_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.clear_clipboard_notification_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.clipboard_timeout_key) -> editor.putString(name, value.toLong().toString()) - context.getString(R.string.settings_autofill_enable_key) -> editor.putBoolean(name, value.toBoolean()) + context.getString(R.string.settings_credential_provider_enable_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.keyboard_notification_entry_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.keyboard_notification_entry_clear_close_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.keyboard_entry_timeout_key) -> editor.putString(name, value.toLong().toString()) @@ -834,6 +864,10 @@ object PreferencesUtil { context.getString(R.string.keyboard_previous_search_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.keyboard_previous_fill_in_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.keyboard_previous_lock_key) -> editor.putBoolean(name, value.toBoolean()) + context.getString(R.string.passkeys_close_database_key) -> editor.putBoolean(name, value.toBoolean()) + context.getString(R.string.passkeys_auto_select_key) -> editor.putBoolean(name, value.toBoolean()) + context.getString(R.string.passkeys_backup_eligibility_key) -> editor.putBoolean(name, value.toBoolean()) + context.getString(R.string.passkeys_backup_state_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.autofill_close_database_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.autofill_inline_suggestions_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.autofill_manual_selection_key) -> editor.putBoolean(name, value.toBoolean()) diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/SettingsActivity.kt b/app/src/main/java/com/kunzisoft/keepass/settings/SettingsActivity.kt index 7c996aac3..880a979ac 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/SettingsActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/SettingsActivity.kt @@ -159,10 +159,6 @@ open class SettingsActivity return coordinatorLayout } - override fun finishActivityIfDatabaseNotLoaded(): Boolean { - return false - } - override fun onDatabaseActionFinished( database: ContextualDatabase, actionTask: String, @@ -192,7 +188,7 @@ open class SettingsActivity } override fun onAssignKeyDialogPositiveClick(mainCredential: MainCredential) { - assignPassword(mainCredential) + assignMainCredential(mainCredential) } override fun onAssignKeyDialogNegativeClick(mainCredential: MainCredential) {} diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/DurationDialogPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/DurationDialogPreference.kt index 46f7a4e92..dfaaeb2f0 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/DurationDialogPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/DurationDialogPreference.kt @@ -52,6 +52,7 @@ class DurationDialogPreference @JvmOverloads constructor(context: Context, notifyChanged() } + @Deprecated(message = "") override fun onSetInitialValue(restorePersistedValue: Boolean, defaultValue: Any?) { if (restorePersistedValue) { mDuration = getPersistedString(mDuration.toString()).toLongOrNull() ?: mDuration diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseColorPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseColorPreferenceDialogFragmentCompat.kt index 516fff33e..6d6904cdc 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseColorPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseColorPreferenceDialogFragmentCompat.kt @@ -95,20 +95,16 @@ class DatabaseColorPreferenceDialogFragmentCompat : DatabaseSavePreferenceDialog return dialog } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - - database?.let { - var initColor = it.customColor - if (initColor != null) { - enableSwitchView.isChecked = true - } else { - enableSwitchView.isChecked = false - initColor = DEFAULT_COLOR - } - chromaColorView.currentColor = initColor - arguments?.putInt(ARG_INITIAL_COLOR, initColor) + override fun onDatabaseRetrieved(database: ContextualDatabase) { + var initColor = database.customColor + if (initColor != null) { + enableSwitchView.isChecked = true + } else { + enableSwitchView.isChecked = false + initColor = DEFAULT_COLOR } + chromaColorView.currentColor = initColor + arguments?.putInt(ARG_INITIAL_COLOR, initColor) } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDataCompressionPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDataCompressionPreferenceDialogFragmentCompat.kt index 98a34265b..a0511892d 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDataCompressionPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDataCompressionPreferenceDialogFragmentCompat.kt @@ -50,16 +50,14 @@ class DatabaseDataCompressionPreferenceDialogFragmentCompat } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) + override fun onDatabaseRetrieved(database: ContextualDatabase) { setExplanationText(R.string.database_data_compression_summary) - mRecyclerView?.adapter = mCompressionAdapter - - database?.let { - compressionSelected = it.compressionAlgorithm - mCompressionAdapter?.setItems(it.availableCompressionAlgorithms, compressionSelected) - } + compressionSelected = database.compressionAlgorithm + mCompressionAdapter?.setItems( + items = database.availableCompressionAlgorithms, + itemUsed = compressionSelected + ) } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDefaultUsernamePreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDefaultUsernamePreferenceDialogFragmentCompat.kt index 9f0f6d116..94c13a467 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDefaultUsernamePreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDefaultUsernamePreferenceDialogFragmentCompat.kt @@ -24,9 +24,8 @@ import com.kunzisoft.keepass.database.ContextualDatabase class DatabaseDefaultUsernamePreferenceDialogFragmentCompat : DatabaseSavePreferenceDialogFragmentCompat() { - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - inputText = database?.defaultUsername?: "" + override fun onDatabaseRetrieved(database: ContextualDatabase) { + inputText = database.defaultUsername } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDescriptionPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDescriptionPreferenceDialogFragmentCompat.kt index 496d14835..21337d600 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDescriptionPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseDescriptionPreferenceDialogFragmentCompat.kt @@ -24,9 +24,8 @@ import com.kunzisoft.keepass.database.ContextualDatabase class DatabaseDescriptionPreferenceDialogFragmentCompat : DatabaseSavePreferenceDialogFragmentCompat() { - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - inputText = database?.description ?: "" + override fun onDatabaseRetrieved(database: ContextualDatabase) { + inputText = database.description } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseEncryptionAlgorithmPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseEncryptionAlgorithmPreferenceDialogFragmentCompat.kt index 2f76bb07b..a0e82b50f 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseEncryptionAlgorithmPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseEncryptionAlgorithmPreferenceDialogFragmentCompat.kt @@ -51,12 +51,9 @@ class DatabaseEncryptionAlgorithmPreferenceDialogFragmentCompat } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - database?.let { - algorithmSelected = database.encryptionAlgorithm - mEncryptionAlgorithmAdapter?.setItems(database.availableEncryptionAlgorithms, algorithmSelected) - } + override fun onDatabaseRetrieved(database: ContextualDatabase) { + algorithmSelected = database.encryptionAlgorithm + mEncryptionAlgorithmAdapter?.setItems(database.availableEncryptionAlgorithms, algorithmSelected) } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseKeyDerivationPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseKeyDerivationPreferenceDialogFragmentCompat.kt index 12aac2292..0c7bbe7fc 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseKeyDerivationPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseKeyDerivationPreferenceDialogFragmentCompat.kt @@ -54,12 +54,12 @@ class DatabaseKeyDerivationPreferenceDialogFragmentCompat } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - database?.let { - kdfEngineSelected = database.kdfEngine - mKdfAdapter?.setItems(database.availableKdfEngines, kdfEngineSelected) - } + override fun onDatabaseRetrieved(database: ContextualDatabase) { + kdfEngineSelected = database.kdfEngine + mKdfAdapter?.setItems( + items = database.availableKdfEngines, + itemUsed = kdfEngineSelected + ) } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMaxHistoryItemsPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMaxHistoryItemsPreferenceDialogFragmentCompat.kt index d253cc6fd..025a109f8 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMaxHistoryItemsPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMaxHistoryItemsPreferenceDialogFragmentCompat.kt @@ -31,19 +31,17 @@ class DatabaseMaxHistoryItemsPreferenceDialogFragmentCompat : DatabaseSavePrefer setExplanationText(R.string.max_history_items_summary) } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - database?.historyMaxItems?.let { maxItemsDatabase -> - inputText = maxItemsDatabase.toString() - setSwitchAction({ isChecked -> - inputText = if (!isChecked) { - NONE_MAX_HISTORY_ITEMS.toString() - } else { - DEFAULT_MAX_HISTORY_ITEMS.toString() - } - showInputText(isChecked) - }, maxItemsDatabase > NONE_MAX_HISTORY_ITEMS) - } + override fun onDatabaseRetrieved(database: ContextualDatabase) { + val maxItemsDatabase = database.historyMaxItems + inputText = maxItemsDatabase.toString() + setSwitchAction({ isChecked -> + inputText = if (!isChecked) { + NONE_MAX_HISTORY_ITEMS.toString() + } else { + DEFAULT_MAX_HISTORY_ITEMS.toString() + } + showInputText(isChecked) + }, maxItemsDatabase > NONE_MAX_HISTORY_ITEMS) } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMaxHistorySizePreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMaxHistorySizePreferenceDialogFragmentCompat.kt index 845d7c4b0..99a038a65 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMaxHistorySizePreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMaxHistorySizePreferenceDialogFragmentCompat.kt @@ -34,31 +34,29 @@ class DatabaseMaxHistorySizePreferenceDialogFragmentCompat : DatabaseSavePrefere setExplanationText(R.string.max_history_size_summary) } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - database?.historyMaxSize?.let { maxItemsDatabase -> - dataByte = DataByte(maxItemsDatabase, DataByte.ByteFormat.BYTE) - .toBetterByteFormat() - inputText = dataByte.number.toString() - if (dataByte.number >= 0) { - setUnitText(dataByte.format.stringId) - } else { - unitText = null - } - - setSwitchAction({ isChecked -> - if (!isChecked) { - dataByte = INFINITE_MAX_HISTORY_SIZE_DATA_BYTE - inputText = INFINITE_MAX_HISTORY_SIZE.toString() - unitText = null - } else { - dataByte = DEFAULT_MAX_HISTORY_SIZE_DATA_BYTE - inputText = dataByte.number.toString() - setUnitText(dataByte.format.stringId) - } - showInputText(isChecked) - }, maxItemsDatabase > INFINITE_MAX_HISTORY_SIZE) + override fun onDatabaseRetrieved(database: ContextualDatabase) { + val maxItemsDatabase = database.historyMaxSize + dataByte = DataByte(maxItemsDatabase, DataByte.ByteFormat.BYTE) + .toBetterByteFormat() + inputText = dataByte.number.toString() + if (dataByte.number >= 0) { + setUnitText(dataByte.format.stringId) + } else { + unitText = null } + + setSwitchAction({ isChecked -> + if (!isChecked) { + dataByte = INFINITE_MAX_HISTORY_SIZE_DATA_BYTE + inputText = INFINITE_MAX_HISTORY_SIZE.toString() + unitText = null + } else { + dataByte = DEFAULT_MAX_HISTORY_SIZE_DATA_BYTE + inputText = dataByte.number.toString() + setUnitText(dataByte.format.stringId) + } + showInputText(isChecked) + }, maxItemsDatabase > INFINITE_MAX_HISTORY_SIZE) } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMemoryUsagePreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMemoryUsagePreferenceDialogFragmentCompat.kt index 53724b180..cf20b927e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMemoryUsagePreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseMemoryUsagePreferenceDialogFragmentCompat.kt @@ -34,15 +34,12 @@ class DatabaseMemoryUsagePreferenceDialogFragmentCompat : DatabaseSavePreference setExplanationText(R.string.memory_usage_explanation) } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - database?.let { - val memoryBytes = database.memoryUsage - dataByte = DataByte(memoryBytes, DataByte.ByteFormat.BYTE) - .toBetterByteFormat() - inputText = dataByte.number.toString() - setUnitText(dataByte.format.stringId) - } + override fun onDatabaseRetrieved(database: ContextualDatabase) { + val memoryBytes = database.memoryUsage + dataByte = DataByte(memoryBytes, DataByte.ByteFormat.BYTE) + .toBetterByteFormat() + inputText = dataByte.number.toString() + setUnitText(dataByte.format.stringId) } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseNamePreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseNamePreferenceDialogFragmentCompat.kt index 9ce8af0b7..be24c70ec 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseNamePreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseNamePreferenceDialogFragmentCompat.kt @@ -24,9 +24,8 @@ import com.kunzisoft.keepass.database.ContextualDatabase class DatabaseNamePreferenceDialogFragmentCompat : DatabaseSavePreferenceDialogFragmentCompat() { - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - inputText = database?.name ?: "" + override fun onDatabaseRetrieved(database: ContextualDatabase) { + inputText = database.name } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseParallelismPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseParallelismPreferenceDialogFragmentCompat.kt index 2dfd7c8df..a70b5bdfe 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseParallelismPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseParallelismPreferenceDialogFragmentCompat.kt @@ -31,9 +31,8 @@ class DatabaseParallelismPreferenceDialogFragmentCompat : DatabaseSavePreference setExplanationText(R.string.parallelism_explanation) } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - inputText = database?.parallelism?.toString() ?: MIN_PARALLELISM.toString() + override fun onDatabaseRetrieved(database: ContextualDatabase) { + inputText = database.parallelism.toString() } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRecycleBinGroupPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRecycleBinGroupPreferenceDialogFragmentCompat.kt index 8574dfc61..38659077b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRecycleBinGroupPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRecycleBinGroupPreferenceDialogFragmentCompat.kt @@ -48,12 +48,9 @@ class DatabaseRecycleBinGroupPreferenceDialogFragmentCompat } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - database?.let { - mGroupRecycleBin = database.recycleBin - mGroupsAdapter?.setItems(database.getAllGroupsWithoutRoot(), mGroupRecycleBin) - } + override fun onDatabaseRetrieved(database: ContextualDatabase) { + mGroupRecycleBin = database.recycleBin + mGroupsAdapter?.setItems(database.getAllGroupsWithoutRoot(), mGroupRecycleBin) } override fun onItemSelected(item: Group) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRemoveUnlinkedDataPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRemoveUnlinkedDataPreferenceDialogFragmentCompat.kt index 34862d853..4e7f19d68 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRemoveUnlinkedDataPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRemoveUnlinkedDataPreferenceDialogFragmentCompat.kt @@ -46,6 +46,8 @@ class DatabaseRemoveUnlinkedDataPreferenceDialogFragmentCompat : DatabaseSavePre } } + override fun onDatabaseRetrieved(database: ContextualDatabase) {} + companion object { fun newInstance(key: String): DatabaseRemoveUnlinkedDataPreferenceDialogFragmentCompat { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRoundsPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRoundsPreferenceDialogFragmentCompat.kt index df16f8cd4..248b0374c 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRoundsPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseRoundsPreferenceDialogFragmentCompat.kt @@ -32,9 +32,8 @@ class DatabaseRoundsPreferenceDialogFragmentCompat : DatabaseSavePreferenceDialo explanationText = getString(R.string.rounds_explanation) } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - inputText = database?.numberKeyEncryptionRounds?.toString() ?: MIN_ITERATIONS.toString() + override fun onDatabaseRetrieved(database: ContextualDatabase) { + inputText = database.numberKeyEncryptionRounds.toString() } override fun onDialogClosed(database: ContextualDatabase?, positiveResult: Boolean) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseSavePreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseSavePreferenceDialogFragmentCompat.kt index 563c70d5c..2b6829b49 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseSavePreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseSavePreferenceDialogFragmentCompat.kt @@ -22,6 +22,9 @@ package com.kunzisoft.keepass.settings.preferencedialogfragment import android.content.Context import android.os.Bundle import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.kunzisoft.androidclearchroma.ChromaUtil import com.kunzisoft.keepass.activities.legacy.DatabaseRetrieval import com.kunzisoft.keepass.database.ContextualDatabase @@ -32,13 +35,15 @@ import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.tasks.ActionRunnable import com.kunzisoft.keepass.viewmodels.DatabaseViewModel +import kotlinx.coroutines.launch abstract class DatabaseSavePreferenceDialogFragmentCompat : InputPreferenceDialogFragmentCompat(), DatabaseRetrieval { private var mDatabaseAutoSaveEnable = true private val mDatabaseViewModel: DatabaseViewModel by activityViewModels() - private var mDatabase: ContextualDatabase? = null + protected val mDatabase: ContextualDatabase? + get() = mDatabaseViewModel.database override fun onAttach(context: Context) { super.onAttach(context) @@ -47,18 +52,32 @@ abstract class DatabaseSavePreferenceDialogFragmentCompat override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - mDatabaseViewModel.database.observe(this) { database -> - onDatabaseRetrieved(database) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mDatabaseViewModel.actionState.collect { uiState -> + when (uiState) { + is DatabaseViewModel.ActionState.OnDatabaseActionFinished -> { + onDatabaseActionFinished( + uiState.database, + uiState.actionTask, + uiState.result + ) + } + + else -> {} + } + } + } + } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + mDatabaseViewModel.databaseState.collect { database -> + database?.let { + onDatabaseRetrieved(database) + } + } + } } - } - - override fun onResume() { - super.onResume() - onDatabaseRetrieved(mDatabase) - } - - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - this.mDatabase = database } override fun onDatabaseActionFinished( @@ -77,8 +96,10 @@ abstract class DatabaseSavePreferenceDialogFragmentCompat // To inherit to save element in database } - protected fun saveColor(oldColor: Int?, - newColor: Int?) { + protected fun saveColor( + oldColor: Int?, + newColor: Int? + ) { val oldColorString = if (oldColor != null) ChromaUtil.getFormattedColorString(oldColor, false) else @@ -87,77 +108,158 @@ abstract class DatabaseSavePreferenceDialogFragmentCompat ChromaUtil.getFormattedColorString(newColor, false) else "" - mDatabaseViewModel.saveColor(oldColorString, newColorString, mDatabaseAutoSaveEnable) + mDatabaseViewModel.saveColor( + oldColorString, + newColorString, + mDatabaseAutoSaveEnable + ) } - protected fun saveCompression(oldCompression: CompressionAlgorithm, - newCompression: CompressionAlgorithm + protected fun saveCompression( + oldCompression: CompressionAlgorithm, + newCompression: CompressionAlgorithm ) { - mDatabaseViewModel.saveCompression(oldCompression, newCompression, mDatabaseAutoSaveEnable) + mDatabaseViewModel.saveCompression( + oldCompression, + newCompression, + mDatabaseAutoSaveEnable + ) } - protected fun saveDefaultUsername(oldUsername: String, - newUsername: String) { - mDatabaseViewModel.saveDefaultUsername(oldUsername, newUsername, mDatabaseAutoSaveEnable) + protected fun saveDefaultUsername( + oldUsername: String, + newUsername: String + ) { + mDatabaseViewModel.saveDefaultUsername( + oldUsername, + newUsername, + mDatabaseAutoSaveEnable + ) } - protected fun saveDescription(oldDescription: String, - newDescription: String) { - mDatabaseViewModel.saveDescription(oldDescription, newDescription, mDatabaseAutoSaveEnable) + protected fun saveDescription( + oldDescription: String, + newDescription: String + ) { + mDatabaseViewModel.saveDescription( + oldDescription, + newDescription, + mDatabaseAutoSaveEnable + ) } - protected fun saveEncryption(oldEncryption: EncryptionAlgorithm, - newEncryptionAlgorithm: EncryptionAlgorithm) { - mDatabaseViewModel.saveEncryption(oldEncryption, newEncryptionAlgorithm, mDatabaseAutoSaveEnable) + protected fun saveEncryption( + oldEncryption: EncryptionAlgorithm, + newEncryptionAlgorithm: EncryptionAlgorithm + ) { + mDatabaseViewModel.saveEncryption( + oldEncryption, + newEncryptionAlgorithm, + mDatabaseAutoSaveEnable + ) } - protected fun saveKeyDerivation(oldKeyDerivation: KdfEngine, - newKeyDerivation: KdfEngine) { - mDatabaseViewModel.saveKeyDerivation(oldKeyDerivation, newKeyDerivation, mDatabaseAutoSaveEnable) + protected fun saveKeyDerivation( + oldKeyDerivation: KdfEngine, + newKeyDerivation: KdfEngine + ) { + mDatabaseViewModel.saveKeyDerivation( + oldKeyDerivation, + newKeyDerivation, + mDatabaseAutoSaveEnable + ) } - protected fun saveName(oldName: String, - newName: String) { - mDatabaseViewModel.saveName(oldName, newName, mDatabaseAutoSaveEnable) + protected fun saveName( + oldName: String, + newName: String + ) { + mDatabaseViewModel.saveName( + oldName, + newName, + mDatabaseAutoSaveEnable + ) } - protected fun saveRecycleBin(oldGroup: Group?, - newGroup: Group?) { - mDatabaseViewModel.saveRecycleBin(oldGroup, newGroup, mDatabaseAutoSaveEnable) + protected fun saveRecycleBin( + oldGroup: Group?, + newGroup: Group? + ) { + mDatabaseViewModel.saveRecycleBin( + oldGroup, + newGroup, + mDatabaseAutoSaveEnable + ) } protected fun removeUnlinkedData() { mDatabaseViewModel.removeUnlinkedData(mDatabaseAutoSaveEnable) } - protected fun saveTemplatesGroup(oldGroup: Group?, - newGroup: Group?) { - mDatabaseViewModel.saveTemplatesGroup(oldGroup, newGroup, mDatabaseAutoSaveEnable) + protected fun saveTemplatesGroup( + oldGroup: Group?, + newGroup: Group? + ) { + mDatabaseViewModel.saveTemplatesGroup( + oldGroup, + newGroup, + mDatabaseAutoSaveEnable + ) } - protected fun saveMaxHistoryItems(oldNumber: Int, - newNumber: Int) { - mDatabaseViewModel.saveMaxHistoryItems(oldNumber, newNumber, mDatabaseAutoSaveEnable) + protected fun saveMaxHistoryItems( + oldNumber: Int, + newNumber: Int + ) { + mDatabaseViewModel.saveMaxHistoryItems( + oldNumber, + newNumber, + mDatabaseAutoSaveEnable + ) } - protected fun saveMaxHistorySize(oldNumber: Long, - newNumber: Long) { - mDatabaseViewModel.saveMaxHistorySize(oldNumber, newNumber, mDatabaseAutoSaveEnable) + protected fun saveMaxHistorySize( + oldNumber: Long, + newNumber: Long + ) { + mDatabaseViewModel.saveMaxHistorySize( + oldNumber, + newNumber, + mDatabaseAutoSaveEnable + ) } - protected fun saveMemoryUsage(oldNumber: Long, - newNumber: Long) { - mDatabaseViewModel.saveMemoryUsage(oldNumber, newNumber, mDatabaseAutoSaveEnable) + protected fun saveMemoryUsage( + oldNumber: Long, + newNumber: Long + ) { + mDatabaseViewModel.saveMemoryUsage( + oldNumber, + newNumber, + mDatabaseAutoSaveEnable + ) } - protected fun saveParallelism(oldNumber: Long, - newNumber: Long) { - mDatabaseViewModel.saveParallelism(oldNumber, newNumber, mDatabaseAutoSaveEnable) + protected fun saveParallelism( + oldNumber: Long, + newNumber: Long + ) { + mDatabaseViewModel.saveParallelism( + oldNumber, + newNumber, + mDatabaseAutoSaveEnable + ) } - protected fun saveIterations(oldNumber: Long, - newNumber: Long) { - mDatabaseViewModel.saveIterations(oldNumber, newNumber, mDatabaseAutoSaveEnable) + protected fun saveIterations( + oldNumber: Long, + newNumber: Long + ) { + mDatabaseViewModel.saveIterations( + oldNumber, + newNumber, + mDatabaseAutoSaveEnable + ) } companion object { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseTemplatesGroupPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseTemplatesGroupPreferenceDialogFragmentCompat.kt index 1919420ef..ba4b6349b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseTemplatesGroupPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/DatabaseTemplatesGroupPreferenceDialogFragmentCompat.kt @@ -48,12 +48,9 @@ class DatabaseTemplatesGroupPreferenceDialogFragmentCompat } } - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - super.onDatabaseRetrieved(database) - database?.let { - mGroupTemplates = database.templatesGroup - mGroupsAdapter?.setItems(database.getAllGroupsWithoutRoot(), mGroupTemplates) - } + override fun onDatabaseRetrieved(database: ContextualDatabase) { + mGroupTemplates = database.templatesGroup + mGroupsAdapter?.setItems(database.getAllGroupsWithoutRoot(), mGroupTemplates) } override fun onItemSelected(item: Group) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/PasskeysPrivilegedAppsPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/PasskeysPrivilegedAppsPreferenceDialogFragmentCompat.kt new file mode 100644 index 000000000..ad13c3f0a --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/PasskeysPrivilegedAppsPreferenceDialogFragmentCompat.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.settings.preferencedialogfragment + +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.annotation.RequiresApi +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp +import com.kunzisoft.keepass.settings.preferencedialogfragment.adapter.ListSelectionItemAdapter +import com.kunzisoft.keepass.settings.preferencedialogfragment.viewmodel.PasskeysPrivilegedAppsViewModel +import kotlinx.coroutines.launch + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeysPrivilegedAppsPreferenceDialogFragmentCompat + : InputPreferenceDialogFragmentCompat() { + + private var mAdapter = ListSelectionItemAdapter() + private val passkeysPrivilegedAppsViewModel : PasskeysPrivilegedAppsViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + passkeysPrivilegedAppsViewModel.retrievePrivilegedAppsToSelect() + + passkeysPrivilegedAppsViewModel.uiState.collect { uiState -> + when(uiState) { + is PasskeysPrivilegedAppsViewModel.UiState.Loading -> {} + is PasskeysPrivilegedAppsViewModel.UiState.OnPrivilegedAppsToSelectRetrieved -> { + mAdapter.apply { + setItems(uiState.privilegedApps) + selectedItems = uiState.selected.toMutableList() + } + } + } + } + } + } + } + + override fun onBindDialogView(view: View) { + super.onBindDialogView(view) + setExplanationText(R.string.passkeys_privileged_apps_explanation) + view.findViewById(R.id.pref_dialog_list).apply { + layoutManager = LinearLayoutManager(context) + adapter = mAdapter + } + } + + override fun onDialogClosed(positiveResult: Boolean) { + if (positiveResult) { + passkeysPrivilegedAppsViewModel.saveSelectedPrivilegedApp(mAdapter.selectedItems) + } + } + + companion object { + + fun newInstance(key: String): PasskeysPrivilegedAppsPreferenceDialogFragmentCompat { + val fragment = PasskeysPrivilegedAppsPreferenceDialogFragmentCompat() + val bundle = Bundle(1) + bundle.putString(ARG_KEY, key) + fragment.arguments = bundle + + return fragment + } + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/adapter/ListSelectionItemAdapter.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/adapter/ListSelectionItemAdapter.kt new file mode 100644 index 000000000..c20f632de --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/adapter/ListSelectionItemAdapter.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.settings.preferencedialogfragment.adapter + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.kunzisoft.keepass.R + +class ListSelectionItemAdapter() + : RecyclerView.Adapter() { + + private val itemList: MutableList = mutableListOf() + var selectedItems: MutableList = mutableListOf() + @SuppressLint("NotifyDataSetChanged") + set(value) { + field = value + notifyDataSetChanged() + } + + var itemSelectedCallback: ItemSelectedCallback? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectionViewHolder { + return SelectionViewHolder(LayoutInflater.from(parent.context) + .inflate(R.layout.pref_dialog_list_item, parent, false)) + } + + @SuppressLint("SetTextI18n", "NotifyDataSetChanged") + override fun onBindViewHolder(holder: SelectionViewHolder, position: Int) { + val item = itemList[position] + + holder.container.apply { + isSelected = selectedItems.contains(item) + } + holder.textView.apply { + text = item.toString() + setOnClickListener { + if (selectedItems.contains(item)) + selectedItems.remove(item) + else + selectedItems.add(item) + itemSelectedCallback?.onItemSelected(item) + notifyDataSetChanged() + } + } + } + + override fun getItemCount(): Int { + return itemList.size + } + + fun setItems(items: List) { + this.itemList.clear() + this.itemList.addAll(items) + } + + interface ItemSelectedCallback { + fun onItemSelected(item: T) + } + + class SelectionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + var textView: TextView = itemView.findViewById(R.id.pref_dialog_list_text) + var container: ViewGroup = itemView.findViewById(R.id.pref_dialog_list_container) + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/viewmodel/PasskeysPrivilegedAppsViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/viewmodel/PasskeysPrivilegedAppsViewModel.kt new file mode 100644 index 000000000..6179c668f --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/viewmodel/PasskeysPrivilegedAppsViewModel.kt @@ -0,0 +1,61 @@ +package com.kunzisoft.keepass.settings.preferencedialogfragment.viewmodel + +import android.app.Application +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp +import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.deletePrivilegedAppsFile +import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.retrieveCustomPrivilegedApps +import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.retrievePredefinedPrivilegedApps +import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.saveCustomPrivilegedApps +import com.kunzisoft.keepass.utils.AppUtil.getInstalledBrowsersWithSignatures +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeysPrivilegedAppsViewModel(application: Application): AndroidViewModel(application) { + + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState + + fun retrievePrivilegedAppsToSelect() { + viewModelScope.launch { + val predefinedPrivilegedApps = retrievePredefinedPrivilegedApps(getApplication()) + val customPrivilegedApps = retrieveCustomPrivilegedApps(getApplication()) + // Only retrieve browser apps that are not already in the predefined list + val browserApps = getInstalledBrowsersWithSignatures(getApplication()).filter { + predefinedPrivilegedApps.none { privilegedApp -> + privilegedApp.packageName == it.packageName + && privilegedApp.fingerprints.any { + fingerprint -> fingerprint in it.fingerprints + } + } + } + _uiState.value = UiState.OnPrivilegedAppsToSelectRetrieved( + privilegedApps = browserApps, + selected = customPrivilegedApps + ) + } + } + + fun saveSelectedPrivilegedApp(privilegedApps: List) { + viewModelScope.launch { + if (privilegedApps.isNotEmpty()) + saveCustomPrivilegedApps(getApplication(), privilegedApps) + else + deletePrivilegedAppsFile(getApplication()) + } + } + + sealed class UiState { + + object Loading : UiState() + data class OnPrivilegedAppsToSelectRetrieved( + val privilegedApps: List, + val selected: List + ) : UiState() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/AppUtil.kt b/app/src/main/java/com/kunzisoft/keepass/utils/AppUtil.kt new file mode 100644 index 000000000..88e3741ef --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/utils/AppUtil.kt @@ -0,0 +1,122 @@ +package com.kunzisoft.keepass.utils + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.net.toUri +import com.kunzisoft.encrypt.Signature.getAllFingerprints +import com.kunzisoft.keepass.BuildConfig +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp +import com.kunzisoft.keepass.education.Education + +object AppUtil { + + fun randomRequestCode(): Int { + return (Math.random() * Integer.MAX_VALUE).toInt() + } + + fun Context.isExternalAppInstalled(packageName: String, showError: Boolean = true): Boolean { + try { + this.applicationContext.packageManager.getPackageInfoCompat( + packageName, + PackageManager.GET_ACTIVITIES + ) + Education.setEducationScreenReclickedPerformed(this) + return true + } catch (e: Exception) { + if (showError) + Log.e(AppUtil::class.simpleName, "App not accessible", e) + } + return false + } + + fun Context.openExternalApp(packageName: String, sourcesURL: String? = null) { + var launchIntent: Intent? = null + try { + launchIntent = this.packageManager.getLaunchIntentForPackage(packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } catch (ignored: Exception) { } + try { + if (launchIntent == null) { + this.startActivity( + Intent(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setData( + if (sourcesURL != null + && !BuildConfig.CLOSED_STORE + ) { + sourcesURL + } else { + this.getString( + if (BuildConfig.CLOSED_STORE) + R.string.play_store_url + else + R.string.f_droid_url, + packageName + ) + }.toUri() + ) + ) + } else { + this.startActivity(launchIntent) + } + } catch (e: Exception) { + Log.e(AppUtil::class.simpleName, "App cannot be open", e) + } + } + + fun Context.isContributingUser(): Boolean { + return (Education.isEducationScreenReclickedPerformed(this) + || isExternalAppInstalled(this.getString(R.string.keepro_app_id), false) + ) + } + + @RequiresApi(Build.VERSION_CODES.P) + fun getInstalledBrowsersWithSignatures(context: Context): List { + val packageManager = context.packageManager + val browserList = mutableListOf() + + // Create a generic web intent + val intent = Intent(Intent.ACTION_VIEW) + intent.data = context.getString(R.string.homepage_url).toUri() + + // Query for apps that can handle this intent + val resolveInfoList: List = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.queryIntentActivities( + intent, + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong()) + ) + } else { + @Suppress("DEPRECATION") + packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL) + } + + val processedPackageNames = mutableSetOf() + + for (resolveInfo in resolveInfoList) { + val packageName = resolveInfo.activityInfo.packageName + if (packageName != null && !processedPackageNames.contains(packageName)) { + try { + val packageInfo = packageManager.getPackageInfo( + packageName, + PackageManager.GET_SIGNING_CERTIFICATES + ) + val signatureFingerprints = packageInfo.signingInfo?.getAllFingerprints() + signatureFingerprints?.let { + browserList.add(AndroidPrivilegedApp(packageName, signatureFingerprints)) + processedPackageNames.add(packageName) + } + } catch (e: Exception) { + Log.e(AppUtil::class.simpleName, "Error processing package: $packageName", e) + } + } + } + return browserList.distinctBy { it.packageName } // Ensure uniqueness just in case + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt b/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt index 6244ecbda..8c4eb9e91 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt +++ b/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt @@ -33,7 +33,7 @@ import androidx.core.content.ContextCompat import com.kunzisoft.keepass.R import com.kunzisoft.keepass.app.AppLifecycleObserver import com.kunzisoft.keepass.database.ContextualDatabase -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.services.ClipboardEntryNotificationService import com.kunzisoft.keepass.services.KeyboardEntryNotificationService import com.kunzisoft.keepass.settings.PreferencesUtil diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/IOActionTask.kt b/app/src/main/java/com/kunzisoft/keepass/utils/IOActionTask.kt index 5d4ff9e41..194236457 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/IOActionTask.kt +++ b/app/src/main/java/com/kunzisoft/keepass/utils/IOActionTask.kt @@ -19,30 +19,39 @@ */ package com.kunzisoft.keepass.utils -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.EmptyCoroutineContext /** * Class to invoke action in a separate IO thread */ class IOActionTask( - private val action: () -> T , - private val afterActionListener: ((T?) -> Unit)? = null) { - - private val mainScope = CoroutineScope(Dispatchers.Main) - + private val action: () -> T, + private val onActionComplete: ((T?) -> Unit)? = null, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main), + private val exceptionHandler: CoroutineExceptionHandler? = null +) { fun execute() { - mainScope.launch { + scope.launch(exceptionHandler ?: EmptyCoroutineContext) { withContext(Dispatchers.IO) { val asyncResult: Deferred = async { - try { - action.invoke() - } catch (e: Exception) { - e.printStackTrace() - null - } + exceptionHandler?.let { + action.invoke() + } ?: try { + action.invoke() + } catch (e: Exception) { + e.printStackTrace() + null + } } withContext(Dispatchers.Main) { - afterActionListener?.invoke(asyncResult.await()) + onActionComplete?.invoke(asyncResult.await()) } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/MagikeyboardUtil.kt b/app/src/main/java/com/kunzisoft/keepass/utils/MagikeyboardUtil.kt index be095f648..e98c2831b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/MagikeyboardUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/utils/MagikeyboardUtil.kt @@ -4,7 +4,7 @@ import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager import android.util.Log -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService object MagikeyboardUtil { private val TAG = MagikeyboardUtil::class.java.name diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/MenuUtil.kt b/app/src/main/java/com/kunzisoft/keepass/utils/MenuUtil.kt index cf0cd289b..6df1c4561 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/MenuUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/utils/MenuUtil.kt @@ -28,7 +28,7 @@ import android.view.MenuItem import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.AboutActivity import com.kunzisoft.keepass.settings.SettingsActivity -import com.kunzisoft.keepass.utils.UriUtil.isContributingUser +import com.kunzisoft.keepass.utils.AppUtil.isContributingUser import com.kunzisoft.keepass.utils.UriUtil.openUrl object MenuUtil { @@ -40,9 +40,6 @@ object MenuUtil { menu.findItem(R.id.menu_contribute)?.isVisible = false } - /* - * @param checkLock Check the time lock before launch settings in LockingActivity - */ fun onDefaultMenuOptionsItemSelected(activity: Activity, item: MenuItem, timeoutEnable: Boolean = false) { diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/UriUtil.kt b/app/src/main/java/com/kunzisoft/keepass/utils/UriUtil.kt index c53a29e7d..83a06d6ee 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/UriUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/utils/UriUtil.kt @@ -200,64 +200,5 @@ object UriUtil { this.openUrl(this.getString(resId)) } - fun Context.isContributingUser(): Boolean { - return (Education.isEducationScreenReclickedPerformed(this) - || isExternalAppInstalled(this.getString(R.string.keepro_app_id), false) - ) - } - - fun Context.isExternalAppInstalled(packageName: String, showError: Boolean = true): Boolean { - try { - this.applicationContext.packageManager.getPackageInfoCompat( - packageName, - PackageManager.GET_ACTIVITIES - ) - Education.setEducationScreenReclickedPerformed(this) - return true - } catch (e: Exception) { - if (showError) - Log.e(TAG, "App not accessible", e) - } - return false - } - - fun Context.openExternalApp(packageName: String, sourcesURL: String? = null) { - var launchIntent: Intent? = null - try { - launchIntent = this.packageManager.getLaunchIntentForPackage(packageName)?.apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - } catch (ignored: Exception) { } - try { - if (launchIntent == null) { - this.startActivity( - Intent(Intent.ACTION_VIEW) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .setData( - Uri.parse( - if (sourcesURL != null - && !BuildConfig.CLOSED_STORE - ) { - sourcesURL - } else { - this.getString( - if (BuildConfig.CLOSED_STORE) - R.string.play_store_url - else - R.string.f_droid_url, - packageName - ) - } - ) - ) - ) - } else { - this.startActivity(launchIntent) - } - } catch (e: Exception) { - Log.e(TAG, "App cannot be open", e) - } - } - private const val TAG = "UriUtil" } diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/WebDomain.kt b/app/src/main/java/com/kunzisoft/keepass/utils/WebDomain.kt deleted file mode 100644 index 429405ee0..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/utils/WebDomain.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.kunzisoft.keepass.utils - -import android.content.Context -import com.kunzisoft.keepass.model.SearchInfo -import com.kunzisoft.keepass.settings.PreferencesUtil -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import mozilla.components.lib.publicsuffixlist.PublicSuffixList - -object WebDomain { - - /** - * Get the concrete web domain AKA without sub domain if needed - */ - fun getConcreteWebDomain(context: Context, - webDomain: String?, - concreteWebDomain: (String?) -> Unit) { - CoroutineScope(Dispatchers.Main).launch { - if (webDomain != null) { - // Warning, web domain can contains IP, don't crop in this case - if (PreferencesUtil.searchSubdomains(context) - || Regex(SearchInfo.WEB_IP_REGEX).matches(webDomain)) { - concreteWebDomain.invoke(webDomain) - } else { - val publicSuffixList = PublicSuffixList(context) - concreteWebDomain.invoke(publicSuffixList - .getPublicSuffixPlusOne(webDomain).await()) - } - } else { - concreteWebDomain.invoke(null) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/view/PasskeyTextFieldView.kt b/app/src/main/java/com/kunzisoft/keepass/view/PasskeyTextFieldView.kt new file mode 100644 index 000000000..c099f92f9 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/view/PasskeyTextFieldView.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2025 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 . + * + */ +package com.kunzisoft.keepass.view + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.ContextThemeWrapper +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.ViewCompat +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.password.PasswordEntropy + + +class PasskeyTextFieldView @JvmOverloads constructor(context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0) + : PasswordTextFieldView(context, attrs, defStyle) { + + private var relyingPartyViewId = ViewCompat.generateViewId() + private var usernameViewId = ViewCompat.generateViewId() + private var passkeyImageId = ViewCompat.generateViewId() + + private var passkeyImage = AppCompatImageView( + ContextThemeWrapper(context, R.style.KeepassDXStyle_ImageButton_Simple), null, 0).apply { + layoutParams = LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT) + setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_passkey_white_24dp)) + contentDescription = context.getString(R.string.passkey) + } + + private val relyingPartyView = AppCompatTextView(context).apply { + setTextAppearance(context, + R.style.KeepassDXStyle_TextAppearance_TextNodePrimary) + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT + ).also { + it.topMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + it.leftMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + it.marginStart = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + } + setTextIsSelectable(true) + } + private val usernameView = AppCompatTextView(context).apply { + setTextAppearance(context, + R.style.KeepassDXStyle_TextAppearance_TextNodeSecondary) + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT).also { + it.topMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 4f, + resources.displayMetrics + ).toInt() + it.leftMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + it.marginStart = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + } + setTextIsSelectable(true) + } + + private fun buildViews() { + indicatorDrawable?.let { + DrawableCompat.setTint(it, PasswordEntropy.Strength.VERY_UNGUESSABLE.color) + } + passkeyImage.apply { + id = passkeyImageId + layoutParams = (layoutParams as LayoutParams?)?.also { + it.addRule(ALIGN_PARENT_RIGHT) + it.addRule(ALIGN_PARENT_END) + } + } + labelView.apply { + layoutParams = (layoutParams as LayoutParams?)?.also { + it.addRule(LEFT_OF, passkeyImageId) + it.addRule(START_OF, passkeyImageId) + } + } + relyingPartyView.apply { + id = relyingPartyViewId + layoutParams = (layoutParams as LayoutParams?)?.also { + it.addRule(LEFT_OF, passkeyImageId) + it.addRule(START_OF, passkeyImageId) + it.addRule(BELOW, labelViewId) + } + } + usernameView.apply { + id = usernameViewId + layoutParams = (layoutParams as LayoutParams?)?.also { + it.addRule(LEFT_OF, passkeyImageId) + it.addRule(START_OF, passkeyImageId) + it.addRule(BELOW, relyingPartyViewId) + } + } + } + + init { + removeAllViews() + buildViews() + addView(passkeyImage) + addView(labelView) + addView(relyingPartyView) + addView(usernameView) + } + + override var default: String = "" + override var isFieldVisible: Boolean = true + + var relyingParty: String + get() { + return relyingPartyView.text.toString() + } + set(value) { + relyingPartyView.text = value + } + + var username: String + get() { + return usernameView.text.toString() + } + set(value) { + usernameView.text = value + } + + override fun getEntropyStrength(passwordText: String) { + // Do nothing + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/view/PasswordTextFieldView.kt b/app/src/main/java/com/kunzisoft/keepass/view/PasswordTextFieldView.kt index c56454d10..12942811e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/PasswordTextFieldView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/PasswordTextFieldView.kt @@ -34,9 +34,9 @@ import com.kunzisoft.keepass.password.PasswordGenerator import com.kunzisoft.keepass.settings.PreferencesUtil -class PasswordTextFieldView @JvmOverloads constructor(context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0) +open class PasswordTextFieldView @JvmOverloads constructor(context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0) : TextFieldView(context, attrs, defStyle) { private var mPasswordEntropyCalculator: PasswordEntropy = PasswordEntropy { @@ -45,7 +45,7 @@ class PasswordTextFieldView @JvmOverloads constructor(context: Context, } } - private var indicatorDrawable = ContextCompat.getDrawable( + protected var indicatorDrawable = ContextCompat.getDrawable( context, R.drawable.ic_shield_white_24dp )?.apply { @@ -98,7 +98,7 @@ class PasswordTextFieldView @JvmOverloads constructor(context: Context, value = resources.getString(valueId) } - private fun getEntropyStrength(passwordText: String) { + protected open fun getEntropyStrength(passwordText: String) { mPasswordEntropyCalculator.getEntropyStrength(passwordText) { entropyStrength -> labelView.apply { post { diff --git a/app/src/main/java/com/kunzisoft/keepass/view/SearchFiltersView.kt b/app/src/main/java/com/kunzisoft/keepass/view/SearchFiltersView.kt index 8f957e6f9..5a501a1e2 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/SearchFiltersView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/SearchFiltersView.kt @@ -3,7 +3,6 @@ package com.kunzisoft.keepass.view import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import android.widget.CompoundButton import android.widget.ImageView @@ -30,8 +29,10 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, private var searchTitle: CompoundButton private var searchUsername: CompoundButton private var searchPassword: CompoundButton + private var searchApplicationId: CompoundButton private var searchURL: CompoundButton private var searchByURLDomain: Boolean = false + private var searchByURLSubDomain: Boolean = false private var searchExpired: CompoundButton private var searchNotes: CompoundButton private var searchOther: CompoundButton @@ -50,8 +51,10 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, this.searchInTitles = searchTitle.isChecked this.searchInUsernames = searchUsername.isChecked this.searchInPasswords = searchPassword.isChecked + this.searchInAppIds = searchApplicationId.isChecked this.searchInUrls = searchURL.isChecked this.searchByDomain = searchByURLDomain + this.searchBySubDomain = searchByURLSubDomain this.searchInExpired = searchExpired.isChecked this.searchInNotes = searchNotes.isChecked this.searchInOther = searchOther.isChecked @@ -71,8 +74,10 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, searchTitle.isChecked = value.searchInTitles searchUsername.isChecked = value.searchInUsernames searchPassword.isChecked = value.searchInPasswords + searchApplicationId.isChecked = value.searchInAppIds searchURL.isChecked = value.searchInUrls searchByURLDomain = value.searchByDomain + searchByURLSubDomain = value.searchBySubDomain searchExpired.isChecked = value.searchInExpired searchNotes.isChecked = value.searchInNotes searchOther.isChecked = value.searchInOther @@ -87,7 +92,7 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, var onParametersChangeListener: ((searchParameters: SearchParameters) -> Unit)? = null private var mOnParametersChangeListener: ((searchParameters: SearchParameters) -> Unit)? = { // To recalculate height - if (searchAdvanceFiltersContainer?.visibility == View.VISIBLE) { + if (searchAdvanceFiltersContainer?.visibility == VISIBLE) { searchAdvanceFiltersContainer?.expand( false, searchAdvanceFiltersContainer?.getFullHeight() @@ -110,6 +115,7 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, searchTitle = findViewById(R.id.search_chip_title) searchUsername = findViewById(R.id.search_chip_username) searchPassword = findViewById(R.id.search_chip_password) + searchApplicationId = findViewById(R.id.search_chip_application_id) searchURL = findViewById(R.id.search_chip_url) searchExpired = findViewById(R.id.search_chip_expires) searchNotes = findViewById(R.id.search_chip_note) @@ -125,7 +131,7 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, // Expand menu with button searchExpandButton.setOnClickListener { - val isVisible = searchAdvanceFiltersContainer?.visibility == View.VISIBLE + val isVisible = searchAdvanceFiltersContainer?.visibility == VISIBLE if (isVisible) closeAdvancedFilters() else @@ -156,6 +162,10 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, searchParameters.searchInPasswords = isChecked mOnParametersChangeListener?.invoke(searchParameters) } + searchApplicationId.setOnCheckedChangeListener { _, isChecked -> + searchParameters.searchInAppIds = isChecked + mOnParametersChangeListener?.invoke(searchParameters) + } searchURL.setOnCheckedChangeListener { _, isChecked -> searchParameters.searchInUrls = isChecked mOnParametersChangeListener?.invoke(searchParameters) @@ -200,10 +210,10 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, searchNumbers.text = SearchHelper.showNumberOfSearchResults(numbers) } - fun setCurrentGroupText(text: String) { + fun setCurrentGroupText(text: String?) { val maxChars = 12 searchCurrentGroup.text = when { - text.isEmpty() -> context.getString(R.string.current_group) + text.isNullOrEmpty() -> context.getString(R.string.current_group) text.length > maxChars -> text.substring(0, maxChars) + "…" else -> text } @@ -213,6 +223,10 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, searchOther.isVisible = available } + fun availableApplicationIds(available: Boolean) { + searchApplicationId.isVisible = available + } + fun availableTags(available: Boolean) { searchTag.isVisible = available } @@ -243,16 +257,20 @@ class SearchFiltersView @JvmOverloads constructor(context: Context, ) } + fun showSearchExpandButton(show: Boolean) { + searchExpandButton.isVisible = show + } + override fun setVisibility(visibility: Int) { when (visibility) { - View.VISIBLE -> { - searchAdvanceFiltersContainer?.visibility = View.GONE + VISIBLE -> { + searchAdvanceFiltersContainer?.visibility = GONE searchContainer.showByFading() } else -> { searchContainer.hideByFading() - if (searchAdvanceFiltersContainer?.visibility == View.VISIBLE) { - searchAdvanceFiltersContainer?.visibility = View.INVISIBLE + if (searchAdvanceFiltersContainer?.visibility == VISIBLE) { + searchAdvanceFiltersContainer?.visibility = INVISIBLE searchAdvanceFiltersContainer?.collapse() } } diff --git a/app/src/main/java/com/kunzisoft/keepass/view/TemplateAbstractView.kt b/app/src/main/java/com/kunzisoft/keepass/view/TemplateAbstractView.kt index 0d1823ffe..4d5eec7e7 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/TemplateAbstractView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/TemplateAbstractView.kt @@ -534,10 +534,15 @@ abstract class TemplateAbstractView< } protected fun getCustomField(fieldName: String): Field { + return getCustomFieldOrNull(fieldName) + ?: Field(fieldName, ProtectedString(false)) + } + + protected fun getCustomFieldOrNull(fieldName: String): Field? { return getCustomField(fieldName, templateFieldNotEmpty = false, retrieveDefaultValues = false - ) ?: Field(fieldName, ProtectedString(false)) + ) } private fun getCustomField(fieldName: String, diff --git a/app/src/main/java/com/kunzisoft/keepass/view/TemplateEditView.kt b/app/src/main/java/com/kunzisoft/keepass/view/TemplateEditView.kt index 8963534ba..ee07349b3 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/TemplateEditView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/TemplateEditView.kt @@ -20,6 +20,8 @@ import com.kunzisoft.keepass.database.helper.getLocalizedName import com.kunzisoft.keepass.database.helper.isStandardPasswordName import com.kunzisoft.keepass.model.DataDate import com.kunzisoft.keepass.model.DataTime +import com.kunzisoft.keepass.model.AppOriginEntryField +import com.kunzisoft.keepass.model.PasskeyEntryFields import com.kunzisoft.keepass.otp.OtpEntryFields @@ -256,9 +258,12 @@ class TemplateEditView @JvmOverloads constructor(context: Context, override fun populateEntryInfoWithViews(templateFieldNotEmpty: Boolean, retrieveDefaultValues: Boolean) { super.populateEntryInfoWithViews(templateFieldNotEmpty, retrieveDefaultValues) - mEntryInfo?.otpModel = OtpEntryFields.parseFields { key -> - getCustomField(key).protectedValue.toString() - }?.otpModel + val getField: (id: String) -> String? = { key -> + getCustomFieldOrNull(key)?.protectedValue?.stringValue + } + mEntryInfo?.otpModel = OtpEntryFields.parseFields(getField)?.otpModel + mEntryInfo?.passkey = PasskeyEntryFields.parseFields(getField) + mEntryInfo?.appOrigin = AppOriginEntryField.parseFields(getField) } override fun onRestoreEntryInstanceState(state: SavedState) { diff --git a/app/src/main/java/com/kunzisoft/keepass/view/TemplateView.kt b/app/src/main/java/com/kunzisoft/keepass/view/TemplateView.kt index 92e4b0140..12b55ca34 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/TemplateView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/TemplateView.kt @@ -1,7 +1,6 @@ package com.kunzisoft.keepass.view import android.content.Context -import android.os.Build import android.util.AttributeSet import android.view.View import androidx.core.view.isVisible @@ -11,8 +10,11 @@ import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.database.element.template.TemplateAttribute import com.kunzisoft.keepass.database.element.template.TemplateField import com.kunzisoft.keepass.database.helper.getLocalizedName +import com.kunzisoft.keepass.database.helper.isPasskeyLabel import com.kunzisoft.keepass.database.helper.isStandardPasswordName import com.kunzisoft.keepass.model.OtpModel +import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.model.PasskeyEntryFields.PASSKEY_FIELD import com.kunzisoft.keepass.otp.OtpElement import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD @@ -52,6 +54,8 @@ class TemplateView @JvmOverloads constructor(context: Context, return context?.let { (if (TemplateField.isStandardPasswordName(context, templateAttribute.label)) PasswordTextFieldView(it) + else if (TemplateField.isPasskeyLabel(context, templateAttribute.label)) + PasskeyTextFieldView(it) else TextFieldView(it)).apply { applyFontVisibility(mFontInVisibility) setProtection(field.protectedValue.isProtected, mHideProtectedValue) @@ -123,20 +127,20 @@ class TemplateView @JvmOverloads constructor(context: Context, override fun populateViewsWithEntryInfo(showEmptyFields: Boolean): List { val emptyCustomFields = super.populateViewsWithEntryInfo(false) - // Hide empty custom fields emptyCustomFields.forEach { customFieldId -> customFieldId.view.isVisible = false } - removeOtpRunnable() mEntryInfo?.let { entryInfo -> // Assign specific OTP dynamic view entryInfo.otpModel?.let { assignOtp(it) } + entryInfo.passkey?.let { + assignPasskey(it) + } } - return emptyCustomFields } @@ -196,6 +200,22 @@ class TemplateView @JvmOverloads constructor(context: Context, } } + private fun getPasskeyView(): PasskeyTextFieldView? { + getViewFieldByName(PASSKEY_FIELD)?.let { viewField -> + val view = viewField.view + if (view is PasskeyTextFieldView) + return view + } + return null + } + + private fun assignPasskey(passkey: Passkey) { + getPasskeyView()?.apply { + relyingParty = passkey.relyingParty + username = passkey.username + } + } + private fun removeOtpRunnable() { mLastOtpTokenView?.removeCallbacks(mOtpRunnable) mLastOtpTokenView = null diff --git a/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt b/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt index d9fbf49fa..89f45b5d4 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt @@ -20,14 +20,12 @@ package com.kunzisoft.keepass.view import android.content.Context -import android.os.Build import android.text.InputFilter import android.text.util.Linkify import android.util.AttributeSet import android.util.TypedValue import android.view.ContextThemeWrapper import android.view.View -import android.view.View.OnClickListener import android.widget.RelativeLayout import androidx.annotation.StringRes import androidx.appcompat.widget.AppCompatImageButton @@ -37,8 +35,8 @@ import androidx.core.text.util.LinkifyCompat import androidx.core.view.ViewCompat import androidx.core.view.isVisible import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.model.EntryInfo.Companion.APPLICATION_ID_FIELD_NAME -import com.kunzisoft.keepass.utils.UriUtil.openExternalApp +import com.kunzisoft.keepass.model.AppOriginEntryField.APPLICATION_ID_FIELD_NAME +import com.kunzisoft.keepass.utils.AppUtil.openExternalApp open class TextFieldView @JvmOverloads constructor(context: Context, @@ -46,7 +44,7 @@ open class TextFieldView @JvmOverloads constructor(context: Context, defStyle: Int = 0) : RelativeLayout(context, attrs, defStyle), GenericTextFieldView { - private var labelViewId = ViewCompat.generateViewId() + protected var labelViewId = ViewCompat.generateViewId() private var valueViewId = ViewCompat.generateViewId() private var showButtonId = ViewCompat.generateViewId() private var copyButtonId = ViewCompat.generateViewId() diff --git a/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt b/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt index 2437e070d..a5a5a6f19 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt @@ -61,6 +61,7 @@ import androidx.core.view.updatePaddingRelative import com.google.android.material.appbar.CollapsingToolbarLayout import com.google.android.material.snackbar.Snackbar import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.database.exception.LocalizedException import com.kunzisoft.keepass.database.helper.getLocalizedMessage import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.tasks.ActionRunnable @@ -237,6 +238,17 @@ fun View.updateLockPaddingStart() { } } +fun Context.toastError(e: Throwable) { + Toast.makeText( + applicationContext, + if (e is LocalizedException) + e.getLocalizedMessage(resources) + else + e.localizedMessage, + Toast.LENGTH_LONG + ).show() +} + fun Context.showActionErrorIfNeeded(result: ActionRunnable.Result) { if (!result.isSuccess) { result.exception?.getLocalizedMessage(resources)?.let { errorMessage -> diff --git a/app/src/main/java/com/kunzisoft/keepass/viewmodels/DatabaseViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/viewmodels/DatabaseViewModel.kt index c8dfe9814..200b88f7d 100644 --- a/app/src/main/java/com/kunzisoft/keepass/viewmodels/DatabaseViewModel.kt +++ b/app/src/main/java/com/kunzisoft/keepass/viewmodels/DatabaseViewModel.kt @@ -1,214 +1,500 @@ package com.kunzisoft.keepass.viewmodels -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import android.app.Application +import android.net.Uri +import android.os.Bundle +import androidx.lifecycle.AndroidViewModel import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.DatabaseTaskProvider +import com.kunzisoft.keepass.database.MainCredential +import com.kunzisoft.keepass.database.ProgressMessage import com.kunzisoft.keepass.database.crypto.EncryptionAlgorithm import com.kunzisoft.keepass.database.crypto.kdf.KdfEngine +import com.kunzisoft.keepass.database.element.Entry import com.kunzisoft.keepass.database.element.Group +import com.kunzisoft.keepass.database.element.binary.BinaryData 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.model.CipherEncryptDatabase +import com.kunzisoft.keepass.model.SnapFileDatabaseInfo +import com.kunzisoft.keepass.services.DatabaseTaskNotificationService import com.kunzisoft.keepass.tasks.ActionRunnable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.UUID -class DatabaseViewModel: ViewModel() { +class DatabaseViewModel(application: Application): AndroidViewModel(application) { - val database : LiveData get() = _database - private val _database = MutableLiveData() + private val mDatabaseState = MutableStateFlow(null) + val databaseState: StateFlow = mDatabaseState - val actionFinished : LiveData get() = _actionFinished - private val _actionFinished = SingleLiveEvent() + val database: ContextualDatabase? + get() = databaseState.value - val saveDatabase : LiveData get() = _saveDatabase - private val _saveDatabase = SingleLiveEvent() + private val mActionState = MutableStateFlow(ActionState.Loading) + val actionState: StateFlow = mActionState - val mergeDatabase : LiveData get() = _mergeDatabase - private val _mergeDatabase = SingleLiveEvent() + private var mDatabaseTaskProvider: DatabaseTaskProvider = DatabaseTaskProvider( + context = application + ) - val reloadDatabase : LiveData get() = _reloadDatabase - private val _reloadDatabase = SingleLiveEvent() + init { + mDatabaseTaskProvider.onDatabaseRetrieved = { databaseRetrieved -> + val databaseWasReloaded = databaseRetrieved?.wasReloaded == true + if (databaseWasReloaded) { + mActionState.value = ActionState.OnDatabaseReloaded + } + if (database == null || database != databaseRetrieved || databaseWasReloaded) { + databaseRetrieved?.wasReloaded = false + mDatabaseState.value = databaseRetrieved + } + } + mDatabaseTaskProvider.onStartActionRequested = { bundle, actionTask -> + mActionState.value = ActionState.OnDatabaseActionRequested(bundle, actionTask) + } + mDatabaseTaskProvider.databaseInfoListener = object : DatabaseTaskNotificationService.DatabaseInfoListener { + override fun onDatabaseInfoChanged( + previousDatabaseInfo: SnapFileDatabaseInfo, + newDatabaseInfo: SnapFileDatabaseInfo, + readOnlyDatabase: Boolean + ) { + mActionState.value = ActionState.OnDatabaseInfoChanged( + previousDatabaseInfo, + newDatabaseInfo, + readOnlyDatabase + ) + } + } + mDatabaseTaskProvider.actionTaskListener = object : DatabaseTaskNotificationService.ActionTaskListener { + override fun onActionStarted( + database: ContextualDatabase, + progressMessage: ProgressMessage + ) { + mActionState.value = ActionState.OnDatabaseActionStarted(database, progressMessage) + } - val saveName : LiveData get() = _saveName - private val _saveName = SingleLiveEvent() + override fun onActionUpdated( + database: ContextualDatabase, + progressMessage: ProgressMessage + ) { + mActionState.value = ActionState.OnDatabaseActionUpdated(database, progressMessage) + } - val saveDescription : LiveData get() = _saveDescription - private val _saveDescription = SingleLiveEvent() + override fun onActionStopped(database: ContextualDatabase?) { + mActionState.value = ActionState.OnDatabaseActionStopped(database) + } - val saveDefaultUsername : LiveData get() = _saveDefaultUsername - private val _saveDefaultUsername = SingleLiveEvent() + override fun onActionFinished( + database: ContextualDatabase, + actionTask: String, + result: ActionRunnable.Result + ) { + mActionState.value = ActionState.OnDatabaseActionFinished(database, actionTask, result) + } + } - val saveColor : LiveData get() = _saveColor - private val _saveColor = SingleLiveEvent() - - val saveCompression : LiveData get() = _saveCompression - private val _saveCompression = SingleLiveEvent() - - val removeUnlinkData : LiveData get() = _removeUnlinkData - private val _removeUnlinkData = SingleLiveEvent() - - val saveRecycleBin : LiveData get() = _saveRecycleBin - private val _saveRecycleBin = SingleLiveEvent() - - val saveTemplatesGroup : LiveData get() = _saveTemplatesGroup - private val _saveTemplatesGroup = SingleLiveEvent() - - val saveMaxHistoryItems : LiveData get() = _saveMaxHistoryItems - private val _saveMaxHistoryItems = SingleLiveEvent() - - val saveMaxHistorySize : LiveData get() = _saveMaxHistorySize - private val _saveMaxHistorySize = SingleLiveEvent() - - val saveEncryption : LiveData get() = _saveEncryption - private val _saveEncryption = SingleLiveEvent() - - val saveKeyDerivation : LiveData get() = _saveKeyDerivation - private val _saveKeyDerivation = SingleLiveEvent() - - val saveIterations : LiveData get() = _saveIterations - private val _saveIterations = SingleLiveEvent() - - val saveMemoryUsage : LiveData get() = _saveMemoryUsage - private val _saveMemoryUsage = SingleLiveEvent() - - val saveParallelism : LiveData get() = _saveParallelism - private val _saveParallelism = SingleLiveEvent() - - - fun defineDatabase(database: ContextualDatabase?) { - this._database.value = database + mDatabaseTaskProvider.registerProgressTask() } - fun onActionFinished(database: ContextualDatabase, - actionTask: String, - result: ActionRunnable.Result) { - this._actionFinished.value = ActionResult(database, actionTask, result) + /* + * Main database actions + */ + + fun loadDatabase( + databaseUri: Uri, + mainCredential: MainCredential, + readOnly: Boolean, + cipherEncryptDatabase: CipherEncryptDatabase?, + fixDuplicateUuid: Boolean + ) { + mDatabaseTaskProvider.startDatabaseLoad( + databaseUri, + mainCredential, + readOnly, + cipherEncryptDatabase, + fixDuplicateUuid + ) } - fun saveDatabase(save: Boolean) { - _saveDatabase.value = save + fun createDatabase( + databaseUri: Uri, + mainCredential: MainCredential + ) { + mDatabaseTaskProvider.startDatabaseCreate(databaseUri, mainCredential) } - fun mergeDatabase(save: Boolean) { - _mergeDatabase.value = save + fun assignMainCredential( + databaseUri: Uri?, + mainCredential: MainCredential + ) { + if (databaseUri != null) { + mDatabaseTaskProvider.startDatabaseAssignCredential(databaseUri, mainCredential) + } + } + + fun saveDatabase(save: Boolean, saveToUri: Uri? = null) { + mDatabaseTaskProvider.startDatabaseSave(save, saveToUri) + } + + fun mergeDatabase( + save: Boolean, + fromDatabaseUri: Uri? = null, + mainCredential: MainCredential? = null + ) { + mDatabaseTaskProvider.startDatabaseMerge(save, fromDatabaseUri, mainCredential) } fun reloadDatabase(fixDuplicateUuid: Boolean) { - _reloadDatabase.value = fixDuplicateUuid + mDatabaseTaskProvider.askToStartDatabaseReload( + conditionToAsk = database?.dataModifiedSinceLastLoading != false + ) { + mDatabaseTaskProvider.startDatabaseReload(fixDuplicateUuid) + } } - fun saveName(oldValue: String, - newValue: String, - save: Boolean) { - _saveName.value = SuperString(oldValue, newValue, save) + fun onDatabaseChangeValidated() { + mDatabaseTaskProvider.onDatabaseChangeValidated() } - fun saveDescription(oldValue: String, - newValue: String, - save: Boolean) { - _saveDescription.value = SuperString(oldValue, newValue, save) + /* + * Nodes actions + */ + + fun createEntry( + newEntry: Entry, + parent: Group, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseCreateEntry( + newEntry, + parent, + save + ) } - fun saveDefaultUsername(oldValue: String, - newValue: String, - save: Boolean) { - _saveDefaultUsername.value = SuperString(oldValue, newValue, save) + fun updateEntry( + oldEntry: Entry, + entryToUpdate: Entry, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseUpdateEntry( + oldEntry, + entryToUpdate, + save + ) } - fun saveColor(oldValue: String, - newValue: String, - save: Boolean) { - _saveColor.value = SuperString(oldValue, newValue, save) + fun restoreEntryHistory( + mainEntryId: NodeId, + entryHistoryPosition: Int, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseRestoreEntryHistory( + mainEntryId, + entryHistoryPosition, + save + ) } - fun saveCompression(oldValue: CompressionAlgorithm, - newValue: CompressionAlgorithm, - save: Boolean) { - _saveCompression.value = SuperCompression(oldValue, newValue, save) + fun deleteEntryHistory( + mainEntryId: NodeId, + entryHistoryPosition: Int, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseDeleteEntryHistory( + mainEntryId, + entryHistoryPosition, + save + ) + } + + fun createGroup( + newGroup: Group, + parent: Group, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseCreateGroup( + newGroup, + parent, + save + ) + } + + fun updateGroup( + oldGroup: Group, + groupToUpdate: Group, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseUpdateGroup( + oldGroup, + groupToUpdate, + save + ) + } + + fun copyNodes( + nodesToCopy: List, + newParent: Group, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseCopyNodes( + nodesToCopy, + newParent, + save + ) + } + + fun moveNodes( + nodesToMove: List, + newParent: Group, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseMoveNodes( + nodesToMove, + newParent, + save + ) + } + + fun deleteNodes( + nodes: List, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseDeleteNodes( + nodes, + save + ) + } + + /* + * Attributes + */ + + fun buildNewAttachment(): BinaryData? { + return database?.buildNewBinaryAttachment() + } + + /* + * Settings actions + */ + + fun saveName( + oldValue: String, + newValue: String, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveName( + oldValue, + newValue, + save + ) + } + + fun saveDescription( + oldValue: String, + newValue: String, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveDescription( + oldValue, + newValue, + save + ) + } + + fun saveDefaultUsername( + oldValue: String, + newValue: String, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveDefaultUsername( + oldValue, + newValue, + save + ) + } + + fun saveColor( + oldValue: String, + newValue: String, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveColor( + oldValue, + newValue, + save + ) + } + + fun saveCompression( + oldValue: CompressionAlgorithm, + newValue: CompressionAlgorithm, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveCompression( + oldValue, + newValue, + save + ) } fun removeUnlinkedData(save: Boolean) { - _removeUnlinkData.value = save + mDatabaseTaskProvider.startDatabaseRemoveUnlinkedData(save) } - fun saveRecycleBin(oldValue: Group?, - newValue: Group?, - save: Boolean) { - _saveRecycleBin.value = SuperGroup(oldValue, newValue, save) + fun saveRecycleBin( + oldValue: Group?, + newValue: Group?, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveRecycleBin( + oldValue, + newValue, + save + ) } - fun saveTemplatesGroup(oldValue: Group?, - newValue: Group?, - save: Boolean) { - _saveTemplatesGroup.value = SuperGroup(oldValue, newValue, save) + fun saveTemplatesGroup( + oldValue: Group?, + newValue: Group?, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveTemplatesGroup( + oldValue, + newValue, + save + ) } - fun saveMaxHistoryItems(oldValue: Int, - newValue: Int, - save: Boolean) { - _saveMaxHistoryItems.value = SuperInt(oldValue, newValue, save) + fun saveMaxHistoryItems( + oldValue: Int, + newValue: Int, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveMaxHistoryItems( + oldValue, + newValue, + save + ) } - fun saveMaxHistorySize(oldValue: Long, - newValue: Long, - save: Boolean) { - _saveMaxHistorySize.value = SuperLong(oldValue, newValue, save) + fun saveMaxHistorySize( + oldValue: Long, + newValue: Long, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveMaxHistorySize( + oldValue, + newValue, + save + ) } - fun saveEncryption(oldValue: EncryptionAlgorithm, - newValue: EncryptionAlgorithm, - save: Boolean) { - _saveEncryption.value = SuperEncryption(oldValue, newValue, save) + fun saveEncryption( + oldValue: EncryptionAlgorithm, + newValue: EncryptionAlgorithm, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveEncryption( + oldValue, + newValue, + save + ) } - fun saveKeyDerivation(oldValue: KdfEngine, - newValue: KdfEngine, - save: Boolean) { - _saveKeyDerivation.value = SuperKeyDerivation(oldValue, newValue, save) + fun saveKeyDerivation( + oldValue: KdfEngine, + newValue: KdfEngine, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveKeyDerivation( + oldValue, + newValue, + save + ) } - fun saveIterations(oldValue: Long, - newValue: Long, - save: Boolean) { - _saveIterations.value = SuperLong(oldValue, newValue, save) + fun saveIterations( + oldValue: Long, + newValue: Long, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveIterations( + oldValue, + newValue, + save + ) } - fun saveMemoryUsage(oldValue: Long, - newValue: Long, - save: Boolean) { - _saveMemoryUsage.value = SuperLong(oldValue, newValue, save) + fun saveMemoryUsage( + oldValue: Long, + newValue: Long, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveMemoryUsage( + oldValue, + newValue, + save + ) } - fun saveParallelism(oldValue: Long, - newValue: Long, - save: Boolean) { - _saveParallelism.value = SuperLong(oldValue, newValue, save) + fun saveParallelism( + oldValue: Long, + newValue: Long, + save: Boolean + ) { + mDatabaseTaskProvider.startDatabaseSaveParallelism( + oldValue, + newValue, + save + ) } - data class ActionResult(val database: ContextualDatabase, - val actionTask: String, - val result: ActionRunnable.Result) - data class SuperString(val oldValue: String, - val newValue: String, - val save: Boolean) - data class SuperInt(val oldValue: Int, - val newValue: Int, - val save: Boolean) - data class SuperLong(val oldValue: Long, - val newValue: Long, - val save: Boolean) - data class SuperMerge(val fixDuplicateUuid: Boolean, - val save: Boolean) - data class SuperCompression(val oldValue: CompressionAlgorithm, - val newValue: CompressionAlgorithm, - val save: Boolean) - data class SuperEncryption(val oldValue: EncryptionAlgorithm, - val newValue: EncryptionAlgorithm, - val save: Boolean) - data class SuperKeyDerivation(val oldValue: KdfEngine, - val newValue: KdfEngine, - val save: Boolean) - data class SuperGroup(val oldValue: Group?, - val newValue: Group?, - val save: Boolean) + /* + * Hardware Key + */ + fun onChallengeResponded(challengeResponse: ByteArray?) { + mDatabaseTaskProvider.startChallengeResponded( + challengeResponse ?: ByteArray(0) + ) + } + + override fun onCleared() { + super.onCleared() + mDatabaseTaskProvider.unregisterProgressTask() + mDatabaseTaskProvider.destroy() + } + + sealed class ActionState { + object Loading: ActionState() + object OnDatabaseReloaded: ActionState() + data class OnDatabaseActionRequested( + val bundle: Bundle? = null, + val actionTask: String + ): ActionState() + data class OnDatabaseInfoChanged( + val previousDatabaseInfo: SnapFileDatabaseInfo, + val newDatabaseInfo: SnapFileDatabaseInfo, + val readOnlyDatabase: Boolean + ): ActionState() + data class OnDatabaseActionStarted( + var database: ContextualDatabase, + val progressMessage: ProgressMessage + ): ActionState() + data class OnDatabaseActionUpdated( + var database: ContextualDatabase, + val progressMessage: ProgressMessage + ): ActionState() + data class OnDatabaseActionStopped( + var database: ContextualDatabase? + ): ActionState() + data class OnDatabaseActionFinished( + var database: ContextualDatabase, + val actionTask: String, + val result: ActionRunnable.Result + ): ActionState() + } } diff --git a/app/src/main/java/com/kunzisoft/keepass/viewmodels/EntryEditViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/viewmodels/EntryEditViewModel.kt index b740d1fb0..6b4deed54 100644 --- a/app/src/main/java/com/kunzisoft/keepass/viewmodels/EntryEditViewModel.kt +++ b/app/src/main/java/com/kunzisoft/keepass/viewmodels/EntryEditViewModel.kt @@ -3,6 +3,7 @@ package com.kunzisoft.keepass.viewmodels import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.Attachment import com.kunzisoft.keepass.database.element.Entry @@ -16,10 +17,11 @@ import com.kunzisoft.keepass.model.AttachmentState import com.kunzisoft.keepass.model.EntryAttachmentState import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.RegisterInfo -import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.model.StreamDirection import com.kunzisoft.keepass.otp.OtpElement import com.kunzisoft.keepass.utils.IOActionTask +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import java.util.UUID @@ -28,12 +30,18 @@ class EntryEditViewModel: NodeEditViewModel() { private var mEntryId: NodeId? = null private var mParentId: NodeId<*>? = null private var mRegisterInfo: RegisterInfo? = null - private var mSearchInfo: SearchInfo? = null private var mParent: Group? = null private var mEntry: Entry? = null private var mIsTemplate: Boolean = false private val mTempAttachments = mutableListOf() + // To show dialog only one time + var backPressedAlreadyApproved = false + var warningOverwriteDataAlreadyApproved = false + + // Useful to not relaunch a current action + private var actionLocked: Boolean = false + val templatesEntry : LiveData get() = _templatesEntry private val _templatesEntry = MutableLiveData() @@ -73,24 +81,28 @@ class EntryEditViewModel: NodeEditViewModel() { val onBinaryPreviewLoaded : LiveData get() = _onBinaryPreviewLoaded private val _onBinaryPreviewLoaded = SingleLiveEvent() - fun loadDatabase(database: ContextualDatabase?) { - loadTemplateEntry(database, mEntryId, mParentId, mRegisterInfo, mSearchInfo) + private val mUiState = MutableStateFlow(UIState.Loading) + val uiState: StateFlow = mUiState + + fun loadTemplateEntry(database: ContextualDatabase?) { + loadTemplateEntry(database, mEntryId, mParentId, mRegisterInfo) } - fun loadTemplateEntry(database: ContextualDatabase?, - entryId: NodeId?, - parentId: NodeId<*>?, - registerInfo: RegisterInfo?, - searchInfo: SearchInfo?) { + fun loadTemplateEntry( + database: ContextualDatabase?, + entryId: NodeId?, + parentId: NodeId<*>?, + registerInfo: RegisterInfo? + ) { this.mEntryId = entryId this.mParentId = parentId this.mRegisterInfo = registerInfo - this.mSearchInfo = searchInfo database?.let { mEntryId?.let { IOActionTask( - { + scope = viewModelScope, + action = { // Create an Entry copy to modify from the database entry mEntry = database.getEntryById(it) // Retrieve the parent @@ -105,21 +117,24 @@ class EntryEditViewModel: NodeEditViewModel() { database, entry, mIsTemplate, - registerInfo, - searchInfo + registerInfo ) } }, - { templatesEntry -> + onActionComplete = { templatesEntry -> mEntryId = null _templatesEntry.value = templatesEntry + if (templatesEntry?.overwrittenData == true) { + mUiState.value = UIState.ShowOverwriteMessage + } } ).execute() } mParentId?.let { IOActionTask( - { + scope = viewModelScope, + action = { mParent = database.getGroupById(it) mParent?.let { parentGroup -> mEntry = database.createEntry()?.apply { @@ -145,12 +160,11 @@ class EntryEditViewModel: NodeEditViewModel() { database, mEntry, mIsTemplate, - registerInfo, - searchInfo + registerInfo ) } }, - { templatesEntry -> + onActionComplete = { templatesEntry -> mParentId = null _templatesEntry.value = templatesEntry } @@ -159,32 +173,37 @@ class EntryEditViewModel: NodeEditViewModel() { } } - private fun decodeTemplateEntry(database: ContextualDatabase, - entry: Entry?, - isTemplate: Boolean, - registerInfo: RegisterInfo?, - searchInfo: SearchInfo?): TemplatesEntry { + private fun decodeTemplateEntry( + database: ContextualDatabase, + entry: Entry?, + isTemplate: Boolean, + registerInfo: RegisterInfo? + ): TemplatesEntry { val templates = database.getTemplates(isTemplate) val entryTemplate = entry?.let { database.getTemplate(it) } ?: Template.STANDARD var entryInfo: EntryInfo? = null + var overwrittenData = false // Decode the entry / load entry info entry?.let { database.decodeEntryWithTemplateConfiguration(it).let { entry -> // Load entry info entry.getEntryInfo(database, true).let { tempEntryInfo -> // Retrieve data from registration - (registerInfo?.searchInfo ?: searchInfo)?.let { tempSearchInfo -> - tempEntryInfo.saveSearchInfo(database, tempSearchInfo) - } registerInfo?.let { regInfo -> - tempEntryInfo.saveRegisterInfo(database, regInfo) + overwrittenData = tempEntryInfo.saveRegisterInfo(database, regInfo) } entryInfo = tempEntryInfo } } } - return TemplatesEntry(isTemplate, templates, entryTemplate, entryInfo) + return TemplatesEntry( + isTemplate, + templates, + entryTemplate, + entryInfo, + overwrittenData + ) } fun changeTemplate(template: Template) { @@ -197,44 +216,52 @@ class EntryEditViewModel: NodeEditViewModel() { _requestEntryInfoUpdate.value = EntryUpdate(database, mEntry, mParent) } + fun unlockAction() { + actionLocked = false + } + fun saveEntryInfo(database: ContextualDatabase?, entry: Entry?, parent: Group?, entryInfo: EntryInfo) { - IOActionTask( - { - removeTempAttachmentsNotCompleted(entryInfo) - entry?.let { oldEntry -> - // Create a clone - var newEntry = Entry(oldEntry) + if (actionLocked.not()) { + actionLocked = true + IOActionTask( + scope = viewModelScope, + action = { + removeTempAttachmentsNotCompleted(entryInfo) + entry?.let { oldEntry -> + // Create a clone + var newEntry = Entry(oldEntry) - // Build info - newEntry.setEntryInfo(database, entryInfo) + // Build info + newEntry.setEntryInfo(database, entryInfo) - // Encode entry properties for template - _onTemplateChanged.value?.let { template -> - newEntry = - database?.encodeEntryWithTemplateConfiguration(newEntry, template) - ?: newEntry - } + // Encode entry properties for template + _onTemplateChanged.value?.let { template -> + newEntry = + database?.encodeEntryWithTemplateConfiguration(newEntry, template) + ?: newEntry + } - // Delete temp attachment if not used - mTempAttachments.forEach { tempAttachmentState -> - val tempAttachment = tempAttachmentState.attachment - database?.attachmentPool?.let { binaryPool -> - if (!newEntry.getAttachments(binaryPool).contains(tempAttachment)) { - database.removeAttachmentIfNotUsed(tempAttachment) + // Delete temp attachment if not used + mTempAttachments.forEach { tempAttachmentState -> + val tempAttachment = tempAttachmentState.attachment + database?.attachmentPool?.let { binaryPool -> + if (!newEntry.getAttachments(binaryPool).contains(tempAttachment)) { + database.removeAttachmentIfNotUsed(tempAttachment) + } } } - } - // Return entry to save - EntrySave(oldEntry, newEntry, parent) + // Return entry to save + EntrySave(oldEntry, newEntry, parent) + } + }, + onActionComplete = { entrySave -> + entrySave?.let { + _onEntrySaved.value = it + } } - }, - { entrySave -> - entrySave?.let { - _onEntrySaved.value = it - } - } - ).execute() + ).execute() + } } private fun removeTempAttachmentsNotCompleted(entryInfo: EntryInfo) { @@ -321,10 +348,13 @@ class EntryEditViewModel: NodeEditViewModel() { _onBinaryPreviewLoaded.value = AttachmentPosition(entryAttachmentState, viewPosition) } - data class TemplatesEntry(val isTemplate: Boolean, - val templates: List