fix: First validation pass

This commit is contained in:
J-Jamet
2025-09-02 11:49:40 +02:00
parent 5e4ee167fc
commit d36f675da7
101 changed files with 6028 additions and 1133 deletions

3
.gitignore vendored
View File

@@ -19,6 +19,9 @@ bin/
gen/
out/
# Kotlin folder
.kotlin/
# Gradle files
.gradle/
build/

View File

@@ -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"
@@ -141,6 +149,9 @@ dependencies {
// Password generator
implementation 'me.gosimple:nbvcxz:1.5.0'
// Credentials Provider
implementation "androidx.credentials:credentials:1.2.2"
// Modules import
implementation project(path: ':database')
implementation project(path: ':icon-pack')

View File

@@ -45,7 +45,8 @@
android:resizeableActivity="true"
android:supportsRtl="true"
android:theme="@style/KeepassDXStyle.Night"
tools:targetApi="s">
tools:targetApi="s"
tools:ignore="CredentialDependency">
<meta-data
android:name="com.google.android.backup.api_key"
android:value="${googleAndroidBackupAPIKey}" />
@@ -159,7 +160,7 @@
<activity
android:name="com.kunzisoft.keepass.settings.SettingsActivity" />
<activity
android:name="com.kunzisoft.keepass.activities.AutofillLauncherActivity"
android:name="com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity"
android:theme="@style/Theme.Transparent"
android:configChanges="keyboardHidden"
android:excludeFromRecents="true"/>
@@ -173,7 +174,7 @@
android:name="com.kunzisoft.keepass.hardware.HardwareKeyActivity"
android:theme="@style/Theme.Transparent" />
<activity
android:name="com.kunzisoft.keepass.activities.EntrySelectionLauncherActivity"
android:name="com.kunzisoft.keepass.credentialprovider.activity.EntrySelectionLauncherActivity"
android:theme="@style/Theme.Transparent"
android:launchMode="singleInstance"
android:exported="true">
@@ -199,7 +200,13 @@
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
<activity
android:name="com.kunzisoft.keepass.credentialprovider.activity.PasskeyLauncherActivity"
android:theme="@style/Theme.Transparent"
android:configChanges="keyboardHidden"
android:excludeFromRecents="true"
android:exported="false"
tools:targetApi="upside_down_cake" />
<service
android:name="com.kunzisoft.keepass.services.DatabaseTaskNotificationService"
android:foregroundServiceType="dataSync"
@@ -227,7 +234,7 @@
android:exported="false" />
<!-- Receiver for Autofill -->
<service
android:name="com.kunzisoft.keepass.autofill.KeeAutofillService"
android:name="com.kunzisoft.keepass.credentialprovider.autofill.KeeAutofillService"
android:label="@string/autofill_service_name"
android:exported="true"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
@@ -239,7 +246,7 @@
</intent-filter>
</service>
<service
android:name="com.kunzisoft.keepass.magikeyboard.MagikeyboardService"
android:name="com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService"
android:label="@string/keyboard_label"
android:exported="true"
android:permission="android.permission.BIND_INPUT_METHOD" >
@@ -249,6 +256,22 @@
<action android:name="android.view.InputMethod" />
</intent-filter>
</service>
<service
android:name="com.kunzisoft.keepass.credentialprovider.passkey.PasskeyProviderService"
android:enabled="true"
android:exported="true"
android:label="@string/passkey_service_name"
android:icon="@mipmap/ic_launcher"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
tools:targetApi="upside_down_cake">
<intent-filter>
<action android:name="android.service.credentials.CredentialProviderService" />
</intent-filter>
<meta-data
android:name="android.credentials.provider"
android:resource="@xml/provider" />
</service>
<receiver
android:name="com.kunzisoft.keepass.receivers.DexModeReceiver"
android:exported="true">

View File

@@ -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"
}
]
}
}
]
}

View File

@@ -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

View File

@@ -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<Intent>?,
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<Intent>?,
entryId: NodeId<UUID>,
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<Intent>?,
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
)
}
}

View File

@@ -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<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
AutofillHelper.buildActivityResultLauncher(this)
else null
private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
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<Intent>?,
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<Intent>?,
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<Intent>?,
registerInfo: RegisterInfo? = null,
typeMode: TypeMode) {
EntrySelectionHelper.startActivityForRegistrationModeResult(
context,
activityResultLauncher,
Intent(context, FileDatabaseSelectActivity::class.java),
registerInfo,
typeMode
)
}
}
}

View File

@@ -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<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
AutofillHelper.buildActivityResultLauncher(this)
else null
private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
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<Intent>?,
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<Intent>?,
autofillComponent: AutofillComponent,
searchInfo: SearchInfo? = null,
autoSearch: Boolean = false) {
fun launchForAutofillSelectionResult(activity: AppCompatActivity,
database: ContextualDatabase,
activityResultLauncher: ActivityResultLauncher<Intent>?,
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<Intent>?,
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<Intent>?,
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<Intent>?) {
EntrySelectionHelper.doSpecialAction(activity.intent,
{
// Default action
launch(
activity,
activityResultLauncher: ActivityResultLauncher<Intent>?) {
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()
}
}
)
}
}
}

View File

@@ -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<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
AutofillHelper.buildActivityResultLauncher(this)
else null
private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
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<Intent>?,
databaseFile: Uri,
keyFile: Uri?,
hardwareKey: HardwareKey?,
activityResultLauncher: ActivityResultLauncher<Intent>?,
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<Intent>?,
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<Intent>?,
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<Intent>?) {
activityResultLauncher: ActivityResultLauncher<Intent>?) {
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)

View File

@@ -45,6 +45,7 @@ abstract class DatabaseDialogFragment : DialogFragment(), DatabaseRetrieval {
}
@Suppress("DEPRECATION")
@Deprecated(message = "")
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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

View File

@@ -1,5 +0,0 @@
package com.kunzisoft.keepass.activities.helpers
enum class TypeMode {
DEFAULT, MAGIKEYBOARD, AUTOFILL
}

View File

@@ -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() {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -17,17 +17,32 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
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<Intent> {
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<Intent>?,
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<Intent>?,
searchInfo: SearchInfo?
) {
addSpecialModeInIntent(intent, SpecialMode.SELECTION)
addTypeModeInIntent(intent, TypeMode.PASSKEY)
addSearchInfoInIntent(intent, searchInfo)
activityResultLauncher?.launch(intent)
}
fun startActivityForRegistrationModeResult(
context: Context?,
activityResultLauncher: ActivityResultLauncher<Intent>?,
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<EntryInfo>,
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
}
}

View File

@@ -1,4 +1,4 @@
package com.kunzisoft.keepass.activities.helpers
package com.kunzisoft.keepass.credentialprovider
enum class SpecialMode {
DEFAULT,

View File

@@ -0,0 +1,5 @@
package com.kunzisoft.keepass.credentialprovider
enum class TypeMode {
DEFAULT, MAGIKEYBOARD, AUTOFILL, PASSKEY
}

View File

@@ -17,9 +17,8 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
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<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
AutofillHelper.buildActivityResultLauncher(this, true)
else null
private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
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<SearchInfo>(KEY_SEARCH_INFO)?.let { searchInfo ->
@@ -102,7 +105,9 @@ class AutofillLauncherActivity : DatabaseModeActivity() {
}
SpecialMode.REGISTRATION -> {
// To register info
val registerInfo = intent.getParcelableExtraCompat<RegisterInfo>(KEY_REGISTER_INFO)
val registerInfo = intent.getParcelableExtraCompat<RegisterInfo>(
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()
}

View File

@@ -17,23 +17,25 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
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
)
}
}
)
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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<Intent>? =
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<Intent>? =
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
)
}
}
}

View File

@@ -1,4 +1,4 @@
package com.kunzisoft.keepass.autofill
package com.kunzisoft.keepass.credentialprovider.autofill
import android.app.assist.AssistStructure

View File

@@ -17,7 +17,7 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
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<CompatInlineSuggestionsRequest>(EXTRA_INLINE_SUGGESTIONS_REQUEST)
val compatInlineSuggestionsRequest = activity.intent?.getParcelableExtraCompat<CompatInlineSuggestionsRequest>(
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<Intent> {
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<Intent>?,
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

View File

@@ -17,7 +17,7 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.autofill
package com.kunzisoft.keepass.credentialprovider.autofill
import android.annotation.TargetApi
import android.os.Build

View File

@@ -17,7 +17,7 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
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) {

View File

@@ -16,7 +16,7 @@
* You should have received a copy of the GNU General Public License
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*/
package com.kunzisoft.keepass.autofill
package com.kunzisoft.keepass.credentialprovider.autofill
import android.app.assist.AssistStructure
import android.os.Build

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<EntryInfo>,
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,

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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<BeginGetCredentialResponse, GetCredentialException>,
) {
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<CredentialEntry> = 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<CredentialEntry> {
val passkeyEntries: MutableList<CredentialEntry> = 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<BeginCreateCredentialResponse, CreateCredentialException>,
) {
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<CreateEntry>.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<CreateEntry> = 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<Void?, ClearCredentialException>
) {
// nothing to do
}
companion object {
private val TAG = PasskeyProviderService::class.java.simpleName
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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)
}
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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<String, Any>()
ao.put("fmt", "none")
ao.put("attStmt", emptyMap<Any, Any>())
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()
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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)
}
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.credentialprovider.passkey.data
import org.json.JSONObject
interface AuthenticatorResponse {
var clientJson: JSONObject
fun json(): JSONObject
}

View File

@@ -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<ByteArray, ByteArray> = 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<ByteArray>(byteMap.keys)
keysList.sortedWith(
Comparator<ByteArray> { 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<Any>()
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<Any, Any>()
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")
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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())
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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 = "<placeholder>"
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.credentialprovider.passkey.data
interface ClientDataResponse {
fun hashData(): ByteArray
fun buildResponse(): String
}

View File

@@ -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<String>
)
data class AuthenticatorSelectionCriteria(
val authenticatorAttachment: String,
val residentKey: String,
val requireResidentKey: Boolean = false,
val userVerification: String = "preferred"
)

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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()
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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<PublicKeyCredentialParameters>
var timeout: Long
var excludeCredentials: List<PublicKeyCredentialDescriptor>
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<PublicKeyCredentialParameters> = 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
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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<KeyPair, Long>,
val clientDataResponse: ClientDataResponse
)

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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")
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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
)

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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<ParcelUuid>(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<Int, Any>()),
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
}

View File

@@ -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<Node>,
newParent: Group?,
save: Boolean) {
private fun startDatabaseActionListNodes(
actionTask: String,
nodesPaste: List<Node>,
newParent: Group?,
save: Boolean
) {
val groupsIdToCopy = ArrayList<NodeId<*>>()
val entriesIdToCopy = ArrayList<NodeId<UUID>>()
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<Node>,
newParent: Group,
save: Boolean) {
fun startDatabaseCopyNodes(
nodesToCopy: List<Node>,
newParent: Group,
save: Boolean
) {
startDatabaseActionListNodes(ACTION_DATABASE_COPY_NODES_TASK, nodesToCopy, newParent, save)
}
fun startDatabaseMoveNodes(nodesToMove: List<Node>,
newParent: Group,
save: Boolean) {
fun startDatabaseMoveNodes(
nodesToMove: List<Node>,
newParent: Group,
save: Boolean
) {
startDatabaseActionListNodes(ACTION_DATABASE_MOVE_NODES_TASK, nodesToMove, newParent, save)
}
fun startDatabaseDeleteNodes(nodesToDelete: List<Node>,
save: Boolean) {
fun startDatabaseDeleteNodes(
nodesToDelete: List<Node>,
save: Boolean
) {
startDatabaseActionListNodes(ACTION_DATABASE_DELETE_NODES_TASK, nodesToDelete, null, save)
}
@@ -585,26 +613,28 @@ class DatabaseTaskProvider(
-----------------
*/
fun startDatabaseRestoreEntryHistory(mainEntryId: NodeId<UUID>,
entryHistoryPosition: Int,
save: Boolean) {
fun startDatabaseRestoreEntryHistory(
mainEntryId: NodeId<UUID>,
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<UUID>,
entryHistoryPosition: Int,
save: Boolean) {
fun startDatabaseDeleteEntryHistory(
mainEntryId: NodeId<UUID>,
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 {

View File

@@ -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
}
}

View File

@@ -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<EntryInfo>) -> Unit,
onItemNotFound: (openedDatabase: ContextualDatabase) -> Unit,
onDatabaseClosed: () -> Unit) {
fun checkAutoSearchInfo(
context: Context,
database: ContextualDatabase?,
searchInfo: SearchInfo?,
onItemsFound: (openedDatabase: ContextualDatabase,
items: List<EntryInfo>) -> 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) {

View File

@@ -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() {

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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
}
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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<ViewField> {
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

View File

@@ -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()

View File

@@ -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 ->

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,20v-2.35c0,-0.633 0.158,-1.175 0.475,-1.625 0.317,-0.45 0.725,-0.792 1.225,-1.025 1.117,-0.5 2.188,-0.875 3.213,-1.125S9.967,13.5 11,13.5c0.433,0 0.854,0.021 1.263,0.063s0.829,0.104 1.263,0.188c-0.083,0.967 0.096,1.879 0.538,2.737C14.504,17.346 15.15,18.017 16,18.5v1.5L3,20ZM19,23.675 L17.5,22.175v-4.65c-0.733,-0.217 -1.333,-0.629 -1.8,-1.237 -0.467,-0.608 -0.7,-1.313 -0.7,-2.112 0,-0.967 0.342,-1.792 1.025,-2.475 0.683,-0.683 1.508,-1.025 2.475,-1.025s1.792,0.342 2.475,1.025c0.683,0.683 1.025,1.508 1.025,2.475 0,0.75 -0.213,1.417 -0.637,2 -0.425,0.583 -0.962,1 -1.612,1.25l1.25,1.25 -1.5,1.5 1.5,1.5 -2,2ZM11,11.5c-1.05,0 -1.938,-0.363 -2.662,-1.087 -0.725,-0.725 -1.087,-1.612 -1.087,-2.662s0.363,-1.938 1.087,-2.662C9.063,4.363 9.95,4 11,4s1.938,0.363 2.662,1.087c0.725,0.725 1.087,1.612 1.087,2.662s-0.363,1.938 -1.087,2.662C12.938,11.137 12.05,11.5 11,11.5ZM18.5,14.675c0.283,0 0.521,-0.096 0.712,-0.287S19.5,13.958 19.5,13.675c0,-0.283 -0.096,-0.521 -0.287,-0.712s-0.429,-0.287 -0.712,-0.287c-0.283,0 -0.521,0.096 -0.712,0.287S17.5,13.392 17.5,13.675c0,0.283 0.096,0.521 0.287,0.712s0.429,0.287 0.712,0.287Z"
android:strokeWidth="0.5"
android:fillColor="#ffffff"/>
</vector>

View File

@@ -159,6 +159,14 @@
android:layout_gravity="center"
android:src="@drawable/ic_attach_file_white_24dp" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/node_passkey_icon"
style="@style/KeepassDXStyle.Icon.Entry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:src="@drawable/ic_passkey_white_24dp" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatTextView

View File

@@ -93,7 +93,7 @@
android:maxLines="1"
android:ellipsize="end"
android:textColor="@color/grey_blue_slighter"/>
<com.kunzisoft.keepass.magikeyboard.KeyboardView
<com.kunzisoft.keepass.credentialprovider.magikeyboard.KeyboardView
android:id="@+id/magikeyboard_view"
style="@style/KeepassDXStyle.Keyboard"
android:layout_width="match_parent"

View File

@@ -736,4 +736,17 @@
<string name="hide_expired_entries_summary">Expired entries are not shown</string>
<string name="hide_templates_title">Hide templates</string>
<string name="hide_templates_summary">Templates are not shown</string>
<string name="passkey">Passkey</string>
<string name="passkey_service_name">KeePassDX Credential Provider</string>
<string name="passkey_creation_description">Save passkey in new entry</string>
<string name="passkey_update_description">Update passkey in "%1$s"</string>
<string name="passkey_selection_username">No passkey found</string>
<string name="passkey_selection_description">Select an existing passkey</string>
<string name="passkey_locked_database_username">KeePassDX Database Locked</string>
<string name="passkey_locked_database_description">Select to unlock</string>
<string name="passkey_username">Passkey Username</string>
<string name="passkey_private_key">Passkey Private Key</string>
<string name="passkey_credential_id">Passkey Credential Id</string>
<string name="passkey_user_handle">Passkey User Handle</string>
<string name="passkey_relying_party">Passkey Relying Party</string>
</resources>

View File

@@ -567,6 +567,13 @@
<item name="android:paddingEnd">8dp</item>
<item name="android:textSize">16sp</item>
</style>
<style name="KeepassDXStyle.TextAppearance.TextNodePrimary" parent="KeepassDXStyle.TextAppearance.TextNode">
<item name="android:textSize">14sp</item>
<item name="android:textStyle">bold</item>
</style>
<style name="KeepassDXStyle.TextAppearance.TextNodeSecondary" parent="KeepassDXStyle.TextAppearance.TextNode">
<item name="android:textSize">14sp</item>
</style>
<!-- Snackbar -->
<style name="KeepassDXStyle.SnackBar" parent="Widget.Material3.Snackbar">

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<credential-provider>
<capabilities>
<capability name="androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL" />
</capabilities>
</credential-provider>

View File

@@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.8.20'
ext.kotlin_version = '2.0.0'
ext.android_core_version = '1.10.1'
ext.android_appcompat_version = '1.6.1'
ext.android_material_version = '1.9.0'

View File

@@ -36,12 +36,17 @@ android {
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources.excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") // bouncycastle need this
}
}
dependencies {
// Crypto
implementation 'org.bouncycastle:bcprov-jdk15on:1.70'
implementation 'org.bouncycastle:bcpkix-jdk18on:1.81'
//androidTestImplementation 'org.testng:testng:6.9.6'
androidTestImplementation "androidx.test:runner:$android_test_version"
testImplementation "androidx.test:runner:$android_test_version"
}

View File

@@ -0,0 +1,177 @@
package com.kunzisoft.encrypt
import com.kunzisoft.encrypt.HashManager.fingerprintToUrlSafeBase64
import org.junit.Assert
import org.junit.Test
class SignatureTest {
// All private keys are for testing only.
// DO NOT USE THEM
// region ES256
private val es256PemInKeePassXC =
"""
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgaIrmuL+0IpvMpZ4O
8+CpXEzVNoyNkhquyRqD8CtVWDmhRANCAARyucecj8E9YvcAZHEYgElcLjwLMWmM
vQ2BDZPVL4pLG1oBZer1mPEEQV7LzwGYvTzV/eb9GlXPwj/4la/bpVp1
-----END PRIVATE KEY-----
""".trimIndent().trim()
private val es256PemInKeePassDX = """
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgaIrmuL+0IpvMpZ4O
8+CpXEzVNoyNkhquyRqD8CtVWDmgCgYIKoZIzj0DAQehRANCAARyucecj8E9YvcA
ZHEYgElcLjwLMWmMvQ2BDZPVL4pLG1oBZer1mPEEQV7LzwGYvTzV/eb9GlXPwj/4
la/bpVp1
-----END PRIVATE KEY-----
""".trimIndent().trim()
@Test
fun testEC256KeyConversionKeypassXCIn() {
val privateKey = Signature.createPrivateKey(es256PemInKeePassXC)
val pemOut = Signature.convertPrivateKeyToPem(privateKey)
assert(pemOut == es256PemInKeePassDX)
}
@Test
fun testEC256KeyConversionKeePassDXIn() {
val privateKey = Signature.createPrivateKey(es256PemInKeePassDX)
val pemOut = Signature.convertPrivateKeyToPem(privateKey)
assert(pemOut == es256PemInKeePassDX)
}
@Test
fun testEC256KeyGenAndConversion() {
val (keyPair, keyTypeId) = Signature.generateKeyPair(listOf(Signature.ES256_ALGORITHM))!!
val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private)
assert(keyTypeId == Signature.ES256_ALGORITHM)
assert(privateKeyPem.contains("-----BEGIN PRIVATE KEY-----", true))
assert( privateKeyPem.contains("-----BEGIN EC PRIVATE KEY-----", true).not())
}
// endregion
// region RSA
private val rsa256PemIn = """
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCaunVJEhLHl7/f
NZufOmj4MY/1J/YHgMAZYFBORQVm58psUjCU7jIww+BK5aRGShdumRzbxr1Yqyh6
yvWbv2u9l6cOdtQKFtXDsWtP9tMBqqhODhG30gE3rEt5l2k1CSzSO9sGghUxlb2i
Q9fiSQ4HmiEc+cXbsSbeYsWwGYNYNhPdJ7vwsZsXzmD0RPpcxy0uJatASjWx3lXF
+eprpcZUr0NLlGIob6VfCt0q2ZfoeEHcEcp4qQ9nI+hOLPFzp/x1TFLX8wKvmwRh
ifwyAauVIaDZaQvAeoBuV2hSBl596ujiJt2Kd3pbQ4SjD1MXudbVvGkofgSZNR0f
ai6f6POFAgMBAAECggEAFtSIVb3K85RahU7dpYLy1hxKB3xb+wNuVNA3STU59NMi
tRTzgiYbVcKxJ5v2v0BTcMg6z9rlOV4X3PZxgwedmB32UlYKN2rjI7rcALKEs+xA
ZTQCPUNJVrOfd1N1/JNb/7FBQhaTlftoPbcQ9Zyd61U8qY/ZN+9NsuaUEMXS8YLe
cqlwJjRcWh3PuTQ+qeVw5l6lgK4XEyDbh/Aj9DGgwVsAkwGdXpuQRBQr8UClO/he
2iOwkn4LJ5nnXwByMpEct03+eUj0kxlijunYbBnKJfRv6tz+ZcpZoc1EqeWbrTB0
eKf+R6N8MHgJSemVVGZvgsUYbfMqkJA/LNOyIpQ/wQKBgQDJhowHVDtze+FQrunR
NchOXgZNWTFf3ZITxxnWnTgumtKdg3MkxeKEBCzAqefb6n6zi2rQzP/PAZAKT/we
YP24hwUVeFePH9/Llf5QuCOWGtkZbNRFSCHWcbfRQAL4vfPJk79bhwCoC5wQ5uk1
atCA+dln6b3wDXq2bvBs6Rj7bQKBgQDEjZIMMgYoEq6yKCFK+11BFo3sj3rmbCcE
tu29mXBfromgzfL0NLoqUAB5OsYKO1nl7eQp2QVIgdLLs8BwTKkel4gosK9B5T4C
umFG0yGIOJz7twA5joLuZAFsoPazj6yHUXaFJaye2P5KwVCL9ws5V2WKgnsF/hKe
QWwSIjtxeQKBgAbyR1NdWOtDIuIYFWErvGrPHOJ/p48JYSajX0Whh7U7ivT4+fgT
hhpM1ooRkTdoXtOrg5QM7OhiwmdImIUnjLdWmBtEWahKTfmDgw+fOULMTB1vPeXh
daEhrFdfIHsYeRXCrP7nqWMhe1Ct1O4Nb4BynEbTrMNgg5FUQ59NbZoFAoGAXNNb
YSUS4UQJexwWtRnHbeDgABO3ADGdr81QtBVOC/IbD3WUQx7PuQH1Z0uJkfV7vGpA
Mj9LDnY5fniS7rZVvJvl8wmWi3FfetxY6qD1mibahMplcclLLpjOT2YpfJ3i5jlj
1vf28UIbvmRTzPZMN7V9wA9lWGwokNLm3h2Ko0kCgYBq0NEd+VMkuIXuGz6j2IXC
qjKf187RZAn2B7otXoumCze+uxm4N0PyOYb1fNGHeE8/RjNQO7VmzZg/dMrk9ZJh
ueHJgOLTbDdlQCUacSipHGmWMN9E+EjgBRiqmPZzV6dq/kGc2FUSGB22wY8gckEX
AmqgkPgYHZ/VzFPTrp97IQ==
-----END PRIVATE KEY-----
""".trimIndent().trim()
@Test
fun testRS256KeyConversion() {
val privateKey = Signature.createPrivateKey(rsa256PemIn)
val pemOut = Signature.convertPrivateKeyToPem(privateKey)
assert(pemOut == rsa256PemIn)
}
@Test
fun testRS256KeyGenAndConversion() {
val (keyPair, keyTypeId) = Signature.generateKeyPair(listOf(Signature.RS256_ALGORITHM))!!
val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private)
assert(keyTypeId == Signature.RS256_ALGORITHM)
assert(privateKeyPem.contains("-----BEGIN PRIVATE KEY-----", true))
}
// endregion
// region ED25519
private val ed25519PemInShort = """
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEILBoCo4+IXxIuwN36/oaEsPgbe6WYJcV9YW+xnprDF4H
-----END PRIVATE KEY-----
""".trimIndent()
private val ed25519PemInLong = """
-----BEGIN PRIVATE KEY-----
MFECAQEwBQYDK2VwBCIEIESP8edVGbqoR/pKNmy7j7FV8Y68zrIi/5VEuAJ281K6
gSEAyJU1wQNaJUeyxPcWjN7xZKZUhCRoIFS/MQvbdd4QE7Q=
-----END PRIVATE KEY-----
""".trimIndent()
private val ed25519PemOut = """
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIESP8edVGbqoR/pKNmy7j7FV8Y68zrIi/5VEuAJ281K6
-----END PRIVATE KEY-----
""".trimIndent()
@Test
fun testEd25519KeyConverionShortIn() {
val privateKey = Signature.createPrivateKey(ed25519PemInShort)
val pemOut = Signature.convertPrivateKeyToPem(privateKey)
assert(pemOut == ed25519PemInShort)
}
@Test
fun testEd25519KeyConverionLongIn() {
val privateKey = Signature.createPrivateKey(ed25519PemInLong)
val pemOut = Signature.convertPrivateKeyToPem(privateKey)
assert(pemOut == ed25519PemOut)
}
@Test
fun testEd25519KeyGenAndConversion() {
val (keyPair, keyTypeId) = Signature.generateKeyPair(listOf(Signature.ED_DSA_ALGORITHM))!!
val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private)
assert(keyTypeId == Signature.ED_DSA_ALGORITHM)
assert(privateKeyPem.contains("-----BEGIN PRIVATE KEY-----", true))
assert(privateKeyPem.contains("-----BEGIN EC PRIVATE KEY-----", true).not())
}
// endregion
@Test
fun testSingleSignature() {
// Generate random input
val fingerprint = "A7:5C:63:72:A0:B6:7D:B0:16:86:B4:7D:F6:8C:91:51:6E:E1:62:29:EE:C4:C0:C6:7D:35:5E:32:20:7C:66:17"
val expected = "p1xjcqC2fbAWhrR99oyRUW7hYinuxMDGfTVeMiB8Zhc"
Assert.assertEquals("Check fingerprint app", expected, fingerprintToUrlSafeBase64(fingerprint))
}
@Test
fun testMultipleSignature() {
// Generate random input
val fingerprint = "A7:5C:63:72:A0:B6:7D:B0:16:86:B4:7D:F6:8C:91:51:6E:E1:62:29:EE:C4:C0:C6:7D:35:5E:32:20:7C:66:17##SIG##DB:25:8A:A6:19:08:9B:D1:3D:BA:71:9E:5A:DA:EC:FF:7F:12:C8:8F:67:AD:68:3C:1F:BC:F2:28:B3:88:BD:91"
val expected = "p1xjcqC2fbAWhrR99oyRUW7hYinuxMDGfTVeMiB8Zhc"
Assert.assertEquals("Check fingerprint app", expected, fingerprintToUrlSafeBase64(fingerprint))
}
}

View File

@@ -0,0 +1,23 @@
package com.kunzisoft.encrypt
import android.util.Base64
class Base64Helper {
companion object {
fun b64Decode(encodedString: String): ByteArray {
return Base64.decode(
encodedString,
Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE
)
}
fun b64Encode(data: ByteArray): String {
return Base64.encodeToString(
data,
Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE
)
}
}
}

View File

@@ -19,6 +19,11 @@
*/
package com.kunzisoft.encrypt
import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.os.Build
import android.util.AndroidException
import android.util.Log
import org.bouncycastle.crypto.engines.ChaCha7539Engine
import org.bouncycastle.crypto.engines.Salsa20Engine
import org.bouncycastle.crypto.params.KeyParameter
@@ -26,9 +31,14 @@ import org.bouncycastle.crypto.params.ParametersWithIV
import java.io.IOException
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.Locale
object HashManager {
private val TAG = HashManager::class.simpleName
fun getHash256(): MessageDigest {
val messageDigest: MessageDigest
try {
@@ -107,4 +117,115 @@ object HashManager {
return StreamCipher(cipher)
}
const val SIGNATURE_DELIMITER = "##SIG##"
/**
* Converts a Signature object into its SHA-256 fingerprint string.
* The fingerprint is typically represented as uppercase hex characters separated by colons.
*/
private fun signatureToSha256Fingerprint(signature: Signature): String? {
return try {
val certificateFactory = CertificateFactory.getInstance("X.509")
val x509Certificate = certificateFactory.generateCertificate(
signature.toByteArray().inputStream()
) as X509Certificate
val messageDigest = MessageDigest.getInstance("SHA-256")
val digest = messageDigest.digest(x509Certificate.encoded)
// Format as colon-separated HEX uppercase string
digest.joinToString(separator = ":") { byte -> "%02X".format(byte) }
.uppercase(Locale.US)
} catch (e: Exception) {
Log.e("SigningInfoUtil", "Error converting signature to SHA-256 fingerprint", e)
null
}
}
/**
* Retrieves all relevant SHA-256 signature fingerprints for a given package.
*
* @param signingInfo The SigningInfo object to retrieve the strings signatures
* @return A List of SHA-256 fingerprint strings, or null if an error occurs or no signatures are found.
*/
fun getAllFingerprints(signingInfo: SigningInfo?): List<String>? {
try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
throw AndroidException("API level ${Build.VERSION.SDK_INT} not supported")
val signatures = mutableSetOf<String>()
if (signingInfo != null) {
// Includes past and current keys if rotation occurred. This is generally preferred.
signingInfo.signingCertificateHistory?.forEach { signature ->
signatureToSha256Fingerprint(signature)?.let { signatures.add(it) }
}
// If only one signer and history is empty (e.g. new app), this might be needed.
// Or if multiple signers are explicitly used for the APK content.
if (signingInfo.hasMultipleSigners()) {
signingInfo.apkContentsSigners?.forEach { signature ->
signatureToSha256Fingerprint(signature)?.let { signatures.add(it) }
}
} else { // Fallback for single signer if history was somehow null/empty
signingInfo.signingCertificateHistory?.firstOrNull()?.let {
signatureToSha256Fingerprint(it)?.let { fp -> signatures.add(fp) }
}
}
}
return if (signatures.isEmpty()) null else signatures.toList()
} catch (e: Exception) {
Log.e(TAG, "Error getting signatures", e)
return null
}
}
/**
* Combines a list of signature into a single string for database storage.
*
* @return A single string with fingerprints joined by a ##SIG## delimiter,
* or null if the input list is null or empty.
*/
fun SigningInfo.getApplicationFingerprints(): String? {
val fingerprints = getAllFingerprints(this)
if (fingerprints.isNullOrEmpty()) {
return null
}
return fingerprints.joinToString(SIGNATURE_DELIMITER)
}
/**
* Transforms a colon-separated hex fingerprint string into a URL-safe,
* padding-removed Base64 string, mimicking the Python behavior:
* base64.urlsafe_b64encode(binascii.a2b_hex(fingerprint.replace(':', ''))).decode('utf8').replace('=', '')
*
* Only check the first footprint if there are several delimited by ##SIG##.
*
* @param fingerprint The colon-separated hex fingerprint string (e.g., "91:F7:CB:...").
* @return The Android App Origin string.
* @throws IllegalArgumentException if the hex string (after removing colons) has an odd length
* or contains non-hex characters.
*/
fun fingerprintToUrlSafeBase64(fingerprint: String): String {
val firstFingerprint = fingerprint.split(SIGNATURE_DELIMITER).firstOrNull()?.trim()
if (firstFingerprint.isNullOrEmpty()) {
throw IllegalArgumentException("Invalid fingerprint $fingerprint")
}
val hexStringNoColons = firstFingerprint.replace(":", "")
if (hexStringNoColons.length % 2 != 0) {
throw IllegalArgumentException("Hex string must have an even number of characters: $hexStringNoColons")
}
if (hexStringNoColons.length != 64) {
throw IllegalArgumentException("Expected a 64-character hex string for a SHA-256 hash, but got ${hexStringNoColons.length} characters.")
}
val hashBytes = ByteArray(hexStringNoColons.length / 2)
for (i in hashBytes.indices) {
try {
val index = i * 2
val byteValue = hexStringNoColons.substring(index, index + 2).toInt(16)
hashBytes[i] = byteValue.toByte()
} catch (e: NumberFormatException) {
throw IllegalArgumentException("Invalid hex character in fingerprint: $hexStringNoColons", e)
}
}
return Base64Helper.b64Encode(hashBytes)
}
}

View File

@@ -0,0 +1,237 @@
package com.kunzisoft.encrypt
import android.util.Log
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey
import org.bouncycastle.jcajce.provider.asymmetric.edec.BCEdDSAPrivateKey
import org.bouncycastle.jcajce.provider.asymmetric.edec.BCEdDSAPublicKey
import org.bouncycastle.jcajce.provider.asymmetric.rsa.BCRSAPublicKey
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.openssl.PEMParser
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator
import org.bouncycastle.util.BigIntegers
import org.bouncycastle.util.io.pem.PemWriter
import java.io.StringReader
import java.io.StringWriter
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.PrivateKey
import java.security.PublicKey
import java.security.Security
import java.security.Signature
import java.security.spec.ECGenParameterSpec
class Signature {
companion object {
// see at https://www.iana.org/assignments/cose/cose.xhtml
const val ES256_ALGORITHM: Long = -7
const val RS256_ALGORITHM: Long = -257
private const val RS256_KEY_SIZE_IN_BITS = 2048
const val ED_DSA_ALGORITHM: Long = -8
init {
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
Security.addProvider(BouncyCastleProvider())
}
fun sign(privateKeyPem: String, message: ByteArray): ByteArray? {
val privateKey = createPrivateKey(privateKeyPem)
val algorithmKey = privateKey.algorithm
val algorithmSignature = when (algorithmKey) {
"EC" -> "SHA256withECDSA"
"ECDSA" -> "SHA256withECDSA"
"RSA" -> "SHA256withRSA"
"Ed25519" -> "Ed25519"
else -> null
}
if (algorithmSignature == null) {
Log.e(this::class.java.simpleName, "sign: the algorithm $algorithmKey is unknown")
return null
}
val sig = Signature.getInstance(algorithmSignature, BouncyCastleProvider.PROVIDER_NAME)
sig.initSign(privateKey)
sig.update(message)
return sig.sign()
}
fun createPrivateKey(privateKeyPem: String): PrivateKey {
val targetReader = StringReader(privateKeyPem)
val pemParser = PEMParser(targetReader)
val privateKeyInfo = pemParser.readObject() as PrivateKeyInfo
val privateKey = JcaPEMKeyConverter().getPrivateKey(privateKeyInfo)
pemParser.close()
targetReader.close()
return privateKey
}
fun convertPrivateKeyToPem(privateKey: PrivateKey): String {
var useV1Info = false
if (privateKey is BCEdDSAPrivateKey) {
// to generate PEM, which are compatible to KeepassXC
useV1Info = true
}
System.setProperty(
"org.bouncycastle.pkcs8.v1_info_only",
useV1Info.toString().lowercase()
)
val noOutputEncryption = null
val pemObjectGenerator = JcaPKCS8Generator(privateKey, noOutputEncryption)
val writer = StringWriter()
val pemWriter = PemWriter(writer)
pemWriter.writeObject(pemObjectGenerator)
pemWriter.close()
val privateKeyInPem = writer.toString().trim()
writer.close()
return privateKeyInPem
}
fun generateKeyPair(keyTypeIdList: List<Long>): Pair<KeyPair, Long>? {
for (typeId in keyTypeIdList) {
if (typeId == ES256_ALGORITHM) {
val es256CurveNameBC = "secp256r1"
val spec = ECGenParameterSpec(es256CurveNameBC)
val keyPairGen =
KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME)
keyPairGen.initialize(spec)
val keyPair = keyPairGen.genKeyPair()
return Pair(keyPair, ES256_ALGORITHM)
} else if (typeId == RS256_ALGORITHM) {
val keyPairGen =
KeyPairGenerator.getInstance("RSA", BouncyCastleProvider.PROVIDER_NAME)
keyPairGen.initialize(RS256_KEY_SIZE_IN_BITS)
val keyPair = keyPairGen.genKeyPair()
return Pair(keyPair, RS256_ALGORITHM)
} else if (typeId == ED_DSA_ALGORITHM) {
val keyPairGen =
KeyPairGenerator.getInstance("Ed25519", BouncyCastleProvider.PROVIDER_NAME)
val keyPair = keyPairGen.genKeyPair()
return Pair(keyPair, ED_DSA_ALGORITHM)
}
}
Log.e(this::class.java.simpleName, "generateKeyPair: no known key type id found")
return null
}
fun convertPublicKey(publicKeyIn: PublicKey, keyTypeId: Long): ByteArray? {
if (keyTypeId == ES256_ALGORITHM) {
if (publicKeyIn is BCECPublicKey) {
publicKeyIn.setPointFormat("UNCOMPRESSED")
return publicKeyIn.encoded
}
} else if (keyTypeId == RS256_ALGORITHM) {
return publicKeyIn.encoded
} else if (keyTypeId == ED_DSA_ALGORITHM) {
return publicKeyIn.encoded
}
Log.e(this::class.java.simpleName, "convertPublicKey: unknown key type id found")
return null
}
fun convertPublicKeyToMap(publicKeyIn: PublicKey, keyTypeId: Long): Map<Int, Any>? {
// https://www.iana.org/assignments/cose/cose.xhtml#key-common-parameters
val keyTypeLabel = 1
val algorithmLabel = 3
if (keyTypeId == ES256_ALGORITHM) {
if (publicKeyIn !is BCECPublicKey) {
Log.e(
this::class.java.simpleName,
"publicKey object has wrong type for keyTypeId $ES256_ALGORITHM: ${publicKeyIn.javaClass.canonicalName}"
)
return null
}
// constants see at https://w3c.github.io/webauthn/#example-bdbd14cc
val publicKeyMap = mutableMapOf<Int, Any>()
val es256KeyTypeId = 2
val es256EllipticCurveP256Id = 1
publicKeyMap[keyTypeLabel] = es256KeyTypeId
publicKeyMap[algorithmLabel] = ES256_ALGORITHM
publicKeyMap[-1] = es256EllipticCurveP256Id
val ecPoint = publicKeyIn.q
publicKeyMap[-2] = ecPoint.xCoord.encoded
publicKeyMap[-3] = ecPoint.yCoord.encoded
return publicKeyMap
} else if (keyTypeId == RS256_ALGORITHM) {
if (publicKeyIn !is BCRSAPublicKey) {
Log.e(
this::class.java.simpleName,
"publicKey object has wrong type for keyTypeId $RS256_ALGORITHM: ${publicKeyIn.javaClass.canonicalName}"
)
return null
}
// constants see at https://w3c.github.io/webauthn/#example-8dfabc00
val rs256KeySizeInBytes = RS256_KEY_SIZE_IN_BITS / 8
val rs256KeyTypeId = 3
val rs256ExponentSizeInBytes = 3
val publicKeyMap = mutableMapOf<Int, Any>()
publicKeyMap[keyTypeLabel] = rs256KeyTypeId
publicKeyMap[algorithmLabel] = RS256_ALGORITHM
publicKeyMap[-1] =
BigIntegers.asUnsignedByteArray(rs256KeySizeInBytes, publicKeyIn.modulus)
publicKeyMap[-2] =
BigIntegers.asUnsignedByteArray(
rs256ExponentSizeInBytes,
publicKeyIn.publicExponent
)
return publicKeyMap
} else if (keyTypeId == ED_DSA_ALGORITHM) {
if (publicKeyIn !is BCEdDSAPublicKey) {
Log.e(
this::class.java.simpleName,
"publicKey object has wrong type for keyTypeId $ED_DSA_ALGORITHM: ${publicKeyIn.javaClass.canonicalName}"
)
return null
}
val publicKeyMap = mutableMapOf<Int, Any>()
// https://www.rfc-editor.org/rfc/rfc9053#name-key-object-parameters
val octetKeyPairId = 1
val curveLabel = -1
val ed25519CurveId = 6
val publicKeyLabel = -2
publicKeyMap[keyTypeLabel] = octetKeyPairId
publicKeyMap[algorithmLabel] = ED_DSA_ALGORITHM
publicKeyMap[curveLabel] = ed25519CurveId
val length = Ed25519PublicKeyParameters.KEY_SIZE
publicKeyMap[publicKeyLabel] = BigIntegers.asUnsignedByteArray(
length,
BigIntegers.fromUnsignedByteArray(publicKeyIn.pointEncoding)
)
return publicKeyMap
}
Log.e(this::class.java.simpleName, "convertPublicKeyToMap: no known key type id found")
return null
}
}
}

View File

@@ -1,5 +1,6 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
android {
namespace 'com.kunzisoft.keepass.database'
@@ -41,7 +42,7 @@ dependencies {
implementation 'commons-io:commons-io:2.8.0'
implementation 'commons-codec:commons-codec:1.15'
implementation project(path: ':crypto')
api project(path: ':crypto')
// Tests
androidTestImplementation "androidx.test:runner:$android_test_version"

View File

@@ -55,6 +55,7 @@ import com.kunzisoft.keepass.database.merge.DatabaseKDBXMerger
import com.kunzisoft.keepass.database.search.SearchHelper
import com.kunzisoft.keepass.database.search.SearchParameters
import com.kunzisoft.keepass.hardware.HardwareKey
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
import com.kunzisoft.keepass.utils.SingletonHolder
import com.kunzisoft.keepass.utils.StringUtil.toFormattedColorInt
@@ -885,29 +886,15 @@ open class Database {
}
fun createVirtualGroupFromSearchInfo(
searchInfoString: String,
searchInfoByDomain: Boolean,
searchInfo: SearchInfo,
max: Int = Integer.MAX_VALUE
): Group? {
return mSearchHelper.createVirtualGroupWithSearchResult(this,
SearchParameters().apply {
searchQuery = searchInfoString
allowEmptyQuery = false
searchInTitles = true
searchInUsernames = false
searchInPasswords = false
searchInUrls = true
searchByDomain = searchInfoByDomain
searchInNotes = true
searchInOTP = false
searchInOther = true
searchInUUIDs = false
searchInTags = false
searchInCurrentGroup = false
searchInSearchableGroup = true
searchInRecycleBin = false
searchInTemplates = false
}, null, max)
return mSearchHelper.createVirtualGroupWithSearchResult(
database = this,
searchParameters = searchInfo.buildSearchParameters(),
fromGroup = null,
max = max
)
}
val tagPool: Tags

View File

@@ -33,12 +33,16 @@ import com.kunzisoft.keepass.database.element.node.Node
import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.element.node.Type
import com.kunzisoft.keepass.model.AppOrigin
import com.kunzisoft.keepass.model.AppOriginEntryField
import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.model.PasskeyEntryFields
import com.kunzisoft.keepass.otp.OtpElement
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.utils.readParcelableCompat
import com.kunzisoft.keepass.utils.StringUtil.toFormattedColorInt
import com.kunzisoft.keepass.utils.StringUtil.toFormattedColorString
import com.kunzisoft.keepass.utils.readParcelableCompat
import java.util.UUID
class Entry : Node, EntryVersionedInterface<Group> {
@@ -354,6 +358,24 @@ class Entry : Node, EntryVersionedInterface<Group> {
return null
}
fun getPasskey(): Passkey? {
entryKDBX?.let {
return PasskeyEntryFields.parseFields { key ->
it.getFieldValue(key)?.toString()
}
}
return null
}
fun getAppOrigin(): AppOrigin? {
entryKDBX?.let {
return AppOriginEntryField.parseFields { key ->
it.getFieldValue(key)?.toString()
}
}
return null
}
fun startToManageFieldReferences(database: DatabaseKDBX) {
entryKDBX?.startToManageFieldReferences(database)
}
@@ -470,9 +492,13 @@ class Entry : Node, EntryVersionedInterface<Group> {
entryInfo.customFields = getExtraFields().toMutableList()
// Add otpElement to generate token
entryInfo.otpModel = getOtpElement()?.otpModel
// Add Passkey
entryInfo.passkey = getPasskey()
entryInfo.appOrigin = getAppOrigin()
if (!raw) {
// Replace parameter fields by generated OTP fields
entryInfo.customFields = OtpEntryFields.generateAutoFields(entryInfo.customFields)
entryInfo.customFields = PasskeyEntryFields.generateAutoFields(entryInfo.customFields)
}
database?.attachmentPool?.let { binaryPool ->
entryInfo.attachments = getAttachments(binaryPool).toMutableList()

View File

@@ -49,6 +49,10 @@ class Tags: Parcelable {
}
}
fun contains(tag: String): Boolean {
return mTags.contains(tag)
}
fun isEmpty(): Boolean {
return mTags.isEmpty()
}

View File

@@ -22,7 +22,8 @@ package com.kunzisoft.keepass.database.element.entry
import android.util.Log
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.utils.UuidUtil
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.UUIDUtils.asUUID
import java.util.concurrent.ConcurrentHashMap
class FieldReferencesEngine(private val mDatabase: DatabaseKDBX) {
@@ -79,7 +80,7 @@ class FieldReferencesEngine(private val mDatabase: DatabaseKDBX) {
'A' -> entryFound?.decodeUrlKey(newRecursionLevel)
'P' -> entryFound?.decodePasswordKey(newRecursionLevel)
'N' -> entryFound?.decodeNotesKey(newRecursionLevel)
'I' -> UuidUtil.toHexString(entryFound?.nodeId?.id)
'I' -> entryFound?.nodeId?.id?.asHexString()
else -> null
}
refsCache[fullReference] = data
@@ -127,7 +128,7 @@ class FieldReferencesEngine(private val mDatabase: DatabaseKDBX) {
'P' -> mDatabase.getEntryByPassword(searchQuery, recursionLevel)
'N' -> mDatabase.getEntryByNotes(searchQuery, recursionLevel)
'I' -> {
UuidUtil.fromHexString(searchQuery)?.let { uuid ->
searchQuery.asUUID()?.let { uuid ->
mDatabase.getEntryById(NodeIdUUID(uuid))
}
}

View File

@@ -22,9 +22,9 @@ package com.kunzisoft.keepass.database.element.node
import android.os.Parcel
import android.os.ParcelUuid
import android.os.Parcelable
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.readParcelableCompat
import com.kunzisoft.keepass.utils.UuidUtil
import java.util.*
import java.util.UUID
class NodeIdUUID : NodeId<UUID> {
@@ -62,7 +62,7 @@ class NodeIdUUID : NodeId<UUID> {
}
override fun toString(): String {
return UuidUtil.toHexString(id) ?: id.toString()
return id.asHexString() ?: id.toString()
}
override fun toVisualString(): String {

View File

@@ -24,7 +24,8 @@ import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX
import com.kunzisoft.keepass.database.element.entry.EntryKDBX
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.utils.UuidUtil
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.UUIDUtils.asUUID
class TemplateEngineCompatible(database: DatabaseKDBX): TemplateEngine(database) {
@@ -33,7 +34,7 @@ class TemplateEngineCompatible(database: DatabaseKDBX): TemplateEngine(database)
}
override fun getTemplate(entryKDBX: EntryKDBX): Template? {
UuidUtil.fromHexString(entryKDBX.getCustomFieldValue(TEMPLATE_ENTRY_UUID))?.let { templateUUID ->
entryKDBX.getCustomFieldValue(TEMPLATE_ENTRY_UUID).asUUID()?.let { templateUUID ->
return getTemplateByCache(templateUUID)
}
return null
@@ -48,7 +49,7 @@ class TemplateEngineCompatible(database: DatabaseKDBX): TemplateEngine(database)
}
private fun getTemplateUUIDField(template: Template): Field? {
UuidUtil.toHexString(template.uuid)?.let { uuidString ->
template.uuid.asHexString()?.let { uuidString ->
return Field(TEMPLATE_ENTRY_UUID,
ProtectedString(false, uuidString))
}

View File

@@ -24,8 +24,11 @@ import com.kunzisoft.keepass.database.element.Entry
import com.kunzisoft.keepass.database.element.Group
import com.kunzisoft.keepass.database.element.node.NodeHandler
import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_RELYING_PARTY
import com.kunzisoft.keepass.model.PasskeyEntryFields.isPasskeyExclusion
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_FIELD
import com.kunzisoft.keepass.utils.UuidUtil
import com.kunzisoft.keepass.otp.OtpEntryFields.isOtpExclusion
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.inTheSameDomainAs
class SearchHelper {
@@ -163,18 +166,31 @@ class SearchHelper {
return true
}
if (searchParameters.searchInUUIDs) {
val hexString = UuidUtil.toHexString(entry.nodeId.id) ?: ""
val hexString = entry.nodeId.id.asHexString() ?: ""
if (checkSearchQuery(hexString, searchParameters))
return true
}
if (searchParameters.searchInOTP) {
if(entry.getExtraFields().any { field ->
field.name == OTP_FIELD
&& checkSearchQuery(field.protectedValue.stringValue, searchParameters)
})
return true
}
if (searchParameters.searchInRelyingParty) {
if(entry.getExtraFields().any { field ->
field.name == FIELD_RELYING_PARTY
&& checkSearchQuery(field.protectedValue.stringValue, searchParameters)
})
return true
}
if (searchParameters.searchInOther) {
entry.getExtraFields().forEach { field ->
if (field.name != OTP_FIELD
|| (field.name == OTP_FIELD && searchParameters.searchInOTP)) {
if (checkSearchQuery(field.protectedValue.toString(), searchParameters))
return true
}
}
if(entry.getExtraFields().any { field ->
field.isOtpExclusion()
&& field.isPasskeyExclusion()
&& checkSearchQuery(field.protectedValue.toString(), searchParameters)
})
return true
}
if (searchParameters.searchInTags) {
if (checkSearchQuery(entry.tags.toString(), searchParameters))

View File

@@ -38,6 +38,7 @@ class SearchParameters() : Parcelable{
var searchInExpired = false
var searchInNotes = true
var searchInOTP = false
var searchInRelyingParty = false
var searchInOther = true
var searchInUUIDs = false
var searchInTags = false
@@ -60,6 +61,7 @@ class SearchParameters() : Parcelable{
searchInExpired = parcel.readByte() != 0.toByte()
searchInNotes = parcel.readByte() != 0.toByte()
searchInOTP = parcel.readByte() != 0.toByte()
searchInRelyingParty = parcel.readByte() != 0.toByte()
searchInOther = parcel.readByte() != 0.toByte()
searchInUUIDs = parcel.readByte() != 0.toByte()
searchInTags = parcel.readByte() != 0.toByte()
@@ -81,6 +83,7 @@ class SearchParameters() : Parcelable{
parcel.writeByte(if (searchInExpired) 1 else 0)
parcel.writeByte(if (searchInNotes) 1 else 0)
parcel.writeByte(if (searchInOTP) 1 else 0)
parcel.writeByte(if (searchInRelyingParty) 1 else 0)
parcel.writeByte(if (searchInOther) 1 else 0)
parcel.writeByte(if (searchInUUIDs) 1 else 0)
parcel.writeByte(if (searchInTags) 1 else 0)

View File

@@ -0,0 +1,143 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.model
import android.os.Parcelable
import android.util.Log
import com.kunzisoft.encrypt.HashManager.fingerprintToUrlSafeBase64
import com.kunzisoft.keepass.model.WebOrigin.Companion.RELYING_PARTY_DEFAULT_PROTOCOL
import kotlinx.parcelize.Parcelize
@Parcelize
data class AppOrigin(
val verified: Boolean,
val androidOrigins: MutableList<AndroidOrigin> = mutableListOf(),
val webOrigins: MutableList<WebOrigin> = mutableListOf(),
) : Parcelable {
fun addAndroidOrigin(androidOrigin: AndroidOrigin) {
androidOrigins.add(androidOrigin)
}
fun addWebOrigin(webOrigin: WebOrigin) {
this.webOrigins.add(webOrigin)
}
/**
* Verify the app origin by comparing it to the list of android origins,
* return the first verified origin or throw an exception if none is found
*/
fun checkAppOrigin(compare: AppOrigin): String {
return androidOrigins.firstOrNull { androidOrigin ->
compare.androidOrigins.any {
it.packageName == androidOrigin.packageName
&& it.fingerprint == androidOrigin.fingerprint
}
}?.let {
AndroidOrigin(
packageName = it.packageName,
fingerprint = it.fingerprint
).toAndroidOrigin()
} ?: throw SecurityException("Wrong signature for ${toName()}")
}
fun clear() {
androidOrigins.clear()
webOrigins.clear()
}
fun isEmpty(): Boolean {
return androidOrigins.isEmpty() && webOrigins.isEmpty()
}
fun toName(): String? {
return if (androidOrigins.isNotEmpty()) {
androidOrigins.first().packageName
} else if (webOrigins.isNotEmpty()){
webOrigins.first().origin
} else null
}
companion object {
private val TAG = AppOrigin::class.java.simpleName
fun fromOrigin(origin: String, androidOrigin: AndroidOrigin, verified: Boolean): AppOrigin {
val appOrigin = AppOrigin(verified)
if (origin.startsWith(RELYING_PARTY_DEFAULT_PROTOCOL)) {
appOrigin.apply {
addWebOrigin(WebOrigin(origin))
}
} else {
Log.w(TAG, "Unknown verified origin $origin")
appOrigin.apply {
addAndroidOrigin(androidOrigin)
}
}
return appOrigin
}
}
}
@Parcelize
data class AndroidOrigin(
val packageName: String,
val fingerprint: String?
) : Parcelable {
/**
* Creates an Android App Origin string of the form "android:apk-key-hash:<base64_urlsafe_hash>"
* from a colon-separated hex fingerprint string.
*
* The input fingerprint is assumed to be the SHA-256 hash of the app's signing certificate.
*
* @param fingerprint The colon-separated hex fingerprint string (e.g., "91:F7:CB:...").
* @return The Android App Origin string.
* @throws IllegalArgumentException if the hex string (after removing colons) has an odd length
* or contains non-hex characters.
*/
fun toAndroidOrigin(): String {
if (fingerprint == null) {
throw IllegalArgumentException("Fingerprint $fingerprint cannot be null")
}
return "android:apk-key-hash:${fingerprintToUrlSafeBase64(fingerprint)}"
}
}
@Parcelize
data class WebOrigin(
val origin: String
) : Parcelable {
fun toWebOrigin(): String {
return origin
}
fun defaultAssetLinks(): String {
return "${origin}/.well-known/assetlinks.json"
}
companion object {
const val RELYING_PARTY_DEFAULT_PROTOCOL = "https"
fun fromRelyingParty(relyingParty: String): WebOrigin = WebOrigin(
origin ="$RELYING_PARTY_DEFAULT_PROTOCOL://$relyingParty"
)
}
}

View File

@@ -0,0 +1,147 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.model
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.model.EntryInfo.Companion.suffixFieldNamePosition
object AppOriginEntryField {
const val WEB_DOMAIN_FIELD_NAME = "URL"
const val APPLICATION_ID_FIELD_NAME = "AndroidApp"
const val APPLICATION_SIGNATURE_FIELD_NAME = "AndroidApp Signature"
/**
* Parse fields of an entry to retrieve a an AppOrigin
*/
fun parseFields(getField: (id: String) -> String?): AppOrigin {
val appOrigin = AppOrigin(verified = true)
// Get Application identifiers
generateSequence(0) { it + 1 }
.map { position ->
val appId = getField(APPLICATION_ID_FIELD_NAME + suffixFieldNamePosition(position))
val appSignature = getField(APPLICATION_SIGNATURE_FIELD_NAME + suffixFieldNamePosition(position))
// Pair them up, if appId is null, we stop
if (appId != null) {
appId to (appSignature ?: "")
} else {
// Stop
null
}
}.takeWhile { it != null }
.forEach { pair ->
appOrigin.addAndroidOrigin(
AndroidOrigin(pair!!.first, pair.second)
)
}
// Get Domains
var domainFieldPosition = 0
while (true) {
val domainKey = WEB_DOMAIN_FIELD_NAME + suffixFieldNamePosition(domainFieldPosition)
val domainValue = getField(domainKey)
if (domainValue != null) {
appOrigin.addWebOrigin(WebOrigin(origin = domainValue))
domainFieldPosition++
} else {
break // No more domain found
}
}
return appOrigin
}
/**
* Useful to detect if an other KeePass compatibility app already add a web domain or an app id
*/
private fun EntryInfo.containsDomainOrApplicationId(search: String): Boolean {
if (url.contains(search))
return true
return customFields.find {
it.protectedValue.stringValue.contains(search)
} != null
}
fun EntryInfo.setWebDomain(webDomain: String?, scheme: String?, customFieldsAllowed: Boolean) {
// If unable to save web domain in custom field or URL not populated, save in URL
webDomain?.let {
val webScheme = if (scheme.isNullOrEmpty()) "https" else scheme
val webDomainToStore = if (webDomain.contains("://")) {
webDomain
} else {
"$webScheme://$webDomain"
}
if (!containsDomainOrApplicationId(webDomain)) {
if (!customFieldsAllowed || url.isEmpty()) {
url = webDomainToStore
} else {
// Save web domain in custom field
addUniqueField(
Field(
WEB_DOMAIN_FIELD_NAME,
ProtectedString(false, webDomainToStore)
),
1 // Start to one because URL is a standard field name
)
}
}
}
}
/**
* Save application id in custom field and the application signature if provided
*/
fun EntryInfo.setApplicationId(applicationId: String?, signature: String? = null) {
// Save application id in custom field
applicationId?.let {
// Check compatibility with other KeePass client unless a signature need to be saved
if (!containsDomainOrApplicationId(applicationId) || signature != null) {
val position = addUniqueField(
Field(
APPLICATION_ID_FIELD_NAME,
ProtectedString(false, applicationId)
)
).first
signature?.let {
addOrReplaceFieldWithSuffix(
Field(
APPLICATION_SIGNATURE_FIELD_NAME,
ProtectedString(true, signature)
),
position
)
}
}
}
}
/**
* Assign an AppOrigin to an EntryInfo,
* Only if [customFieldsAllowed] is true
*/
fun EntryInfo.setAppOrigin(appOrigin: AppOrigin?, customFieldsAllowed: Boolean) {
appOrigin?.androidOrigins?.forEach { appIdentifier ->
setApplicationId(appIdentifier.packageName, appIdentifier.fingerprint)
}
appOrigin?.webOrigins?.forEach { webOrigin ->
setWebDomain(webOrigin.origin, null, customFieldsAllowed)
}
}
}

View File

@@ -0,0 +1,86 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.model
import com.kunzisoft.keepass.database.element.DateInstant
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.database.element.template.TemplateField.LABEL_CVV
import com.kunzisoft.keepass.database.element.template.TemplateField.LABEL_HOLDER
import com.kunzisoft.keepass.database.element.template.TemplateField.LABEL_NUMBER
import org.joda.time.DateTime
object CreditCardEntryFields {
const val CREDIT_CARD_TAG = "Credit Card"
/**
* Parse fields of an entry to retrieve a Passkey
*/
fun parseFields(getField: (id: String) -> String?): CreditCard? {
val cardHolderField = getField(LABEL_HOLDER)
val cardNumberField = getField(LABEL_NUMBER)
val cardExpiration = DateTime() // TODO Expiration
val cardCVVField = getField(LABEL_CVV)
if (cardHolderField == null
|| cardNumberField == null)
return null
return CreditCard(
cardholder = cardHolderField,
number = cardNumberField,
expiration = cardExpiration,
cvv = cardCVVField
)
}
fun EntryInfo.setCreditCard(creditCard: CreditCard?) {
if (creditCard != null) {
tags.put(CREDIT_CARD_TAG)
creditCard.cardholder?.let {
addOrReplaceField(
Field(
LABEL_HOLDER,
ProtectedString(enableProtection = false, it)
)
)
}
creditCard.number?.let {
addOrReplaceField(
Field(
LABEL_NUMBER,
ProtectedString(enableProtection = false, it)
)
)
}
creditCard.expiration?.let {
expires = true
expiryTime = DateInstant(creditCard.expiration.toInstant())
}
creditCard.cvv?.let {
addOrReplaceField(
Field(
LABEL_CVV,
ProtectedString(enableProtection = true, it)
)
)
}
}
}
}

View File

@@ -22,18 +22,27 @@ package com.kunzisoft.keepass.model
import android.os.Parcel
import android.os.ParcelUuid
import android.os.Parcelable
import com.kunzisoft.keepass.database.element.*
import com.kunzisoft.keepass.database.element.Attachment
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.Tags
import com.kunzisoft.keepass.database.element.entry.AutoType
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.database.element.template.TemplateField
import com.kunzisoft.keepass.model.AppOriginEntryField.setAppOrigin
import com.kunzisoft.keepass.model.AppOriginEntryField.setApplicationId
import com.kunzisoft.keepass.model.AppOriginEntryField.setWebDomain
import com.kunzisoft.keepass.model.CreditCardEntryFields.setCreditCard
import com.kunzisoft.keepass.model.PasskeyEntryFields.isPasskeyExclusion
import com.kunzisoft.keepass.model.PasskeyEntryFields.setPasskey
import com.kunzisoft.keepass.otp.OtpElement
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
import com.kunzisoft.keepass.otp.OtpEntryFields.isOtpExclusion
import com.kunzisoft.keepass.otp.OtpEntryFields.setOtp
import com.kunzisoft.keepass.utils.readBooleanCompat
import com.kunzisoft.keepass.utils.readListCompat
import com.kunzisoft.keepass.utils.readParcelableCompat
import com.kunzisoft.keepass.utils.writeBooleanCompat
import java.util.*
import java.util.Locale
import java.util.UUID
class EntryInfo : NodeInfo {
@@ -49,6 +58,8 @@ class EntryInfo : NodeInfo {
var attachments: MutableList<Attachment> = mutableListOf()
var autoType: AutoType = AutoType()
var otpModel: OtpModel? = null
var passkey: Passkey? = null
var appOrigin: AppOrigin? = null
var isTemplate: Boolean = false
constructor() : super()
@@ -68,6 +79,8 @@ class EntryInfo : NodeInfo {
parcel.readListCompat(attachments)
autoType = parcel.readParcelableCompat() ?: autoType
otpModel = parcel.readParcelableCompat() ?: otpModel
passkey = parcel.readParcelableCompat() ?: passkey
appOrigin = parcel.readParcelableCompat() ?: appOrigin
isTemplate = parcel.readBooleanCompat()
}
@@ -89,15 +102,16 @@ class EntryInfo : NodeInfo {
parcel.writeList(attachments)
parcel.writeParcelable(autoType, flags)
parcel.writeParcelable(otpModel, flags)
parcel.writeParcelable(passkey, flags)
parcel.writeParcelable(appOrigin, flags)
parcel.writeBooleanCompat(isTemplate)
}
fun containsCustomFieldsProtected(): Boolean {
return customFields.any { it.protectedValue.isProtected }
}
fun containsCustomFieldsNotProtected(): Boolean {
return customFields.any { !it.protectedValue.isProtected }
fun getCustomFieldsForFilling(): List<Field> {
return customFields.filter {
!it.isOtpExclusion()
&& !it.isPasskeyExclusion()
}
}
fun containsCustomField(label: String): Boolean {
@@ -113,133 +127,98 @@ class EntryInfo : NodeInfo {
return customFields.lastOrNull { it.name == label }?.protectedValue?.toString() ?: ""
}
// Return true if modified
private fun addUniqueField(field: Field, number: Int = 0) {
var sameName = false
var sameValue = false
val suffix = if (number > 0) "_$number" else ""
customFields.forEach { currentField ->
// Not write the same data again
if (currentField.protectedValue.stringValue == field.protectedValue.stringValue) {
sameValue = true
return
/**
* Add a field to the custom fields list, replace if name already exists
*/
fun addOrReplaceField(field: Field) {
customFields.lastOrNull { it.name == field.name }?.let {
it.apply {
protectedValue = field.protectedValue
}
// Same name but new value, create a new suffix
if (currentField.name == field.name + suffix) {
sameName = true
addUniqueField(field, number + 1)
return
}
}
if (!sameName && !sameValue)
(customFields as ArrayList<Field>).add(Field(field.name + suffix, field.protectedValue))
}
private fun containsDomainOrApplicationId(search: String): Boolean {
if (url.contains(search))
return true
return customFields.find {
it.protectedValue.stringValue.contains(search)
} != null
} ?: customFields.add(field)
}
/**
* Add searchInfo to current EntryInfo, return true if new data, false if no modification
* Add a field to the custom fields list with a suffix position,
* replace if name already exists
*/
fun saveSearchInfo(database: Database?, searchInfo: SearchInfo): Boolean {
var modification = false
fun addOrReplaceFieldWithSuffix(field: Field, position: Int) {
addOrReplaceField(Field(
field.name + suffixFieldNamePosition(position),
field.protectedValue)
)
}
/**
* Add an unique field to the custom fields list with a suffix
* if name already exists and value not the same
* @param field the field to add
* @param position the number to add to the suffix
* @return the increment number and the custom field created
*/
fun addUniqueField(field: Field, position: Int = 0): Pair<Int, Field> {
val suffix = suffixFieldNamePosition(position)
if (customFields.any { currentField -> currentField.name == field.name + suffix }) {
val fieldFound = customFields.find {
it.name == field.name + suffix
&& it.protectedValue.stringValue == field.protectedValue.stringValue
}
return if (fieldFound != null) {
Pair(position, fieldFound)
} else {
addUniqueField(field, position + 1)
}
} else {
val field = Field(field.name + suffix, field.protectedValue)
customFields.add(field)
return Pair(position, field)
}
}
/**
* Capitalize and remove suffix of a title
*/
fun String.toTitle(): String {
return this.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
}
/**
* Add searchInfo to current EntryInfo
*/
fun saveSearchInfo(database: Database?, searchInfo: SearchInfo) {
searchInfo.otpString?.let { otpString ->
// Replace the OTP field
OtpEntryFields.parseOTPUri(otpString)?.let { otpElement ->
if (title.isEmpty())
title = otpElement.issuer
if (username.isEmpty())
username = otpElement.name
// Add OTP field
val mutableCustomFields = customFields as ArrayList<Field>
val otpField = OtpEntryFields.buildOtpField(otpElement, null, null)
if (mutableCustomFields.contains(otpField)) {
mutableCustomFields.remove(otpField)
}
mutableCustomFields.add(otpField)
modification = true
}
setOtp(otpString)
} ?: searchInfo.webDomain?.let { webDomain ->
// If unable to save web domain in custom field or URL not populated, save in URL
val scheme = searchInfo.webScheme
val webScheme = if (scheme.isNullOrEmpty()) "https" else scheme
val webDomainToStore = "$webScheme://$webDomain"
if (!containsDomainOrApplicationId(webDomain)) {
if (database?.allowEntryCustomFields() != true || url.isEmpty()) {
url = webDomainToStore
} else {
// Save web domain in custom field
addUniqueField(
Field(
WEB_DOMAIN_FIELD_NAME,
ProtectedString(false, webDomainToStore)
),
1 // Start to one because URL is a standard field name
)
}
modification = true
}
setWebDomain(
webDomain,
searchInfo.webScheme,
database?.allowEntryCustomFields() == true
)
} ?: searchInfo.applicationId?.let { applicationId ->
// Save application id in custom field
if (database?.allowEntryCustomFields() == true) {
if (!containsDomainOrApplicationId(applicationId)) {
addUniqueField(
Field(
APPLICATION_ID_FIELD_NAME,
ProtectedString(false, applicationId)
)
)
modification = true
}
}
setApplicationId(applicationId)
}
if (title.isEmpty()) {
title = searchInfoToTitle(searchInfo)
title = searchInfo.toString().toTitle()
}
return modification
}
/**
* Capitalize and remove suffix of web domain to create a title
* Add registerInfo to current EntryInfo
*/
private fun searchInfoToTitle(searchInfo: SearchInfo): String {
val webDomain = searchInfo.webDomain
return webDomain?.substring(0, webDomain.lastIndexOf('.'))?.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
} ?: searchInfo.toString()
}
fun saveRegisterInfo(database: Database?, registerInfo: RegisterInfo) {
registerInfo.searchInfo.let {
title = searchInfoToTitle(it)
}
registerInfo.username?.let {
username = it
}
registerInfo.password?.let {
password = it
}
if (database?.allowEntryCustomFields() == true) {
val creditCard: CreditCard? = registerInfo.creditCard
creditCard?.cardholder?.let {
addUniqueField(Field(TemplateField.LABEL_HOLDER, ProtectedString(false, it)))
}
creditCard?.expiration?.let {
expires = true
expiryTime = DateInstant(creditCard.expiration.toInstant())
}
creditCard?.number?.let {
addUniqueField(Field(TemplateField.LABEL_NUMBER, ProtectedString(false, it)))
}
creditCard?.cvv?.let {
addUniqueField(Field(TemplateField.LABEL_CVV, ProtectedString(true, it)))
}
saveSearchInfo(database, registerInfo.searchInfo)
registerInfo.username?.let { username = it }
registerInfo.password?.let { password = it }
setCreditCard(registerInfo.creditCard)
setPasskey(registerInfo.passkey)
setAppOrigin(
registerInfo.appOrigin,
database?.allowEntryCustomFields() == true
)
if (title.isEmpty()) {
title = registerInfo.toString().toTitle()
}
}
@@ -268,6 +247,8 @@ class EntryInfo : NodeInfo {
if (attachments != other.attachments) return false
if (autoType != other.autoType) return false
if (otpModel != other.otpModel) return false
if (passkey != other.passkey) return false
if (appOrigin != other.appOrigin) return false
if (isTemplate != other.isTemplate) return false
return true
@@ -287,6 +268,8 @@ class EntryInfo : NodeInfo {
result = 31 * result + attachments.hashCode()
result = 31 * result + autoType.hashCode()
result = 31 * result + (otpModel?.hashCode() ?: 0)
result = 31 * result + (passkey?.hashCode() ?: 0)
result = 31 * result + (appOrigin?.hashCode() ?: 0)
result = 31 * result + isTemplate.hashCode()
return result
}
@@ -294,8 +277,12 @@ class EntryInfo : NodeInfo {
companion object {
const val WEB_DOMAIN_FIELD_NAME = "URL"
const val APPLICATION_ID_FIELD_NAME = "AndroidApp"
/**
* Create a field name suffix depending on the field position
*/
fun suffixFieldNamePosition(position: Int): String {
return if (position > 0) "_$position" else ""
}
@JvmField
val CREATOR: Parcelable.Creator<EntryInfo> = object : Parcelable.Creator<EntryInfo> {

View File

@@ -0,0 +1,32 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Passkey(
val username: String,
val privateKeyPem: String,
val credentialId: String,
val userHandle: String,
val relyingParty: String
): Parcelable

View File

@@ -0,0 +1,139 @@
package com.kunzisoft.keepass.model
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.security.ProtectedString
object PasskeyEntryFields {
// field names from KeypassXC are used
const val FIELD_USERNAME = "KPEX_PASSKEY_USERNAME"
const val FIELD_PRIVATE_KEY = "KPEX_PASSKEY_PRIVATE_KEY_PEM"
const val FIELD_CREDENTIAL_ID = "KPEX_PASSKEY_CREDENTIAL_ID"
const val FIELD_USER_HANDLE = "KPEX_PASSKEY_USER_HANDLE"
const val FIELD_RELYING_PARTY = "KPEX_PASSKEY_RELYING_PARTY"
const val PASSKEY_FIELD = "Passkey"
const val PASSKEY_TAG = "Passkey"
/**
* Parse fields of an entry to retrieve a Passkey
*/
fun parseFields(getField: (id: String) -> String?): Passkey? {
val usernameField = getField(FIELD_USERNAME)
val privateKeyField = getField(FIELD_PRIVATE_KEY)
val credentialIdField = getField(FIELD_CREDENTIAL_ID)
val userHandleField = getField(FIELD_USER_HANDLE)
val relyingPartyField = getField(FIELD_RELYING_PARTY)
if (usernameField == null
|| privateKeyField == null
|| credentialIdField == null
|| userHandleField == null
|| relyingPartyField == null)
return null
return Passkey(
username = usernameField,
privateKeyPem = privateKeyField,
credentialId = credentialIdField,
userHandle = userHandleField,
relyingParty = relyingPartyField
)
}
fun EntryInfo.setPasskey(passkey: Passkey?) {
if (passkey != null) {
tags.put(PASSKEY_TAG)
addOrReplaceField(
Field(
FIELD_USERNAME,
ProtectedString(enableProtection = false, passkey.username)
)
)
addOrReplaceField(
Field(
FIELD_PRIVATE_KEY,
ProtectedString(enableProtection = true, passkey.privateKeyPem)
)
)
addOrReplaceField(
Field(
FIELD_CREDENTIAL_ID,
ProtectedString(enableProtection = true, passkey.credentialId)
)
)
addOrReplaceField(
Field(
FIELD_USER_HANDLE,
ProtectedString(enableProtection = true, passkey.userHandle)
)
)
addOrReplaceField(
Field(
FIELD_RELYING_PARTY,
ProtectedString(enableProtection = false, passkey.relyingParty)
)
)
}
}
/**
* Build Passkey field from a Passkey
*/
fun buildPasskeyField(passkey: Passkey): Field {
return Field(
name = PASSKEY_FIELD,
value = ProtectedString(
enableProtection = false,
string = passkey.relyingParty
)
)
}
/**
* Build new generated fields in a new list from [fieldsToParse] in parameter,
* Remove parameters fields use to generate auto fields
*/
fun generateAutoFields(fieldsToParse: List<Field>): MutableList<Field> {
val newCustomFields: MutableList<Field> = ArrayList(fieldsToParse)
// Remove parameter fields
val usernameField = Field(FIELD_USERNAME)
val privateKeyField = Field(FIELD_PRIVATE_KEY)
val credentialIdField = Field(FIELD_CREDENTIAL_ID)
val userHandleField = Field(FIELD_USER_HANDLE)
val relyingPartyField = Field(FIELD_RELYING_PARTY)
newCustomFields.remove(usernameField)
newCustomFields.remove(privateKeyField)
newCustomFields.remove(credentialIdField)
newCustomFields.remove(userHandleField)
newCustomFields.remove(relyingPartyField)
// Empty auto generated OTP Token field
if (fieldsToParse.contains(usernameField)
|| fieldsToParse.contains(privateKeyField)
|| fieldsToParse.contains(credentialIdField)
|| fieldsToParse.contains(userHandleField)
|| fieldsToParse.contains(relyingPartyField)
)
newCustomFields.add(
Field(
name = PASSKEY_FIELD,
value = ProtectedString(enableProtection = false)
)
)
return newCustomFields
}
/**
* Field ignored for a search or a form filling
*/
fun Field.isPasskeyExclusion(): Boolean {
return when(name) {
PASSKEY_FIELD -> true
FIELD_USERNAME -> true
FIELD_PRIVATE_KEY -> true
FIELD_CREDENTIAL_ID -> true
FIELD_USER_HANDLE -> true
FIELD_RELYING_PARTY -> true
else -> false
}
}
}

View File

@@ -1,32 +1,56 @@
package com.kunzisoft.keepass.model
import android.content.res.Resources
import android.os.Parcel
import android.os.Parcelable
import com.kunzisoft.keepass.utils.ObjectNameResource
import com.kunzisoft.keepass.utils.readParcelableCompat
data class RegisterInfo(val searchInfo: SearchInfo,
val username: String?,
val password: String?,
val creditCard: CreditCard?): Parcelable {
data class RegisterInfo(
val searchInfo: SearchInfo,
val username: String? = null,
val password: String? = null,
val creditCard: CreditCard? = null,
val passkey: Passkey? = null,
val appOrigin: AppOrigin? = null
) : ObjectNameResource, Parcelable {
constructor(parcel: Parcel) : this(
parcel.readParcelableCompat() ?: SearchInfo(),
parcel.readString() ?: "",
parcel.readString() ?: "",
parcel.readParcelableCompat()) {
}
searchInfo = parcel.readParcelableCompat() ?: SearchInfo(),
username = parcel.readString(),
password = parcel.readString(),
creditCard = parcel.readParcelableCompat(),
passkey = parcel.readParcelableCompat(),
appOrigin = parcel.readParcelableCompat()
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeParcelable(searchInfo, flags)
parcel.writeString(username)
parcel.writeString(password)
parcel.writeParcelable(creditCard, flags)
parcel.writeParcelable(passkey, flags)
parcel.writeParcelable(appOrigin, flags)
}
override fun describeContents(): Int {
return 0
}
override fun getName(resources: Resources): String {
return username
?: passkey?.relyingParty
?: appOrigin?.toName()
?: searchInfo.getName(resources)
}
override fun toString(): String {
return username
?: passkey?.relyingParty
?: appOrigin?.toName()
?: searchInfo.toString()
}
companion object CREATOR : Parcelable.Creator<RegisterInfo> {
override fun createFromParcel(parcel: Parcel): RegisterInfo {
return RegisterInfo(parcel)

View File

@@ -4,6 +4,7 @@ import android.content.res.Resources
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import com.kunzisoft.keepass.database.search.SearchParameters
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.utils.ObjectNameResource
import com.kunzisoft.keepass.utils.readBooleanCompat
@@ -11,6 +12,7 @@ import com.kunzisoft.keepass.utils.writeBooleanCompat
class SearchInfo : ObjectNameResource, Parcelable {
var manualSelection: Boolean = false
var tag: String? = null
var applicationId: String? = null
set(value) {
field = when {
@@ -33,26 +35,33 @@ class SearchInfo : ObjectNameResource, Parcelable {
get() {
return if (webDomain == null) null else field
}
var relyingParty: String? = null
var otpString: String? = null
constructor()
constructor(toCopy: SearchInfo?) {
manualSelection = toCopy?.manualSelection ?: manualSelection
tag = toCopy?.tag
applicationId = toCopy?.applicationId
webDomain = toCopy?.webDomain
webScheme = toCopy?.webScheme
relyingParty = toCopy?.relyingParty
otpString = toCopy?.otpString
}
private constructor(parcel: Parcel) {
manualSelection = parcel.readBooleanCompat()
val readTag = parcel.readString()
tag = if (readTag.isNullOrEmpty()) null else readTag
val readAppId = parcel.readString()
applicationId = if (readAppId.isNullOrEmpty()) null else readAppId
applicationId = if (readAppId.isNullOrEmpty()) null else readAppId
val readDomain = parcel.readString()
webDomain = if (readDomain.isNullOrEmpty()) null else readDomain
val readScheme = parcel.readString()
webScheme = if (readScheme.isNullOrEmpty()) null else readScheme
val readRelyingParty = parcel.readString()
relyingParty = if (readRelyingParty.isNullOrEmpty()) null else readRelyingParty
val readOtp = parcel.readString()
otpString = if (readOtp.isNullOrEmpty()) null else readOtp
}
@@ -63,9 +72,11 @@ class SearchInfo : ObjectNameResource, Parcelable {
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeBooleanCompat(manualSelection)
parcel.writeString(tag ?: "")
parcel.writeString(applicationId ?: "")
parcel.writeString(webDomain ?: "")
parcel.writeString(webScheme ?: "")
parcel.writeString(relyingParty ?: "")
parcel.writeString(otpString ?: "")
}
@@ -79,24 +90,54 @@ class SearchInfo : ObjectNameResource, Parcelable {
}
fun containsOnlyNullValues(): Boolean {
return applicationId == null
return tag == null
&& applicationId == null
&& webDomain == null
&& webScheme == null
&& relyingParty == null
&& otpString == null
}
fun isASearchByDomain(): Boolean {
private fun isADomainSearch(): Boolean {
return toString() == webDomain && webDomain != null
}
var isAPasskeySearch: Boolean = false
var query: String? = null
fun buildSearchParameters(): SearchParameters {
return SearchParameters().apply {
searchQuery = query ?: this@SearchInfo.toString()
allowEmptyQuery = false
searchInTitles = !isAPasskeySearch
searchInUsernames = false
searchInPasswords = false
searchInUrls = !isAPasskeySearch
searchByDomain = isADomainSearch()
searchInNotes = false
searchInOTP = false
searchInOther = true
searchInUUIDs = false
searchInTags = false
searchInRelyingParty = isAPasskeySearch
searchInCurrentGroup = false
searchInSearchableGroup = true
searchInRecycleBin = false
searchInTemplates = false
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SearchInfo) return false
if (manualSelection != other.manualSelection) return false
if (tag != other.tag) return false
if (applicationId != other.applicationId) return false
if (webDomain != other.webDomain) return false
if (webScheme != other.webScheme) return false
if (relyingParty != other.relyingParty) return false
if (otpString != other.otpString) return false
return true
@@ -104,15 +145,17 @@ class SearchInfo : ObjectNameResource, Parcelable {
override fun hashCode(): Int {
var result = manualSelection.hashCode()
result = 31 * result + (tag?.hashCode() ?: 0)
result = 31 * result + (applicationId?.hashCode() ?: 0)
result = 31 * result + (webDomain?.hashCode() ?: 0)
result = 31 * result + (webScheme?.hashCode() ?: 0)
result = 31 * result + (relyingParty?.hashCode() ?: 0)
result = 31 * result + (otpString?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return otpString ?: webDomain ?: applicationId ?: ""
return otpString ?: webDomain ?: applicationId ?: relyingParty ?: tag ?: ""
}
companion object {

View File

@@ -24,6 +24,7 @@ import android.net.Uri
import android.util.Log
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.otp.TokenCalculator.HOTP_INITIAL_COUNTER
import com.kunzisoft.keepass.otp.TokenCalculator.HashAlgorithm
import com.kunzisoft.keepass.otp.TokenCalculator.OTP_DEFAULT_DIGITS
@@ -434,6 +435,25 @@ object OtpEntryFields {
buildOtpUri(otpElement, title, username).toString()))
}
fun EntryInfo.setOtp(otpString: String): Boolean {
// Replace the OTP field
parseOTPUri(otpString)?.let { otpElement ->
if (title.isEmpty())
title = otpElement.issuer
if (username.isEmpty())
username = otpElement.name
// Add OTP field
val mutableCustomFields = customFields as ArrayList<Field>
val otpField = OtpEntryFields.buildOtpField(otpElement, null, null)
if (mutableCustomFields.contains(otpField)) {
mutableCustomFields.remove(otpField)
}
mutableCustomFields.add(otpField)
return true
}
return false
}
/**
* Build new generated fields in a new list from [fieldsToParse] in parameter,
* Remove parameters fields use to generate auto fields
@@ -486,4 +506,28 @@ object OtpEntryFields {
newCustomFields.add(Field(OTP_TOKEN_FIELD))
return newCustomFields
}
/**
* Field ignored for a search or a form filling
*/
fun Field.isOtpExclusion(): Boolean {
return when(name) {
OTP_FIELD -> true
TOTP_SEED_FIELD -> true
TOTP_SETTING_FIELD -> true
HMACOTP_SECRET_FIELD -> true
HMACOTP_SECRET_HEX_FIELD -> true
HMACOTP_SECRET_BASE32_FIELD -> true
HMACOTP_SECRET_BASE64_FIELD -> true
HMACOTP_SECRET_COUNTER_FIELD -> true
TIMEOTP_SECRET_FIELD -> true
TIMEOTP_SECRET_HEX_FIELD -> true
TIMEOTP_SECRET_BASE32_FIELD -> true
TIMEOTP_SECRET_BASE64_FIELD -> true
TIMEOTP_LENGTH_FIELD -> true
TIMEOTP_PERIOD_FIELD -> true
TIMEOTP_ALGORITHM_FIELD -> true
else -> false
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2019 Jeremy Jamet / Kunzisoft.
*
* This file is part of KeePassDX.
*
* KeePassDX is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* KeePassDX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.utils
import java.nio.ByteBuffer
import java.util.Locale
import java.util.UUID
object UUIDUtils {
/**
* Specific UUID string format for KeePass database
*/
fun UUID.asHexString(): String? {
try {
val buf = uuidTo16Bytes(this)
val len = buf.size
if (len == 0) {
return ""
}
val sb = StringBuilder()
var bt: Short
var high: Char
var low: Char
for (b in buf) {
bt = (b.toInt() and 0xFF).toShort()
high = (bt.toInt() ushr 4).toChar()
low = (bt.toInt() and 0x0F).toChar()
sb.append(byteToChar(high))
sb.append(byteToChar(low))
}
return sb.toString()
} catch (e: Exception) {
return null
}
}
/**
* From a specific UUID KeePass database string format,
* Note : For a standard UUID string format, use UUID.fromString()
*/
fun String.asUUID(): UUID? {
if (this.length != 32) return null
val charArray = this.lowercase(Locale.getDefault()).toCharArray()
val leastSignificantChars = CharArray(16)
val mostSignificantChars = CharArray(16)
var i = 31
while (i >= 0) {
if (i >= 16) {
mostSignificantChars[32 - i] = charArray[i]
mostSignificantChars[31 - i] = charArray[i - 1]
} else {
leastSignificantChars[16 - i] = charArray[i]
leastSignificantChars[15 - i] = charArray[i - 1]
}
i = i - 2
}
val standardUUIDString = StringBuilder()
standardUUIDString.append(leastSignificantChars)
standardUUIDString.append(mostSignificantChars)
standardUUIDString.insert(8, '-')
standardUUIDString.insert(13, '-')
standardUUIDString.insert(18, '-')
standardUUIDString.insert(23, '-')
return try {
UUID.fromString(standardUUIDString.toString())
} catch (e: Exception) {
null
}
}
fun ByteArray.asUUID(): UUID {
val bb = ByteBuffer.wrap(this)
val firstLong = bb.getLong()
val secondLong = bb.getLong()
return UUID(firstLong, secondLong)
}
fun UUID.asBytes(): ByteArray {
return ByteBuffer.allocate(16).apply {
putLong(mostSignificantBits)
putLong(leastSignificantBits)
}.array()
}
// Use short to represent unsigned byte
private fun byteToChar(bt: Char): Char {
return if (bt.code >= 10) {
('A'.code + bt.code - 10).toChar()
} else {
('0'.code + bt.code).toChar()
}
}
}

View File

@@ -1,101 +0,0 @@
/*
* Copyright 2019 Jeremy Jamet / Kunzisoft.
*
* This file is part of KeePassDX.
*
* KeePassDX is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* KeePassDX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.utils;
import java.util.UUID;
import static com.kunzisoft.keepass.utils.StreamBytesUtilsKt.uuidTo16Bytes;
import org.jetbrains.annotations.Nullable;
public class UuidUtil {
public static @Nullable String toHexString(@Nullable UUID uuid) {
if (uuid == null) { return null; }
try {
byte[] buf = uuidTo16Bytes(uuid);
int len = buf.length;
if (len == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
short bt;
char high, low;
for (byte b : buf) {
bt = (short) (b & 0xFF);
high = (char) (bt >>> 4);
low = (char) (bt & 0x0F);
sb.append(byteToChar(high));
sb.append(byteToChar(low));
}
return sb.toString();
} catch (Exception e) {
return null;
}
}
public static @Nullable UUID fromHexString(@Nullable String hexString) {
if (hexString == null)
return null;
if (hexString.length() != 32)
return null;
char[] charArray = hexString.toLowerCase().toCharArray();
char[] leastSignificantChars = new char[16];
char[] mostSignificantChars = new char[16];
for (int i = 31; i >= 0; i = i-2) {
if (i >= 16) {
mostSignificantChars[32-i] = charArray[i];
mostSignificantChars[31-i] = charArray[i-1];
} else {
leastSignificantChars[16-i] = charArray[i];
leastSignificantChars[15-i] = charArray[i-1];
}
}
StringBuilder standardUUIDString = new StringBuilder();
standardUUIDString.append(leastSignificantChars);
standardUUIDString.append(mostSignificantChars);
standardUUIDString.insert(8, '-');
standardUUIDString.insert(13, '-');
standardUUIDString.insert(18, '-');
standardUUIDString.insert(23, '-');
try {
return UUID.fromString(standardUUIDString.toString());
} catch (Exception e) {
return null;
}
}
// Use short to represent unsigned byte
private static char byteToChar(char bt) {
if (bt >= 10) {
return (char)('A' + bt - 10);
}
else {
return (char)('0' + bt);
}
}
}

View File

@@ -1,15 +1,49 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.tests.utils
import com.kunzisoft.keepass.utils.UuidUtil
import com.kunzisoft.keepass.utils.UUIDUtils.asBytes
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.UUIDUtils.asUUID
import junit.framework.TestCase
import java.util.*
import java.util.UUID
class UUIDTest: TestCase() {
fun testUUID() {
fun testUUIDHexString() {
val randomUUID = UUID.randomUUID()
val hexStringUUID = UuidUtil.toHexString(randomUUID)
val retrievedUUID = UuidUtil.fromHexString(hexStringUUID)
val hexStringUUID = randomUUID.asHexString()
val retrievedUUID = hexStringUUID?.asUUID()
assertEquals(randomUUID, retrievedUUID)
}
fun testUUIDString() {
val staticUUID = "4be0643f-1d98-573b-97cd-ca98a65347dd"
val stringUUID = UUID.fromString(staticUUID).asBytes().asUUID().toString()
assertEquals(staticUUID, stringUUID)
}
fun testUUIDBytes() {
val randomUUID = UUID.randomUUID()
val byteArrayUUID = randomUUID.asBytes()
val retrievedUUID = byteArrayUUID.asUUID()
assertEquals(randomUUID, retrievedUUID)
}
}

View File

@@ -1,3 +1,4 @@
#Sun Sep 08 17:39:21 CEST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip

View File

@@ -6,7 +6,7 @@ android {
compileSdkVersion 34
defaultConfig {
minSdkVersion 14
minSdkVersion 19
targetSdkVersion 34
}

View File

@@ -5,7 +5,7 @@ android {
compileSdkVersion 34
defaultConfig {
minSdkVersion 14
minSdkVersion 19
targetSdkVersion 34
}

Some files were not shown because too many files have changed in this diff Show More