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/app/build.gradle b/app/build.gradle index 3d2414fe0..6ef748520 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -35,6 +35,10 @@ android { } } + buildFeatures { + buildConfig true + } + dependenciesInfo { // Disables dependency metadata when building APKs. includeInApk = false @@ -101,6 +105,10 @@ android { buildFeatures { buildConfig true } + + packaging { + resources.excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") // necessary for bcpkix-jdk18on in crypto + } } def room_version = "2.5.1" @@ -140,6 +148,9 @@ 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') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 900abdea7..d9adc5a52 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"> @@ -159,7 +160,7 @@ @@ -173,7 +174,7 @@ android:name="com.kunzisoft.keepass.hardware.HardwareKeyActivity" android:theme="@style/Theme.Transparent" /> @@ -199,7 +200,13 @@ - + @@ -239,7 +246,7 @@ @@ -249,6 +256,22 @@ + + + + + + + diff --git a/app/src/main/assets/trustedPackages.json b/app/src/main/assets/trustedPackages.json new file mode 100644 index 000000000..8a24f37bb --- /dev/null +++ b/app/src/main/assets/trustedPackages.json @@ -0,0 +1,548 @@ +{ + "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.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" + } + ] + } + }, + { + "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": "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" + } + ] + } + } + ] +} \ No newline at end of file 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 5da1544b8..5229e7995 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt @@ -23,7 +23,6 @@ import android.content.Intent import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -38,13 +37,10 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.viewModels import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.appbar.AppBarLayout @@ -54,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 @@ -73,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 @@ -261,7 +257,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 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 49f6544c6..d7f149202 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt @@ -55,12 +55,15 @@ 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.TypeMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper +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 @@ -70,7 +73,6 @@ 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 @@ -376,18 +378,25 @@ class EntryEditActivity : DatabaseLockActivity(), // Don't wait for saving if it's to provide autofill mDatabase?.let { database -> - EntrySelectionHelper.doSpecialAction(intent, - {}, - {}, - {}, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = {}, + searchAction = {}, + saveAction = {}, + keyboardSelectionAction = { entryValidatedForKeyboardSelection(database, entrySave.newEntry) }, - { _, _ -> + autofillSelectionAction = { _, _ -> entryValidatedForAutofillSelection(database, entrySave.newEntry) }, - { + autofillRegistrationAction = { entryValidatedForAutofillRegistration(entrySave.newEntry) + }, + passkeySelectionAction = { + entryValidatedForPasskeySelection(database, entrySave.newEntry) + }, + passkeyRegistrationAction = { + entryValidatedForPasskeyRegistration(database, entrySave.newEntry) } ) } @@ -430,25 +439,32 @@ class EntryEditActivity : DatabaseLockActivity(), } if (newNodes.size == 1) { (newNodes[0] as? Entry?)?.let { entry -> - EntrySelectionHelper.doSpecialAction(intent, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = { // Finish naturally finishForEntryResult(entry) }, - { + searchAction = { // Nothing when search retrieved }, - { + saveAction = { entryValidatedForSave(entry) }, - { + keyboardSelectionAction = { entryValidatedForKeyboardSelection(database, entry) }, - { _, _ -> + autofillSelectionAction = { _, _ -> entryValidatedForAutofillSelection(database, entry) }, - { + autofillRegistrationAction = { entryValidatedForAutofillRegistration(entry) + }, + passkeySelectionAction = { + entryValidatedForPasskeySelection(database, entry) + }, + passkeyRegistrationAction = { + entryValidatedForPasskeyRegistration(database, entry) } ) } @@ -488,9 +504,33 @@ class EntryEditActivity : DatabaseLockActivity(), onValidateSpecialMode() } - private fun entryValidatedForAutofillRegistration(entry: Entry) { + private fun entryValidatedForPasskeySelection(database: ContextualDatabase, entry: Entry) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + this.buildPasskeyResponseAndSetResult( + entryInfo = entry.getEntryInfo(database) + ) + } + onValidateSpecialMode() + } + + private fun entryValidatedForAutofillRegistration(entry: Entry) { + //if (isIntentSender()) { + // TODO Autofill Callback #765 + //} + onValidateSpecialMode() + if (!isIntentSender()) { + finishForEntryResult(entry) + } + } + + private fun entryValidatedForPasskeyRegistration(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() { @@ -738,12 +778,17 @@ 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) super.finish() @@ -888,7 +933,7 @@ class EntryEditActivity : DatabaseLockActivity(), if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) { val intent = Intent(activity, EntryEditActivity::class.java) intent.putExtra(KEY_PARENT, groupId) - AutofillHelper.startActivityForAutofillResult( + EntrySelectionHelper.startActivityForAutofillSelectionModeResult( activity, intent, activityResultLauncher, @@ -899,21 +944,48 @@ class EntryEditActivity : DatabaseLockActivity(), } } + /** + * Launch EntryEditActivity to add a new passkey entry + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun launchForPasskeySelectionResult(context: Context, + database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, + groupId: NodeId<*>, + searchInfo: SearchInfo? = null) { + if (database.loaded && !database.isReadOnly) { + if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { + val intent = Intent(context, EntryEditActivity::class.java) + intent.putExtra(KEY_PARENT, groupId) + EntrySelectionHelper.startActivityForPasskeySelectionModeResult( + context, + intent, + activityResultLauncher, + searchInfo + ) + } + } + } + /** * Launch EntryEditActivity to register an updated entry (from autofill) */ fun launchToUpdateForRegistration(context: Context, database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, entryId: NodeId, - registerInfo: RegisterInfo? = null) { + registerInfo: RegisterInfo?, + typeMode: TypeMode) { if (database.loaded && !database.isReadOnly) { if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { val intent = Intent(context, EntryEditActivity::class.java) intent.putExtra(KEY_ENTRY, entryId) EntrySelectionHelper.startActivityForRegistrationModeResult( context, + activityResultLauncher, intent, - registerInfo + registerInfo, + typeMode ) } } @@ -924,16 +996,20 @@ class EntryEditActivity : DatabaseLockActivity(), */ fun launchToCreateForRegistration(context: Context, database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, groupId: NodeId<*>, - registerInfo: RegisterInfo? = null) { + registerInfo: RegisterInfo? = null, + typeMode: TypeMode) { if (database.loaded && !database.isReadOnly) { if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { val intent = Intent(context, EntryEditActivity::class.java) intent.putExtra(KEY_PARENT, groupId) EntrySelectionHelper.startActivityForRegistrationModeResult( context, + activityResultLauncher, 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..081ca8d77 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt @@ -44,15 +44,16 @@ 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.credentialprovider.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.credentialprovider.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.buildActivityResultLauncher +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.education.FileDatabaseSelectActivityEducation @@ -98,10 +99,8 @@ 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 + private var mCredentialActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -298,7 +297,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), }, { onCancelSpecialMode() }, { onLaunchActivitySpecialMode() }, - mAutofillActivityResultLauncher) + mCredentialActivityResultLauncher) } private fun launchGroupActivityIfLoaded(database: ContextualDatabase) { @@ -308,7 +307,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), { onValidateSpecialMode() }, { onCancelSpecialMode() }, { onLaunchActivitySpecialMode() }, - mAutofillActivityResultLauncher) + mCredentialActivityResultLauncher) } } @@ -487,23 +486,46 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), activityResultLauncher: ActivityResultLauncher?, autofillComponent: AutofillComponent, searchInfo: SearchInfo? = null) { - AutofillHelper.startActivityForAutofillResult(activity, + EntrySelectionHelper.startActivityForAutofillSelectionModeResult(activity, Intent(activity, FileDatabaseSelectActivity::class.java), activityResultLauncher, autofillComponent, searchInfo) } + /* + * ------------------------- + * Passkey Launch + * ------------------------- + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun launchForPasskeySelectionResult(activity: Activity, + activityResultLauncher: ActivityResultLauncher?, + searchInfo: SearchInfo? = null) { + EntrySelectionHelper.startActivityForPasskeySelectionModeResult( + activity, + Intent(activity, FileDatabaseSelectActivity::class.java), + activityResultLauncher, + searchInfo + ) + } + /* * ------------------------- * Registration Launch * ------------------------- */ fun launchForRegistration(context: Context, - registerInfo: RegisterInfo? = null) { - EntrySelectionHelper.startActivityForRegistrationModeResult(context, - Intent(context, FileDatabaseSelectActivity::class.java), - registerInfo) + activityResultLauncher: ActivityResultLauncher?, + registerInfo: RegisterInfo? = null, + typeMode: TypeMode) { + EntrySelectionHelper.startActivityForRegistrationModeResult( + context, + activityResultLauncher, + Intent(context, FileDatabaseSelectActivity::class.java), + registerInfo, + 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 03aacc6f2..eb6726cd8 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt @@ -63,13 +63,17 @@ 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.buildActivityResultLauncher +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.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 @@ -83,7 +87,6 @@ import com.kunzisoft.keepass.database.element.node.Type import com.kunzisoft.keepass.database.helper.SearchHelper 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 @@ -264,10 +267,8 @@ class GroupActivity : DatabaseLockActivity(), mGroupEditViewModel.selectIcon(icon) } - private var mAutofillActivityResultLauncher: ActivityResultLauncher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - AutofillHelper.buildActivityResultLauncher(this) - else null + private var mCredentialActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -484,59 +485,87 @@ 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 + activity = this@GroupActivity, + database = database, + groupId = currentParentGroupId, + activityResultLauncher = mEntryActivityResultLauncher ) } }, - { + searchAction = { // Search not used }, - { searchInfo -> + saveAction = { searchInfo -> EntryEditActivity.launchToCreateForSave( - this@GroupActivity, - database, - currentGroup.nodeId, - searchInfo + context = this@GroupActivity, + database = database, + groupId = currentGroup.nodeId, + searchInfo = searchInfo ) onLaunchActivitySpecialMode() }, - { searchInfo -> + keyboardSelectionAction = { searchInfo -> EntryEditActivity.launchForKeyboardSelectionResult( - this@GroupActivity, - database, - currentGroup.nodeId, - searchInfo + context = this@GroupActivity, + database = database, + groupId = currentGroup.nodeId, + searchInfo = searchInfo ) onLaunchActivitySpecialMode() }, - { searchInfo, autofillComponent -> + autofillSelectionAction = { searchInfo, autofillComponent -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { EntryEditActivity.launchForAutofillResult( - this@GroupActivity, - database, - mAutofillActivityResultLauncher, - autofillComponent, - currentGroup.nodeId, - searchInfo + activity = this@GroupActivity, + database = database, + activityResultLauncher = mCredentialActivityResultLauncher, + autofillComponent = autofillComponent, + groupId = currentGroup.nodeId, + searchInfo = searchInfo ) onLaunchActivitySpecialMode() } else { onCancelSpecialMode() } }, - { searchInfo -> + autofillRegistrationAction = { registerInfo -> EntryEditActivity.launchToCreateForRegistration( - this@GroupActivity, - database, - currentGroup.nodeId, - searchInfo + context = this@GroupActivity, + database = database, + activityResultLauncher = null, + groupId = currentGroup.nodeId, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + onLaunchActivitySpecialMode() + }, + passkeySelectionAction = { searchInfo -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + EntryEditActivity.launchForPasskeySelectionResult( + context = this@GroupActivity, + database = database, + activityResultLauncher = mCredentialActivityResultLauncher, + groupId = currentGroup.nodeId, + searchInfo = searchInfo, + ) + onLaunchActivitySpecialMode() + } else { + onCancelSpecialMode() + } + }, + passkeyRegistrationAction = { registerInfo -> + EntryEditActivity.launchToCreateForRegistration( + context = this@GroupActivity, + database = database, + activityResultLauncher = mCredentialActivityResultLauncher, + groupId = currentGroup.nodeId, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY ) onLaunchActivitySpecialMode() } @@ -679,30 +708,40 @@ 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 }, - { + saveAction = { // Save not used }, - { + keyboardSelectionAction = { // Keyboard selection entry?.let { entrySelectedForKeyboardSelection(database, it) } }, - { _, _ -> + autofillSelectionAction = { _, _ -> // Autofill selection entry?.let { entrySelectedForAutofillSelection(database, it) } }, - { + autofillRegistrationAction = { // Not use + }, + passkeySelectionAction = { + // Passkey selection + entry?.let { + entrySelectedForPasskeySelection(database, it) + } + }, + passkeyRegistrationAction = { + // TODO Passkey Registration } ) } @@ -846,27 +885,28 @@ 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 -> + saveAction = { searchInfo -> if (!database.isReadOnly) { entrySelectedForSave(database, entryVersioned, searchInfo) loadGroup() } else finish() }, - { searchInfo -> + keyboardSelectionAction = { searchInfo -> if (!database.isReadOnly && searchInfo != null && PreferencesUtil.isKeyboardSaveSearchInfoEnable(this@GroupActivity) @@ -876,7 +916,7 @@ class GroupActivity : DatabaseLockActivity(), entrySelectedForKeyboardSelection(database, entryVersioned) loadGroup() }, - { searchInfo, _ -> + autofillSelectionAction = { searchInfo, _ -> if (!database.isReadOnly && searchInfo != null && PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity) @@ -886,9 +926,39 @@ class GroupActivity : DatabaseLockActivity(), entrySelectedForAutofillSelection(database, entryVersioned) loadGroup() }, - { registerInfo -> + autofillRegistrationAction = { registerInfo -> if (!database.isReadOnly) { - entrySelectedForRegistration(database, entryVersioned, registerInfo) + entrySelectedForRegistration( + database = database, + entry = entryVersioned, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL, + activityResultLauncher = null // TODO Result launcher autofill #765 + ) + loadGroup() + } else + finish() + }, + passkeySelectionAction = { searchInfo -> + if (!database.isReadOnly + && searchInfo != null + // TODO Passkey setting && PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity) + ) { + updateEntryWithSearchInfo(database, entryVersioned, searchInfo) + } + entrySelectedForPasskeySelection(database, entryVersioned) + loadGroup() + }, + passkeyRegistrationAction = { registerInfo -> + if (!database.isReadOnly) { + // TODO Passkey setting && PreferencesUtil.isAutofillOverwriteEnable(this@GroupActivity) + entrySelectedForRegistration( + database = database, + entry = entryVersioned, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY, + activityResultLauncher = mCredentialActivityResultLauncher + ) loadGroup() } else finish() @@ -934,18 +1004,33 @@ class GroupActivity : DatabaseLockActivity(), 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() + } + 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 + context = this@GroupActivity, + database = database, + activityResultLauncher = activityResultLauncher, + entryId = entry.nodeId, + registerInfo = registerInfo, + typeMode = typeMode ) onLaunchActivitySpecialMode() } @@ -961,11 +1046,10 @@ class GroupActivity : DatabaseLockActivity(), raw = true, removeTemplateConfiguration = false ) - val modification = entryInfo.saveSearchInfo(database, searchInfo) + // TODO Transform SearchInfo in RegisterInfo + entryInfo.saveSearchInfo(database, searchInfo) newEntry.setEntryInfo(database, entryInfo) - if (modification) { - updateEntry(entry, newEntry) - } + updateEntry(entry, newEntry) } private fun finishNodeAction() { @@ -1569,19 +1653,19 @@ class GroupActivity : DatabaseLockActivity(), * ------------------------- */ @RequiresApi(api = Build.VERSION_CODES.O) - fun launchForAutofillResult(activity: AppCompatActivity, - database: ContextualDatabase, - activityResultLaunch: ActivityResultLauncher?, - autofillComponent: AutofillComponent, - searchInfo: SearchInfo? = null, - autoSearch: Boolean = false) { + fun launchForAutofillSelectionResult(activity: AppCompatActivity, + database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, + autofillComponent: AutofillComponent, + searchInfo: SearchInfo? = null, + autoSearch: Boolean = false) { if (database.loaded) { checkTimeAndBuildIntent(activity, null) { intent -> intent.putExtra(AUTO_SEARCH_KEY, autoSearch) - AutofillHelper.startActivityForAutofillResult( + EntrySelectionHelper.startActivityForAutofillSelectionModeResult( activity, intent, - activityResultLaunch, + activityResultLauncher, autofillComponent, searchInfo ) @@ -1589,21 +1673,49 @@ class GroupActivity : DatabaseLockActivity(), } } + /* + * ------------------------- + * Passkey Launch + * ------------------------- + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun launchForPasskeySelectionResult(context: Context, + database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, + searchInfo: SearchInfo? = null, + autoSearch: Boolean = false) { + if (database.loaded) { + checkTimeAndBuildIntent(context, null) { intent -> + intent.putExtra(AUTO_SEARCH_KEY, autoSearch) + EntrySelectionHelper.startActivityForPasskeySelectionModeResult( + context, + intent, + activityResultLauncher, + searchInfo + ) + } + } + } + /* * ------------------------- * Registration Launch * ------------------------- */ fun launchForRegistration(context: Context, + activityResultLauncher: ActivityResultLauncher?, database: ContextualDatabase, - registerInfo: RegisterInfo? = null) { + registerInfo: RegisterInfo? = null, + typeMode: TypeMode) { if (database.loaded && !database.isReadOnly) { checkTimeAndBuildIntent(context, null) { intent -> intent.putExtra(AUTO_SEARCH_KEY, false) EntrySelectionHelper.startActivityForRegistrationModeResult( context, + activityResultLauncher, intent, - registerInfo + registerInfo, + typeMode ) } } @@ -1619,153 +1731,232 @@ class GroupActivity : DatabaseLockActivity(), onValidateSpecialMode: () -> Unit, onCancelSpecialMode: () -> Unit, onLaunchActivitySpecialMode: () -> Unit, - autofillActivityResultLauncher: ActivityResultLauncher?) { - EntrySelectionHelper.doSpecialAction(activity.intent, - { - // Default action - launch( - activity, + 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, + searchInfo, + true) + onLaunchActivitySpecialMode() + } else { + // Simply close if database not opened + onCancelSpecialMode() + } + }, + saveAction = { searchInfo -> + // Save info + if (database.loaded) { + if (!database.isReadOnly) { + launchForSaveResult( + activity, database, searchInfo, - true) + false + ) onLaunchActivitySpecialMode() } else { - // Simply close if database not opened + Toast.makeText( + activity.applicationContext, + R.string.autofill_read_only_save, + Toast.LENGTH_LONG + ) + .show() 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 -> - MagikeyboardService.performSelection( - items, - { entryInfo -> - // Keyboard populated - MagikeyboardService.populateKeyboardAndMoveAppToBackground( - activity, - entryInfo - ) - onValidateSpecialMode() - }, - { autoSearch -> - launchForKeyboardSelectionResult(activity, - database, - searchInfo, - autoSearch) - onLaunchActivitySpecialMode() - } + } + }, + keyboardSelectionAction = { searchInfo -> + // Keyboard selection + SearchHelper.checkAutoSearchInfo( + context = activity, + database = database, + searchInfo = searchInfo, + onItemsFound = { _, items -> + MagikeyboardService.performSelection( + items, + { entryInfo -> + // Keyboard populated + MagikeyboardService.populateKeyboardAndMoveAppToBackground( + activity, + entryInfo ) + onValidateSpecialMode() }, - { - // Here no search info found, disable auto search + { autoSearch -> launchForKeyboardSelectionResult(activity, database, searchInfo, - false) + autoSearch) onLaunchActivitySpecialMode() - }, - { - // Simply close if database not opened, normally not happened - onCancelSpecialMode() } + ) + }, + onItemNotFound = { + // Here no search info found, disable auto search + launchForKeyboardSelectionResult(activity, + database, + searchInfo, + false) + onLaunchActivitySpecialMode() + }, + onDatabaseClosed = { + // Simply close if database not opened, normally not happened + onCancelSpecialMode() + } + ) + }, + autofillSelectionAction = { searchInfo, autofillComponent -> + // Autofill selection + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + SearchHelper.checkAutoSearchInfo( + context = activity, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> + // Response is build + AutofillHelper.buildResponseAndSetResult(activity, openedDatabase, items) + onValidateSpecialMode() + }, + onItemNotFound = { + // Here no search info found, disable auto search + launchForAutofillSelectionResult( + activity = activity, + database = database, + autofillComponent = autofillComponent, + searchInfo = searchInfo, + autoSearch = false, + activityResultLauncher = activityResultLauncher) + onLaunchActivitySpecialMode() + }, + onDatabaseClosed = { + // 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) + } else { + onCancelSpecialMode() + } + }, + autofillRegistrationAction = { registerInfo -> + // Autofill registration + if (!database.isReadOnly) { + SearchHelper.checkAutoSearchInfo( + context = activity, + database = database, + searchInfo = registerInfo?.searchInfo, + onItemsFound = { _, _ -> + // No auto search, it's a registration + launchForRegistration( + context = activity, + activityResultLauncher = null, // TODO Autofill result Launcher #765 + database = database, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + onLaunchActivitySpecialMode() + }, + onItemNotFound = { + // Here no search info found, disable auto search + launchForRegistration( + context = activity, + activityResultLauncher = null, // TODO Autofill result Launcher #765 + database = database, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + onLaunchActivitySpecialMode() + }, + onDatabaseClosed = { + // Simply close if database not opened, normally not happened + onCancelSpecialMode() + } + ) + } else { + Toast.makeText(activity.applicationContext, + R.string.autofill_read_only_save, + Toast.LENGTH_LONG) + .show() + onCancelSpecialMode() + } + }, + passkeySelectionAction = { searchInfo -> + // Passkey selection + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + SearchHelper.checkAutoSearchInfo( + context = activity, + database = database, + searchInfo = searchInfo, + onItemsFound = { _, items -> + // Response is build + EntrySelectionHelper.performSelection( + items = items, + actionPopulateCredentialProvider = { entryInfo -> + activity.buildPasskeyResponseAndSetResult(entryInfo) onValidateSpecialMode() }, - { - // Here no search info found, disable auto search - launchForAutofillResult(activity, - database, - autofillActivityResultLauncher, - autofillComponent, - searchInfo, - false) + actionEntrySelection = { + launchForPasskeySelectionResult( + context = activity, + database = database, + searchInfo = searchInfo, + activityResultLauncher = activityResultLauncher, + autoSearch = true + ) onLaunchActivitySpecialMode() - }, - { - // Simply close if database not opened, normally not happened - onCancelSpecialMode() } - ) - } else { - onCancelSpecialMode() - } - }, - { registerInfo -> - // Autofill registration - 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() - } - ) - } else { - Toast.makeText(activity.applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG) - .show() - onCancelSpecialMode() - } - }) + ) + }, + onItemNotFound = { + // Here no search info found, disable auto search + launchForPasskeySelectionResult( + context = activity, + database = database, + searchInfo = searchInfo, + activityResultLauncher = activityResultLauncher + ) + onLaunchActivitySpecialMode() + }, + onDatabaseClosed = { + // Simply close if database not opened, normally not happened + onCancelSpecialMode() + } + ) + } else { + onCancelSpecialMode() + } + }, + passkeyRegistrationAction = { registerInfo -> + // Passkey registration + if (!database.isReadOnly) { + launchForRegistration( + context = activity, + activityResultLauncher = activityResultLauncher, + database = database, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + onLaunchActivitySpecialMode() + } else { + Toast.makeText(activity.applicationContext, + R.string.autofill_read_only_save, + Toast.LENGTH_LONG) + .show() + onCancelSpecialMode() + } + } + ) } } } 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 cd45b54a3..b40e6b33a 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt @@ -48,16 +48,17 @@ 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.EntrySelectionHelper.buildActivityResultLauncher +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException @@ -75,8 +76,8 @@ import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion. import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.DATABASE_URI_KEY import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.MAIN_CREDENTIAL_KEY import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.READ_ONLY_KEY -import com.kunzisoft.keepass.settings.DeviceUnlockSettingsActivity import com.kunzisoft.keepass.settings.AppearanceSettingsActivity +import com.kunzisoft.keepass.settings.DeviceUnlockSettingsActivity import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.tasks.ActionRunnable import com.kunzisoft.keepass.utils.BACK_PREVIOUS_KEYBOARD_ACTION @@ -122,10 +123,8 @@ 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 + private var mCredentialActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -430,7 +429,7 @@ class MainCredentialActivity : DatabaseModeActivity() { { onValidateSpecialMode() }, { onCancelSpecialMode() }, { onLaunchActivitySpecialMode() }, - mAutofillActivityResultLauncher + mCredentialActivityResultLauncher ) } } @@ -845,14 +844,14 @@ class MainCredentialActivity : DatabaseModeActivity() { @RequiresApi(api = Build.VERSION_CODES.O) @Throws(FileNotFoundException::class) fun launchForAutofillResult(activity: AppCompatActivity, + activityResultLauncher: ActivityResultLauncher?, databaseFile: Uri, keyFile: Uri?, hardwareKey: HardwareKey?, - activityResultLauncher: ActivityResultLauncher?, autofillComponent: AutofillComponent, searchInfo: SearchInfo?) { buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> - AutofillHelper.startActivityForAutofillResult( + EntrySelectionHelper.startActivityForAutofillSelectionModeResult( activity, intent, activityResultLauncher, @@ -861,21 +860,51 @@ class MainCredentialActivity : DatabaseModeActivity() { } } + /* + * ------------------------- + * Passkey Launch + * ------------------------- + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + @Throws(FileNotFoundException::class) + fun launchForPasskeyResult(activity: Activity, + activityResultLauncher: ActivityResultLauncher?, + databaseFile: Uri, + keyFile: Uri?, + hardwareKey: HardwareKey?, + searchInfo: SearchInfo?) { + buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> + EntrySelectionHelper.startActivityForPasskeySelectionModeResult( + activity, + intent, + activityResultLauncher, + 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) + context = activity, + activityResultLauncher = activityResultLauncher, + intent = intent, + typeMode = typeMode, + registerInfo = registerInfo + ) } } @@ -891,74 +920,104 @@ class MainCredentialActivity : DatabaseModeActivity() { fileNoFoundAction: (exception: FileNotFoundException) -> Unit, onCancelSpecialMode: () -> Unit, onLaunchActivitySpecialMode: () -> Unit, - autofillActivityResultLauncher: ActivityResultLauncher?) { + activityResultLauncher: 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 + EntrySelectionHelper.doSpecialAction( + intent = activity.intent, + defaultAction = { + launch( + activity = activity, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey + ) + }, + searchAction = { searchInfo -> + launchForSearchResult( + activity = activity, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + }, + saveAction = { searchInfo -> + launchForSaveResult( + activity = activity, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + }, + keyboardSelectionAction = { searchInfo -> + launchForKeyboardResult( + activity = activity, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + }, + autofillSelectionAction = { searchInfo, autofillComponent -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + launchForAutofillResult( + activity = activity, + activityResultLauncher = activityResultLauncher, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + autofillComponent = autofillComponent, + searchInfo = searchInfo ) onLaunchActivitySpecialMode() + } else { + onCancelSpecialMode() } + }, + autofillRegistrationAction = { registerInfo -> + launchForRegistration( + activity = activity, + activityResultLauncher = activityResultLauncher, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + typeMode = TypeMode.AUTOFILL, + registerInfo = registerInfo + ) + onLaunchActivitySpecialMode() + }, + passkeySelectionAction = { searchInfo -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + launchForPasskeyResult( + activity = activity, + activityResultLauncher = activityResultLauncher, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + } else { + onCancelSpecialMode() + } + }, + passkeyRegistrationAction = { registerInfo -> + launchForRegistration( + activity = activity, + activityResultLauncher = activityResultLauncher, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + typeMode = TypeMode.PASSKEY, + registerInfo = registerInfo + ) + onLaunchActivitySpecialMode() + } ) } catch (e: FileNotFoundException) { fileNoFoundAction(e) 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..2f6e0500c 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 @@ -45,6 +45,7 @@ abstract class DatabaseDialogFragment : DialogFragment(), DatabaseRetrieval { } @Suppress("DEPRECATION") + @Deprecated(message = "") override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) 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..f5c2e81e9 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 @@ -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/fragments/EntryFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/EntryFragment.kt index a9de1526c..63b8c4601 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 @@ -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 7a7cf5acf..5dd676ea8 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,8 +36,8 @@ 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.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.adapters.NodesAdapter import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.Group 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..05a3a47e5 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 @@ -5,14 +5,14 @@ import android.os.Bundle import androidx.activity.viewModels 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.database.MainCredential import com.kunzisoft.keepass.model.CipherEncryptDatabase import com.kunzisoft.keepass.tasks.ActionRunnable import com.kunzisoft.keepass.utils.getBinaryDir import com.kunzisoft.keepass.viewmodels.DatabaseViewModel -abstract class DatabaseActivity: StylishActivity(), DatabaseRetrieval { +abstract class DatabaseActivity : StylishActivity(), DatabaseRetrieval { protected val mDatabaseViewModel: DatabaseViewModel by viewModels() protected var mDatabaseTaskProvider: DatabaseTaskProvider? = null @@ -77,7 +77,13 @@ abstract class DatabaseActivity: StylishActivity(), DatabaseRetrieval { cipherEncryptDatabase: CipherEncryptDatabase?, fixDuplicateUuid: Boolean ) { - mDatabaseTaskProvider?.startDatabaseLoad(databaseUri, mainCredential, readOnly, cipherEncryptDatabase, fixDuplicateUuid) + mDatabaseTaskProvider?.startDatabaseLoad( + databaseUri, + mainCredential, + readOnly, + cipherEncryptDatabase, + fixDuplicateUuid + ) } protected fun closeDatabase() { 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..ba30736a0 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 +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 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..ba1a200ee 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 @@ -5,9 +5,11 @@ import android.view.View import android.widget.Toast import androidx.activity.OnBackPressedCallback 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 +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.isIntentSenderMode +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 @@ -42,14 +44,8 @@ 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() { @@ -118,7 +114,8 @@ abstract class DatabaseModeActivity : DatabaseActivity() { mSpecialMode = EntrySelectionHelper.retrieveSpecialModeFromIntent(intent) mTypeMode = EntrySelectionHelper.retrieveTypeModeFromIntent(intent) - val searchInfo: SearchInfo? = EntrySelectionHelper.retrieveRegisterInfoFromIntent(intent)?.searchInfo + val registerInfo: RegisterInfo? = EntrySelectionHelper.retrieveRegisterInfoFromIntent(intent) + val searchInfo: SearchInfo? = registerInfo?.searchInfo ?: EntrySelectionHelper.retrieveSearchInfoFromIntent(intent) // To show the selection mode @@ -136,12 +133,13 @@ abstract class DatabaseModeActivity : DatabaseActivity() { 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) { 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..67b2f7095 100644 --- a/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt +++ b/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt @@ -418,6 +418,7 @@ class NodesAdapter ( } } + // OTP val otpElement = entry.getOtpElement() holder.otpContainer?.removeCallbacks(holder.otpRunnable) if (otpElement != null @@ -438,7 +439,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 +456,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 +465,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 +474,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 +619,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/activities/helpers/EntrySelectionHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt similarity index 54% rename from app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt index 31a988772..6625420f4 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt @@ -17,17 +17,32 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.activities.helpers +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 com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper +import android.util.Log +import android.widget.RemoteViews +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper.addAutofillComponent +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.getParcelableExtraCompat +import com.kunzisoft.keepass.settings.PreferencesUtil +import com.kunzisoft.keepass.utils.LOCK_ACTION import com.kunzisoft.keepass.utils.getEnumExtra +import com.kunzisoft.keepass.utils.getParcelableExtraCompat import com.kunzisoft.keepass.utils.putEnumExtra object EntrySelectionHelper { @@ -37,6 +52,33 @@ object EntrySelectionHelper { private const val KEY_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO" private const val KEY_REGISTER_INFO = "com.kunzisoft.keepass.extra.REGISTER_INFO" + /** + * Utility method to build a registerForActivityResult, + * Used recursively, close each activity with return data + */ + fun AppCompatActivity.buildActivityResultLauncher( + lockDatabase: Boolean = false, + dataTransformation: (data: Intent?) -> Intent? = { it }, + ): ActivityResultLauncher { + return this.registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + val resultCode = it.resultCode + if (resultCode == Activity.RESULT_OK) { + this.setResult(resultCode, dataTransformation(it.data)) + } + if (resultCode == Activity.RESULT_CANCELED) { + this.setResult(Activity.RESULT_CANCELED) + } + this.finish() + + if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(this)) { + // Close the database + this.sendBroadcast(Intent(LOCK_ACTION)) + } + } + } + fun startActivityForSearchModeResult(context: Context, intent: Intent, searchInfo: SearchInfo) { @@ -66,15 +108,52 @@ object EntrySelectionHelper { context.startActivity(intent) } - fun startActivityForRegistrationModeResult(context: Context, - intent: Intent, - registerInfo: RegisterInfo?) { - addSpecialModeInIntent(intent, SpecialMode.REGISTRATION) - // At the moment, only autofill for registration + /** + * Utility method to start an activity with an Autofill for result + */ + @RequiresApi(Build.VERSION_CODES.O) + fun startActivityForAutofillSelectionModeResult( + context: Context, + intent: Intent, + activityResultLauncher: ActivityResultLauncher?, + autofillComponent: AutofillComponent, + searchInfo: SearchInfo? + ) { + addSpecialModeInIntent(intent, SpecialMode.SELECTION) addTypeModeInIntent(intent, TypeMode.AUTOFILL) + intent.addAutofillComponent(context, autofillComponent) + addSearchInfoInIntent(intent, searchInfo) + activityResultLauncher?.launch(intent) + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun startActivityForPasskeySelectionModeResult( + context: Context, + intent: Intent, + activityResultLauncher: ActivityResultLauncher?, + searchInfo: SearchInfo? + ) { + addSpecialModeInIntent(intent, SpecialMode.SELECTION) + addTypeModeInIntent(intent, TypeMode.PASSKEY) + addSearchInfoInIntent(intent, searchInfo) + activityResultLauncher?.launch(intent) + } + + fun startActivityForRegistrationModeResult( + context: Context?, + activityResultLauncher: ActivityResultLauncher?, + intent: Intent, + registerInfo: RegisterInfo?, + typeMode: TypeMode + ) { + addSpecialModeInIntent(intent, SpecialMode.REGISTRATION) + addTypeModeInIntent(intent, typeMode) addRegisterInfoInIntent(intent, registerInfo) - intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) + if (activityResultLauncher == null) { + intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + activityResultLauncher?.launch(intent) ?: context?.startActivity(intent) ?: + throw IllegalStateException("At least Context or ActivityResultLauncher must not be null") } fun addSearchInfoInIntent(intent: Intent, searchInfo: SearchInfo?) { @@ -103,8 +182,13 @@ object EntrySelectionHelper { } fun addSpecialModeInIntent(intent: Intent, specialMode: SpecialMode) { + // TODO Replace by Intent.addSpecialMode intent.putEnumExtra(KEY_SPECIAL_MODE, specialMode) } + fun Intent.addSpecialMode(specialMode: SpecialMode): Intent { + this.putEnumExtra(KEY_SPECIAL_MODE, specialMode) + return this + } fun retrieveSpecialModeFromIntent(intent: Intent): SpecialMode { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -115,8 +199,13 @@ object EntrySelectionHelper { } private fun addTypeModeInIntent(intent: Intent, typeMode: TypeMode) { + // TODO Replace by Intent.addTypeMode intent.putEnumExtra(KEY_TYPE_MODE, typeMode) } + fun Intent.addTypeMode(typeMode: TypeMode): Intent { + this.putEnumExtra(KEY_TYPE_MODE, typeMode) + return this + } fun retrieveTypeModeFromIntent(intent: Intent): TypeMode { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -131,6 +220,17 @@ object EntrySelectionHelper { intent.removeExtra(KEY_TYPE_MODE) } + /** + * 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)) + // TODO Autofill Registration callback #765 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + || (specialMode == SpecialMode.REGISTRATION + && typeMode == TypeMode.PASSKEY) + } + fun doSpecialAction(intent: Intent, defaultAction: () -> Unit, searchAction: (searchInfo: SearchInfo) -> Unit, @@ -138,7 +238,9 @@ object EntrySelectionHelper { keyboardSelectionAction: (searchInfo: SearchInfo?) -> Unit, autofillSelectionAction: (searchInfo: SearchInfo?, autofillComponent: AutofillComponent) -> Unit, - autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) { + autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit, + passkeySelectionAction: (searchInfo: SearchInfo?) -> Unit, + passkeyRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) { when (retrieveSpecialModeFromIntent(intent)) { SpecialMode.DEFAULT -> { @@ -186,6 +288,7 @@ object EntrySelectionHelper { defaultAction.invoke() } TypeMode.MAGIKEYBOARD -> keyboardSelectionAction.invoke(searchInfo) + TypeMode.PASSKEY -> passkeySelectionAction.invoke(searchInfo) else -> { // In this case, error removeModesFromIntent(intent) @@ -202,10 +305,59 @@ object EntrySelectionHelper { } SpecialMode.REGISTRATION -> { val registerInfo: RegisterInfo? = retrieveRegisterInfoFromIntent(intent) - removeModesFromIntent(intent) - removeInfoFromIntent(intent) - autofillRegistrationAction.invoke(registerInfo) + if (!isIntentSenderMode( + specialMode = retrieveSpecialModeFromIntent(intent), + typeMode = retrieveTypeModeFromIntent(intent)) + ) { + removeModesFromIntent(intent) + removeInfoFromIntent(intent) + } + when (retrieveTypeModeFromIntent(intent)) { + TypeMode.AUTOFILL -> { + autofillRegistrationAction.invoke(registerInfo) + } + TypeMode.PASSKEY -> { + passkeyRegistrationAction.invoke(registerInfo) + } + else -> { + // Do other registration type + } + } } } } + + 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 Icon.createWithBitmap(bitmap) + } + } 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 65% 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..e9b11771d 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,4 +1,4 @@ -package com.kunzisoft.keepass.activities.helpers +package com.kunzisoft.keepass.credentialprovider enum class SpecialMode { DEFAULT, 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..6dc5a443c --- /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, AUTOFILL, PASSKEY +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/AutofillLauncherActivity.kt similarity index 76% rename from app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/AutofillLauncherActivity.kt index f69d1475f..c14a21fa0 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/AutofillLauncherActivity.kt @@ -17,9 +17,8 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.activities +package com.kunzisoft.keepass.credentialprovider.activity -import android.app.Activity import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -30,13 +29,17 @@ 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.FileDatabaseSelectActivity +import com.kunzisoft.keepass.activities.GroupActivity 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.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher +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.CompatInlineSuggestionsRequest +import com.kunzisoft.keepass.credentialprovider.autofill.KeeAutofillService import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.helper.SearchHelper import com.kunzisoft.keepass.model.RegisterInfo @@ -48,10 +51,8 @@ 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 + private var mCredentialActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher(lockDatabase = true) override fun applyCustomStyle(): Boolean { return false @@ -72,7 +73,9 @@ class AutofillLauncherActivity : DatabaseModeActivity() { // To pass extra inline request var compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - compatInlineSuggestionsRequest = bundle.getParcelableCompat(KEY_INLINE_SUGGESTION) + compatInlineSuggestionsRequest = bundle.getParcelableCompat( + KEY_INLINE_SUGGESTION + ) } // Build search param bundle.getParcelableCompat(KEY_SEARCH_INFO)?.let { searchInfo -> @@ -102,7 +105,9 @@ class AutofillLauncherActivity : DatabaseModeActivity() { } SpecialMode.REGISTRATION -> { // To register info - val registerInfo = intent.getParcelableExtraCompat(KEY_REGISTER_INFO) + val registerInfo = intent.getParcelableExtraCompat( + KEY_REGISTER_INFO + ) val searchInfo = SearchInfo(registerInfo?.searchInfo) WebDomain.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain -> searchInfo.webDomain = concreteWebDomain @@ -111,7 +116,7 @@ class AutofillLauncherActivity : DatabaseModeActivity() { } else -> { // Not an autofill call - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) finish() } } @@ -122,7 +127,7 @@ class AutofillLauncherActivity : DatabaseModeActivity() { autofillComponent: AutofillComponent?, searchInfo: SearchInfo) { if (autofillComponent == null) { - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) finish() } else if (KeeAutofillService.autofillAllowedFor( applicationId = searchInfo.applicationId, @@ -130,34 +135,39 @@ class AutofillLauncherActivity : DatabaseModeActivity() { context = this )) { // If database is open - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, items -> + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> // Items found AutofillHelper.buildResponseAndSetResult(this, openedDatabase, items) finish() }, - { openedDatabase -> + onItemNotFound = { openedDatabase -> // Show the database UI to select the entry - GroupActivity.launchForAutofillResult(this, + GroupActivity.launchForAutofillSelectionResult( + this, openedDatabase, - mAutofillActivityResultLauncher, + mCredentialActivityResultLauncher, autofillComponent, searchInfo, - false) + false + ) }, - { + onDatabaseClosed = { // If database not open - FileDatabaseSelectActivity.launchForAutofillResult(this, - mAutofillActivityResultLauncher, + FileDatabaseSelectActivity.launchForAutofillResult( + this, + mCredentialActivityResultLauncher, autofillComponent, - searchInfo) + searchInfo + ) } ) } else { showBlockRestartMessage() - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) finish() } } @@ -171,38 +181,51 @@ class AutofillLauncherActivity : DatabaseModeActivity() { context = this )) { val readOnly = database?.isReadOnly != false - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, _ -> + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, _ -> if (!readOnly) { // Show the database UI to select the entry - GroupActivity.launchForRegistration(this, - openedDatabase, - registerInfo) + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = null, // TODO Autofill result launcher #765 + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) } else { showReadOnlySaveMessage() } }, - { openedDatabase -> + onItemNotFound = { openedDatabase -> if (!readOnly) { // Show the database UI to select the entry - GroupActivity.launchForRegistration(this, - openedDatabase, - registerInfo) + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = null, // TODO Autofill result launcher #765 + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) } else { showReadOnlySaveMessage() } }, - { + onDatabaseClosed = { // If database not open - FileDatabaseSelectActivity.launchForRegistration(this, - registerInfo) + FileDatabaseSelectActivity.launchForRegistration( + context = this, + activityResultLauncher = null, // TODO Autofill result launcher #765 + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) } ) } else { showBlockRestartMessage() - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) } finish() } 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 59% 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..2c1bdb4f5 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 androidx.core.net.toUri 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.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.database.ContextualDatabase 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.utils.getParcelableCompat /** * Activity to search or select entry in database, @@ -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) @@ -121,87 +123,105 @@ 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( + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { 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, - false) - } else { - Toast.makeText(applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG) - .show() + autoSearch + ) } - } else if (searchShareForMagikeyboard) { - MagikeyboardService.performSelection( - items, - { entryInfo -> - // Automatically populate keyboard - MagikeyboardService.populateKeyboardAndMoveAppToBackground( - this, - entryInfo - ) - }, - { autoSearch -> - GroupActivity.launchForKeyboardSelectionResult(this, - openedDatabase, - searchInfo, - autoSearch) - } + ) + } else { + GroupActivity.launchForSearchResult( + this, + openedDatabase, + searchInfo, + true + ) + } + }, + onItemNotFound = { openedDatabase -> + // Show the database UI to select the entry + if (searchInfo.otpString != null) { + if (!readOnly) { + GroupActivity.launchForSaveResult( + this, + openedDatabase, + searchInfo, + false ) } else { - GroupActivity.launchForSearchResult(this, - openedDatabase, - searchInfo, - true) - } - }, - { 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) { - 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) + Toast.makeText(applicationContext, + R.string.autofill_read_only_save, + Toast.LENGTH_LONG) + .show() } + } else if (searchShareForMagikeyboard) { + GroupActivity.launchForKeyboardSelectionResult( + this, + openedDatabase, + searchInfo, + false + ) + } else { + GroupActivity.launchForSearchResult( + this, + openedDatabase, + searchInfo, + false + ) } + }, + onDatabaseClosed = { + // If database not open + if (searchInfo.otpString != null) { + FileDatabaseSelectActivity.launchForSaveResult( + this, + searchInfo + ) + } else if (searchShareForMagikeyboard) { + FileDatabaseSelectActivity.launchForKeyboardSelectionResult( + this, + searchInfo + ) + } else { + FileDatabaseSelectActivity.launchForSearchResult( + this, + searchInfo + ) + } + } ) } 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..7239038dc --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -0,0 +1,400 @@ +/* + * 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.util.Log +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.RequiresApi +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.PendingIntentHandler +import androidx.lifecycle.lifecycleScope +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.addSpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addTypeMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAppOrigin +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAuthCode +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addNodeId +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addSearchInfo +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.retrieveNodeId +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.PasskeyHelper.retrieveSearchInfo +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.AppOrigin +import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.model.RegisterInfo +import com.kunzisoft.keepass.model.SearchInfo +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch +import java.io.IOException +import java.io.InvalidObjectException +import java.util.UUID + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeyLauncherActivity : DatabaseModeActivity() { + + private var mUsageParameters: PublicKeyCredentialUsageParameters? = null + private var mCreationParameters: PublicKeyCredentialCreationParameters? = null + private var mPasskey: Passkey? = null + + private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher( + lockDatabase = true, + dataTransformation = { intent -> + // Build a new formatted response from the selection response + val responseIntent = Intent() + try { + 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 + ) + ) + ) + } ?: run { + throw IOException("Usage parameters is null") + } + } catch (e: Exception) { + Log.e(TAG, "Unable to create selection response for passkey", e) + showError(e) + } + // Return the response + responseIntent + } + ) + + private var mPasskeyRegistrationActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher( + lockDatabase = true, + dataTransformation = { intent -> + // Build a new formatted response from the creation response + val responseIntent = Intent() + try { + 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 + ) + ) + } + } else { + throw SecurityException("Passkey was modified before registration") + } + } catch (e: Exception) { + Log.e(TAG, "Unable to create registration response for passkey", e) + showError(e) + } + responseIntent + } + ) + + override fun applyCustomStyle(): Boolean { + return false + } + + override fun finishActivityIfReloadRequested(): Boolean { + return false + } + + override fun onDatabaseRetrieved(database: ContextualDatabase?) { + super.onDatabaseRetrieved(database) + + lifecycleScope.launch(CoroutineExceptionHandler { _, e -> + Log.e(TAG, "Passkey launch error", e) + showError(e) + setResult(RESULT_CANCELED) + finish() + }) { + val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo() + val appOrigin = intent.retrieveAppOrigin() ?: AppOrigin(verified = false) + val nodeId = intent.retrieveNodeId() + checkSecurity(intent, nodeId) + when (mSpecialMode) { + SpecialMode.SELECTION -> { + launchSelection(database, nodeId, searchInfo, appOrigin) + } + SpecialMode.REGISTRATION -> { + // TODO Registration in predefined group + // launchRegistration(database, nodeId, mSearchInfo) + launchRegistration(database, null, searchInfo) + } + else -> { + throw InvalidObjectException("Passkey launch mode not supported") + } + } + } + } + + private fun autoSelectPasskeyAndSetResult( + database: ContextualDatabase?, + nodeId: UUID, + appOrigin: AppOrigin + ) { + mUsageParameters?.let { usageParameters -> + // To get the passkey from the database + val passkey = database + ?.getEntryById(NodeIdUUID(nodeId)) + ?.getEntryInfo(database) + ?.passkey + ?: throw GetCredentialUnknownException("No passkey with nodeId $nodeId found") + + val result = Intent() + PendingIntentHandler.setGetCredentialResponse( + result, + GetCredentialResponse( + buildPasskeyPublicKeyCredential( + requestOptions = usageParameters.publicKeyCredentialRequestOptions, + clientDataResponse = getVerifiedGETClientDataResponse( + usageParameters = usageParameters, + appOrigin = appOrigin + ), + passkey = passkey + ) + ) + ) + setResult(RESULT_OK, result) + finish() + } ?: run { + Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") + setResult(RESULT_CANCELED) + finish() + } + } + + private suspend fun launchSelection( + database: ContextualDatabase?, + nodeId: UUID?, + searchInfo: SearchInfo, + appOrigin: AppOrigin + ) { + Log.d(TAG, "Launch passkey selection") + retrievePasskeyUsageRequestParameters( + intent = intent, + assetManager = assets + ) { usageParameters -> + // Save the requested parameters + mUsageParameters = usageParameters + // Manage the passkey to use + nodeId?.let { nodeId -> + autoSelectPasskeyAndSetResult(database, nodeId, appOrigin) + } ?: run { + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { _, _ -> + Log.w( + TAG, "Passkey found for auto selection, should not append," + + "use PasskeyProviderService instead" + ) + finish() + }, + onItemNotFound = { openedDatabase -> + Log.d( + TAG, "No Passkey found for selection," + + "launch manual selection in opened database" + ) + GroupActivity.launchForPasskeySelectionResult( + context = this, + database = openedDatabase, + activityResultLauncher = mPasskeySelectionActivityResultLauncher, + searchInfo = null, + autoSearch = false + ) + }, + onDatabaseClosed = { + Log.d(TAG, "Manual passkey selection in closed database") + FileDatabaseSelectActivity.launchForPasskeySelectionResult( + activity = this, + activityResultLauncher = mPasskeySelectionActivityResultLauncher, + searchInfo = searchInfo, + ) + } + ) + } + } + } + + private fun autoRegisterPasskeyAndSetResult( + database: ContextualDatabase?, + nodeId: UUID, + passkey: Passkey + ) { + // TODO Overwrite and Register in a predefined group + mCreationParameters?.let { creationParameters -> + // To set the passkey to the database + setResult(RESULT_OK) + finish() + } ?: run { + Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") + setResult(RESULT_CANCELED) + finish() + } + } + + private suspend fun launchRegistration( + database: ContextualDatabase?, + nodeId: UUID?, + searchInfo: SearchInfo + ) { + Log.d(TAG, "Launch passkey registration") + retrievePasskeyCreationRequestParameters( + intent = intent, + assetManager = assets, + 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 = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, _ -> + Log.w(TAG, "Passkey found for registration, " + + "but launch manual registration for a new entry") + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onItemNotFound = { openedDatabase -> + Log.d(TAG, "Launch new manual registration in opened database") + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onDatabaseClosed = { + Log.d(TAG, "Manual passkey registration in closed database") + FileDatabaseSelectActivity.launchForRegistration( + context = this, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + } + ) + } + } + ) + } + + private fun showError(e: Throwable) { + Toast.makeText(this, e.localizedMessage, Toast.LENGTH_LONG).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, + (Math.random() * Integer.MAX_VALUE).toInt(), + 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/autofill/AutofillComponent.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillComponent.kt similarity index 78% rename from app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillComponent.kt index bf8a2996f..3a4097cee 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillComponent.kt @@ -1,4 +1,4 @@ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.app.assist.AssistStructure 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 88% 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..e93039556 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,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.Activity @@ -40,17 +40,13 @@ 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,7 +54,6 @@ 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.getParcelableExtraCompat import kotlin.math.min @@ -263,7 +258,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,23 +289,6 @@ 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, @@ -353,7 +331,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) }) @@ -534,7 +512,9 @@ object AutofillHelper { 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) + 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() } @@ -558,45 +538,14 @@ object AutofillHelper { } } - 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) + fun Intent.addAutofillComponent(context: Context, autofillComponent: AutofillComponent) { + this.putExtra(EXTRA_ASSIST_STRUCTURE, autofillComponent.assistStructure) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R - && PreferencesUtil.isAutofillInlineSuggestionsEnable(activity)) { + && PreferencesUtil.isAutofillInlineSuggestionsEnable(context)) { autofillComponent.compatInlineSuggestionsRequest?.let { - intent.putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it) + this.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 92% 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..9d832e3e1 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 @@ -143,25 +143,28 @@ class KeeAutofillService : AutofillService() { parseResult: StructureParser.Result, inlineSuggestionsRequest: CompatInlineSuggestionsRequest?, callback: FillCallback) { - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, items -> - callback.onSuccess( - AutofillHelper.buildResponse(this, openedDatabase, - items, parseResult, inlineSuggestionsRequest) + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { 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) - } + ) + }, + onItemNotFound = { openedDatabase -> + // Show UI if no search result + showUIForEntrySelection(parseResult, openedDatabase, + searchInfo, inlineSuggestionsRequest, callback) + }, + onDatabaseClosed = { + // Show UI if database not open + showUIForEntrySelection(parseResult, null, + searchInfo, inlineSuggestionsRequest, callback) + } ) } @@ -385,19 +388,21 @@ class KeeAutofillService : AutofillService() { // Show UI to save data val registerInfo = RegisterInfo( - SearchInfo().apply { - applicationId = parseResult.applicationId - webDomain = parseResult.webDomain - webScheme = parseResult.webScheme - }, - parseResult.usernameValue?.textValue?.toString(), - parseResult.passwordValue?.textValue?.toString(), + searchInfo = SearchInfo().apply { + applicationId = parseResult.applicationId + webDomain = parseResult.webDomain + webScheme = parseResult.webScheme + }, + username = parseResult.usernameValue?.textValue?.toString(), + password = parseResult.passwordValue?.textValue?.toString(), + creditCard = CreditCard( parseResult.creditCardHolder, parseResult.creditCardNumber, expiration, parseResult.cardVerificationValue - )) + ) + ) // TODO Callback in each activity #765 //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 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..cfbda6df3 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 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..459d7a6a8 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,9 @@ 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.activity.EntrySelectionLauncherActivity import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.DatabaseTaskProvider import com.kunzisoft.keepass.database.element.Field @@ -324,9 +324,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 +341,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 +362,11 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL } ) }, - { + onItemNotFound = { // Select if not found launchEntrySelection(searchInfo) }, - { + onDatabaseClosed = { // Select if database not opened removeEntryInfo() launchEntrySelection(searchInfo) @@ -463,21 +464,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, 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..6f6bcb652 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -0,0 +1,355 @@ +/* + * 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.helper.SearchHelper +import com.kunzisoft.keepass.model.SearchInfo +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 + + 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) + } + } + + override fun onDestroy() { + mDatabaseTaskProvider?.unregisterProgressTask() + super.onDestroy() + } + + private fun buildPasskeySearchInfo(relyingParty: String): SearchInfo { + return SearchInfo().apply { + this.relyingParty = relyingParty + this.isAPasskeySearch = true + this.query = relyingParty + } + } + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + Log.d(javaClass.simpleName, "onBeginGetCredentialRequest called") + try { + processGetCredentialsRequest(request)?.let { response -> + callback.onResult(response) + } ?: run { + callback.onError(GetCredentialUnknownException()) + } + } catch (e: Exception) { + Log.e(javaClass.simpleName, "onBeginGetCredentialRequest error", e) + callback.onError(GetCredentialUnknownException()) + } + } + + private fun processGetCredentialsRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse? { + val credentialEntries: MutableList = mutableListOf() + + for (option in request.beginGetCredentialOptions) { + when (option) { + is BeginGetPublicKeyCredentialOption -> { + credentialEntries.addAll( + populatePasskeyData(option) + ) + return BeginGetCredentialResponse(credentialEntries) + } + } + } + Log.w(javaClass.simpleName, "unknown beginGetCredentialOption") + return null + } + + private fun populatePasskeyData(option: BeginGetPublicKeyCredentialOption): List { + + 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 = true + ) + ) + } + } + }, + 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_locked_database_username), + displayName = getString(R.string.passkey_selection_description), + icon = defaultIcon, + pendingIntent = pendingIntent, + beginGetPublicKeyCredentialOption = option, + lastUsedTime = Instant.now(), + isAutoSelectAllowed = false + ) + ) + } + }, + 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_locked_database_username), + displayName = getString(R.string.passkey_locked_database_description), + icon = defaultIcon, + pendingIntent = pendingIntent, + beginGetPublicKeyCredentialOption = option, + lastUsedTime = Instant.now(), + isAutoSelectAllowed = true + ) + ) + } + } + ) + return passkeyEntries + } + + override fun onBeginCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + Log.d(javaClass.simpleName, "onBeginCreateCredentialRequest called") + try { + processCreateCredentialRequest(request)?.let { response -> + callback.onResult(response) + } ?: let { + callback.onError(CreateCredentialUnknownException()) + } + } catch (e: Exception) { + Log.e(javaClass.simpleName, "onBeginCreateCredentialRequest error", e) + callback.onError(CreateCredentialUnknownException()) + } + } + + private fun processCreateCredentialRequest(request: BeginCreateCredentialRequest): BeginCreateCredentialResponse? { + when (request) { + is BeginCreatePublicKeyCredentialRequest -> { + // Request is passkey type + return handleCreatePasskeyQuery(request) + } + } + // request type not supported + Log.w(javaClass.simpleName, "unknown type of BeginCreateCredentialRequest") + return null + } + + 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): BeginCreateCredentialResponse { + + val accountName = mDatabase?.name ?: getString(R.string.passkey_locked_database_username) + 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 CreateCredentialUnknownException( + "Unable to register or overwrite a passkey in a database that is read only" + ) + } 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 + ) + ) + ) + } + }*/ + } + }, + onItemNotFound = { database -> + // To create a new entry + if (database.isReadOnly) { + throw CreateCredentialUnknownException( + "Unable to register a new passkey in a database that is read only" + ) + } else { + createEntries.addPendingIntentCreationNewEntry(accountName, searchInfo) + } + }, + 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) + ) + ) + } + } + ) + + return BeginCreateCredentialResponse(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/AuthenticatorAssertionResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAssertionResponse.kt new file mode 100644 index 000000000..3e22ee94b --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAssertionResponse.kt @@ -0,0 +1,66 @@ +/* + * 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 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 { + signature = Signature.sign(privateKey, dataToSign()) + ?: 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..2f128d1d3 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoDataTypes.kt @@ -0,0 +1,40 @@ +/* + * 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 +) + +data class PublicKeyCredentialParameters(val type: String, val alg: Long) + +data class PublicKeyCredentialDescriptor( + val type: String, + val id: ByteArray, + val transports: List +) + +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..304fba835 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt @@ -0,0 +1,29 @@ +/* + * 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, // TODO Equals Hashcode + val signatureKey: Pair, + val clientDataResponse: ClientDataResponse +) \ 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..c59541ad7 --- /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, + val 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..d07412cad --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt @@ -0,0 +1,606 @@ +/* + * 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.Intent +import android.content.res.AssetManager +import android.os.Build +import android.os.Bundle +import android.os.ParcelUuid +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +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.HashManager.getApplicationFingerprints +import com.kunzisoft.encrypt.Signature +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.model.AndroidOrigin +import com.kunzisoft.keepass.model.AppOrigin +import com.kunzisoft.keepass.model.EntryInfo +import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.model.WebOrigin +import com.kunzisoft.keepass.utils.StringUtil.toHexString +import com.kunzisoft.keepass.utils.getParcelableExtraCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +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_SEARCH_INFO = "com.kunzisoft.keepass.extra.searchInfo" + private const val EXTRA_APP_ORIGIN = "com.kunzisoft.keepass.extra.appOrigin" + private const val EXTRA_NODE_ID = "com.kunzisoft.keepass.extra.nodeId" + 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() + + /** + * Build the Passkey response for one entry + */ + fun Activity.buildPasskeyResponseAndSetResult( + entryInfo: EntryInfo, + extras: Bundle? = null + ) { + try { + entryInfo.passkey?.let { + val mReplyIntent = Intent() + Log.d(javaClass.name, "Success Passkey manual selection") + mReplyIntent.putExtra(EXTRA_PASSKEY, entryInfo.passkey) + mReplyIntent.putExtra(EXTRA_APP_ORIGIN, entryInfo.appOrigin) + extras?.let { + mReplyIntent.putExtras(it) + } + setResult(Activity.RESULT_OK, mReplyIntent) + } ?: run { + Log.w(javaClass.name, "Failed Passkey manual selection") + setResult(Activity.RESULT_CANCELED) + } + } catch (e: Exception) { + Log.e(javaClass.name, "Cant add passkey entry as result", e) + setResult(Activity.RESULT_CANCELED) + } + } + + /** + * 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() + ) + }) + } + + /** + * 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 search info to the intent + */ + fun Intent.addSearchInfo(searchInfo: SearchInfo?) { + searchInfo?.let { + putExtra(EXTRA_SEARCH_INFO, searchInfo) + } + } + + /** + * Retrieve the search info from the intent + */ + fun Intent.retrieveSearchInfo(): SearchInfo? { + return this.getParcelableExtraCompat(EXTRA_SEARCH_INFO) + } + + /** + * 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) + } + + /** + * Add the node id to the intent, useful for auto passkey selection + */ + 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 + } + + /** + * 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 (e: 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 list of the trustedPackages.json file, + * 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?, + assets: AssetManager, + relyingParty: String, + onOriginRetrieved: (appOrigin: AppOrigin, clientDataHash: ByteArray) -> Unit, + onOriginNotRetrieved: (appOrigin: AppOrigin, androidOriginString: String) -> Unit + ) { + if (callingAppInfo == null) { + throw SecurityException("Calling app info cannot be retrieved") + } + withContext(Dispatchers.IO) { + var callOrigin: String? + val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use { + it.readText() + } + // for trusted browsers like Chrome and Firefox + callOrigin = callingAppInfo.getOrigin(privilegedAllowlist)?.removeSuffix("/") + val androidOrigin = AndroidOrigin( + packageName = callingAppInfo.packageName, + fingerprint = callingAppInfo.signingInfo.getApplicationFingerprints() + ) + val webOrigin = WebOrigin.fromRelyingParty( + relyingParty = relyingParty + ) + // Check if the webDomain is validated for the + 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.toAndroidOrigin() + ) + } + } + } + } + + /** + * Generate a credential id randomly + */ + 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 + * [assetManager] has been transferred to the origin manager to manage package verification files + * [passkeyCreated] is called asynchronously when the passkey has been created + */ + suspend fun retrievePasskeyCreationRequestParameters( + intent: Intent, + assetManager: AssetManager, + passkeyCreated: (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 + ) + + // create new entry in database + getOrigin( + providedClientDataHash = clientDataHash, + callingAppInfo = callingAppInfo, + assets = assetManager, + relyingParty = relyingParty, + 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 + ): 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 = BACKUP_ELIGIBILITY, + backupState = false, // TODO Setting to add a backup manually #2135 + 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 + * [assetManager] has been transferred to the origin manager 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, + assetManager: AssetManager, + result: (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, + assets = assetManager, + relyingParty = requestOptions.rpId, + 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 + ): PublicKeyCredential { + val getCredentialResponse = FidoPublicKeyCredential( + id = passkey.credentialId, + response = AuthenticatorAssertionResponse( + requestOptions = requestOptions, + userPresent = true, + userVerified = true, + backupEligibility = BACKUP_ELIGIBILITY, + backupState = false, // TODO Setting to add a backup manually #2135 + 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) + ) + } + } + + private const val BACKUP_ELIGIBILITY = true +} \ 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 cd38f0046..f784056bc 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt @@ -108,14 +108,19 @@ class DatabaseTaskProvider( ) { // To show dialog only if context is an activity - private var activity: FragmentActivity? = try { context as? FragmentActivity? } - catch (_: Exception) { null } + 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 + var onActionFinish: (( + database: ContextualDatabase, + actionTask: String, + result: ActionRunnable.Result + ) -> Unit)? = null private var intentDatabaseTask: Intent = Intent( context.applicationContext, @@ -141,7 +146,7 @@ class DatabaseTaskProvider( this.databaseChangedDialogFragment = null } - private val actionTaskListener = object: DatabaseTaskNotificationService.ActionTaskListener { + private val actionTaskListener = object : DatabaseTaskNotificationService.ActionTaskListener { override fun onActionStarted( database: ContextualDatabase, progressMessage: ProgressMessage @@ -175,13 +180,14 @@ class DatabaseTaskProvider( } } - private val mActionDatabaseListener = object: DatabaseChangedDialogFragment.ActionDatabaseChangedListener { - override fun validateDatabaseChanged() { - mBinder?.getService()?.saveDatabaseInfo() + private val mActionDatabaseListener = + object : DatabaseChangedDialogFragment.ActionDatabaseChangedListener { + override fun validateDatabaseChanged() { + mBinder?.getService()?.saveDatabaseInfo() + } } - } - private var databaseInfoListener = object: + private var databaseInfoListener = object : DatabaseTaskNotificationService.DatabaseInfoListener { override fun onDatabaseInfoChanged( previousDatabaseInfo: SnapFileDatabaseInfo, @@ -214,7 +220,7 @@ class DatabaseTaskProvider( } } - private var databaseListener = object: DatabaseTaskNotificationService.DatabaseListener { + private var databaseListener = object : DatabaseTaskNotificationService.DatabaseListener { override fun onDatabaseRetrieved(database: ContextualDatabase?) { onDatabaseRetrieved?.invoke(database) } @@ -265,12 +271,13 @@ class DatabaseTaskProvider( } 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?) { @@ -296,7 +303,11 @@ class DatabaseTaskProvider( private fun bindService() { initServiceConnection() serviceConnection?.let { - context.bindService(intentDatabaseTask, it, BIND_AUTO_CREATE or BIND_IMPORTANT or BIND_ABOVE_CLIENT) + context.bindService( + intentDatabaseTask, + it, + BIND_AUTO_CREATE or BIND_IMPORTANT or BIND_ABOVE_CLIENT + ) } } @@ -331,7 +342,8 @@ class DatabaseTaskProvider( } } } - ContextCompat.registerReceiver(context, databaseTaskBroadcastReceiver, + ContextCompat.registerReceiver( + context, databaseTaskBroadcastReceiver, IntentFilter().apply { addAction(DATABASE_START_TASK_ACTION) addAction(DATABASE_STOP_TASK_ACTION) @@ -416,47 +428,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) { @@ -472,15 +488,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) } /* @@ -489,54 +505,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 -> @@ -544,6 +566,7 @@ class DatabaseTaskProvider( Type.GROUP -> { groupsIdToCopy.add((nodeVersioned as Group).nodeId) } + Type.ENTRY -> { entriesIdToCopy.add((nodeVersioned as Entry).nodeId) } @@ -558,24 +581,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) } @@ -585,26 +613,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) } /* @@ -613,110 +643,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) } /* @@ -725,59 +763,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) } /** @@ -787,15 +830,13 @@ 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 { 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..05bfa3c85 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,7 +24,33 @@ 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.DatabaseException +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.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.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_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? = when (this) { @@ -63,6 +89,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 +138,13 @@ 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) + 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 c4a90cf3c..5a7f846a0 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 @@ -43,13 +43,15 @@ object SearchHelper { /** * 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) { + 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)) { @@ -59,8 +61,7 @@ object SearchHelper { && !searchInfo.containsOnlyNullValues()) { // If search provide results database.createVirtualGroupFromSearchInfo( - searchInfo.toString(), - searchInfo.isASearchByDomain(), + searchInfo, MAX_SEARCH_ENTRY )?.let { searchGroup -> if (searchGroup.numberOfChildEntries > 0) { 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/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/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/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/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/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/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/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..fb53a360a 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt @@ -27,7 +27,6 @@ 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,7 +36,7 @@ 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.model.AppOriginEntryField.APPLICATION_ID_FIELD_NAME import com.kunzisoft.keepass.utils.UriUtil.openExternalApp @@ -46,7 +45,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/viewmodels/EntryEditViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/viewmodels/EntryEditViewModel.kt index b740d1fb0..e7e4f1b9b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/viewmodels/EntryEditViewModel.kt +++ b/app/src/main/java/com/kunzisoft/keepass/viewmodels/EntryEditViewModel.kt @@ -174,7 +174,8 @@ class EntryEditViewModel: NodeEditViewModel() { // Load entry info entry.getEntryInfo(database, true).let { tempEntryInfo -> // Retrieve data from registration - (registerInfo?.searchInfo ?: searchInfo)?.let { tempSearchInfo -> + // TODO only save registration + searchInfo?.let { tempSearchInfo -> tempEntryInfo.saveSearchInfo(database, tempSearchInfo) } registerInfo?.let { regInfo -> diff --git a/app/src/main/res/drawable/ic_passkey_white_24dp.xml b/app/src/main/res/drawable/ic_passkey_white_24dp.xml new file mode 100644 index 000000000..4d0fabc83 --- /dev/null +++ b/app/src/main/res/drawable/ic_passkey_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/item_list_nodes_entry.xml b/app/src/main/res/layout/item_list_nodes_entry.xml index 84bdf1020..9cee13e89 100644 --- a/app/src/main/res/layout/item_list_nodes_entry.xml +++ b/app/src/main/res/layout/item_list_nodes_entry.xml @@ -159,6 +159,14 @@ android:layout_gravity="center" android:src="@drawable/ic_attach_file_white_24dp" /> + + - Expired entries are not shown Hide templates Templates are not shown + Passkey + KeePassDX Credential Provider + Save passkey in new entry + Update passkey in "%1$s" + No passkey found + Select an existing passkey + KeePassDX Database Locked + Select to unlock + Passkey Username + Passkey Private Key + Passkey Credential Id + Passkey User Handle + Passkey Relying Party \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 12cb2aacf..0f87e9f95 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -567,6 +567,13 @@ 8dp 16sp + +