diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 701d55e3..1a1f7bd4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.androidx.room)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
@@ -64,11 +63,13 @@ android {
kotlin {
compilerOptions {
+ freeCompilerArgs.add("-Xcontext-parameters")
jvmTarget = JvmTarget.JVM_17
}
}
dependencies {
+ implementation(libs.okhttp)
// Koin DI
implementation(project.dependencies.platform(libs.koin.bom))
@@ -77,14 +78,7 @@ dependencies {
implementation(libs.koin.annotations)
ksp(libs.koin.ksp.compiler)
- // Room
- implementation(libs.androidx.room.runtime)
- implementation(libs.androidx.room.ktx)
- ksp(libs.androidx.room.compiler)
-
- // Automation
- implementation(projects.automation)
- ksp(projects.automationProcessor)
+ implementation(projects.core.item)
// Datastore
implementation(libs.androidx.datastore)
@@ -96,6 +90,8 @@ dependencies {
implementation(libs.offrange.passgen)
implementation(libs.argon2kt)
+ implementation(libs.androidx.autofill)
+
// Use GMS ML Kit for barcode scanning on the Play Store to minimize app size; use ZXing for F-Droid builds.
"playStoreImplementation"(libs.gms.mlkit.barcode.scanning)
"fdroidImplementation"(libs.zxing.barcode.scanning)
@@ -153,12 +149,4 @@ protobuf {
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
-}
-
-room {
- schemaDirectory("$projectDir/schemas")
-}
-
-ksp {
- arg("automation.packageName", "de.davis.keygo.generated")
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1ea35187..6f0cb358 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -43,6 +43,24 @@
android:scheme="otpauth" />
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt b/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt
index fca42191..3b837392 100644
--- a/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt
+++ b/app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt
@@ -1,5 +1,15 @@
package de.davis.keygo.app
import android.app.Application
+import de.davis.keygo.app.di.init
+import org.koin.core.context.startKoin
-class KeyGoApplication : Application()
\ No newline at end of file
+class KeyGoApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ startKoin {
+ init(this@KeyGoApplication)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/app/di/Koin.kt b/app/src/main/kotlin/de/davis/keygo/app/di/Koin.kt
index 4e7342f4..a9d7d9b3 100644
--- a/app/src/main/kotlin/de/davis/keygo/app/di/Koin.kt
+++ b/app/src/main/kotlin/de/davis/keygo/app/di/Koin.kt
@@ -2,8 +2,10 @@ package de.davis.keygo.app.di
import android.content.Context
import de.davis.keygo.auth.di.AuthModule
-import de.davis.keygo.core.data.local.datasource.KeyGoDatabase
+import de.davis.keygo.autofill.di.AutofillModule
import de.davis.keygo.core.di.CoreModule
+import de.davis.keygo.core.item.data.local.datasource.ItemDatabase
+import de.davis.keygo.core.util.di.CoreUtilModule
import de.davis.keygo.dashboard.di.DashboardModule
import de.davis.keygo.item.di.ItemModule
import de.davis.keygo.migration.create_access.di.MigrationCreateAccessModule
@@ -17,12 +19,14 @@ fun KoinApplication.init(androidContext: Context) {
// modules
modules(
- KeyGoDatabase.koinModule,
+ ItemDatabase.koinModule,
CoreModule.module,
+ CoreUtilModule.module,
AuthModule.module,
DashboardModule.module,
ItemModule.module,
TotpModule.module,
+ AutofillModule.module,
MigrationCreateAccessModule.module
)
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/KeyDerivationRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/auth/data/repository/KeyDerivationRepositoryImpl.kt
index 9c519830..fadfc2a8 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/KeyDerivationRepositoryImpl.kt
+++ b/app/src/main/kotlin/de/davis/keygo/auth/data/repository/KeyDerivationRepositoryImpl.kt
@@ -3,9 +3,9 @@ package de.davis.keygo.auth.data.repository
import com.lambdapioneer.argon2kt.Argon2Kt
import com.lambdapioneer.argon2kt.Argon2Mode
import com.lambdapioneer.argon2kt.Argon2Version
-import de.davis.keygo.auth.domain.model.CryptographyError
import de.davis.keygo.auth.domain.repository.KeyDerivationRepository
-import de.davis.keygo.core.domain.Result
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.util.Result
import org.koin.core.annotation.Single
@Single
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/PasswordWrappedKeyRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/auth/data/repository/PasswordWrappedKeyRepositoryImpl.kt
new file mode 100644
index 00000000..b094d762
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/auth/data/repository/PasswordWrappedKeyRepositoryImpl.kt
@@ -0,0 +1,22 @@
+package de.davis.keygo.auth.data.repository
+
+import androidx.datastore.core.DataStore
+import de.davis.keygo.auth.data.local.model.ProtoPasswordKeyData
+import de.davis.keygo.auth.data.mapper.toDomain
+import de.davis.keygo.auth.data.mapper.toProto
+import de.davis.keygo.auth.domain.model.PasswordWrappedKeyData
+import de.davis.keygo.core.di.annotation.PasswordQualifier
+import de.davis.keygo.core.identity.common.data.repository.DefaultWrappedKeyRepository
+import de.davis.keygo.core.identity.common.domain.repository.WrappedKeyRepository
+import org.koin.core.annotation.Single
+
+@Suppress("FunctionName")
+@Single(binds = [WrappedKeyRepository::class])
+@PasswordQualifier
+fun PasswordWrappedKeyRepositoryImpl(@PasswordQualifier dataStore: DataStore) =
+ DefaultWrappedKeyRepository(
+ dataStore = dataStore,
+ toDomain = ProtoPasswordKeyData::toDomain,
+ toProto = PasswordWrappedKeyData::toProto,
+ defaultInstance = ProtoPasswordKeyData::getDefaultInstance
+ )
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/di/AuthModule.kt b/app/src/main/kotlin/de/davis/keygo/auth/di/AuthModule.kt
index ce2e863b..00a3344e 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/di/AuthModule.kt
+++ b/app/src/main/kotlin/de/davis/keygo/auth/di/AuthModule.kt
@@ -3,9 +3,7 @@ package de.davis.keygo.auth.di
import android.content.Context
import androidx.datastore.dataStore
import com.lambdapioneer.argon2kt.Argon2Kt
-import de.davis.keygo.auth.data.local.model.ProtoBiometricKeyData
import de.davis.keygo.auth.data.local.model.ProtoPasswordKeyData
-import de.davis.keygo.auth.di.annotation.BiometricQualifier
import de.davis.keygo.core.di.DefaultProtoSerializer
import de.davis.keygo.core.di.annotation.PasswordQualifier
import org.koin.core.annotation.ComponentScan
@@ -24,14 +22,6 @@ object AuthModule {
)
)
- private val Context.protoBiometricKeyDataStore by dataStore(
- "biometric_key_data.pb",
- DefaultProtoSerializer(
- defaultInstance = ProtoBiometricKeyData.getDefaultInstance(),
- parser = ProtoBiometricKeyData.parser()
- )
- )
-
@Single
internal fun provideArgon2Kt() = Argon2Kt()
@@ -39,9 +29,4 @@ object AuthModule {
@PasswordQualifier
internal fun provideProtoPasswordKeyDataStore(context: Context) =
context.protoPasswordKeyDataStore
-
- @Single
- @BiometricQualifier
- internal fun provideProtoBiometricKeyDataStore(context: Context) =
- context.protoBiometricKeyDataStore
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricRequest.kt b/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricRequest.kt
deleted file mode 100644
index 22c2f34c..00000000
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricRequest.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.davis.keygo.auth.domain.model
-
-import javax.crypto.Cipher
-
-sealed interface BiometricRequest {
-
- data object Class2 : BiometricRequest
- data class Class3(val cipher: Cipher) : BiometricRequest
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricResult.kt b/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricResult.kt
deleted file mode 100644
index 38592c56..00000000
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricResult.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.davis.keygo.auth.domain.model
-
-sealed interface BiometricResult {
-
- data object Success : BiometricResult
- data object Failed : BiometricResult
- data object Error : BiometricResult
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/BiometricAvailabilityRepository.kt b/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/BiometricAvailabilityRepository.kt
deleted file mode 100644
index 24d2455d..00000000
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/BiometricAvailabilityRepository.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.davis.keygo.auth.domain.repository
-
-import de.davis.keygo.auth.domain.model.BiometricAvailability
-import de.davis.keygo.auth.domain.model.BiometricClass
-
-interface BiometricAvailabilityRepository {
-
- fun availability(biometricClass: BiometricClass): BiometricAvailability
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/KeyDerivationRepository.kt b/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/KeyDerivationRepository.kt
index 5b44facb..ee98428e 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/KeyDerivationRepository.kt
+++ b/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/KeyDerivationRepository.kt
@@ -1,7 +1,7 @@
package de.davis.keygo.auth.domain.repository
-import de.davis.keygo.auth.domain.model.CryptographyError
-import de.davis.keygo.core.domain.Result
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.util.Result
interface KeyDerivationRepository {
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/PasswordWrappedKeyRepository.kt b/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/PasswordWrappedKeyRepository.kt
index 0057ab88..55978316 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/PasswordWrappedKeyRepository.kt
+++ b/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/PasswordWrappedKeyRepository.kt
@@ -1,23 +1,6 @@
package de.davis.keygo.auth.domain.repository
-import androidx.datastore.core.DataStore
-import de.davis.keygo.auth.data.local.model.ProtoPasswordKeyData
-import de.davis.keygo.auth.data.mapper.toDomain
-import de.davis.keygo.auth.data.mapper.toProto
-import de.davis.keygo.auth.data.repository.DefaultWrappedKeyRepository
import de.davis.keygo.auth.domain.model.PasswordWrappedKeyData
-import de.davis.keygo.core.di.annotation.PasswordQualifier
-import org.koin.core.annotation.Single
+import de.davis.keygo.core.identity.common.domain.repository.WrappedKeyRepository
-typealias PasswordWrappedKeyRepository = WrappedKeyRepository
-
-@Suppress("FunctionName")
-@Single(binds = [WrappedKeyRepository::class])
-@PasswordQualifier
-fun PasswordWrappedKeyRepositoryImpl(@PasswordQualifier dataStore: DataStore) =
- DefaultWrappedKeyRepository(
- dataStore = dataStore,
- toDomain = ProtoPasswordKeyData::toDomain,
- toProto = PasswordWrappedKeyData::toProto,
- defaultInstance = ProtoPasswordKeyData::getDefaultInstance
- )
\ No newline at end of file
+typealias PasswordWrappedKeyRepository = WrappedKeyRepository
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/CreateAccessUseCase.kt b/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/CreateAccessUseCase.kt
index 78522c02..7ca68b6e 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/CreateAccessUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/CreateAccessUseCase.kt
@@ -1,22 +1,22 @@
package de.davis.keygo.auth.domain.usecase
-import de.davis.keygo.auth.data.repository.BiometricWrappedKeyRepository
-import de.davis.keygo.auth.di.annotation.BiometricQualifier
-import de.davis.keygo.auth.domain.factory.CipherFactory
-import de.davis.keygo.auth.domain.model.BiometricWrappedKeyData
-import de.davis.keygo.auth.domain.model.CryptographicMode
-import de.davis.keygo.auth.domain.model.CryptographyError
import de.davis.keygo.auth.domain.model.PasswordWrappedKeyData
import de.davis.keygo.auth.domain.repository.DeviceInfoRepository
import de.davis.keygo.auth.domain.repository.KeyDerivationRepository
import de.davis.keygo.auth.domain.repository.PasswordWrappedKeyRepository
+import de.davis.keygo.core.di.annotation.BiometricQualifier
import de.davis.keygo.core.di.annotation.PasswordQualifier
-import de.davis.keygo.core.domain.Result
import de.davis.keygo.core.domain.Session
-import de.davis.keygo.core.domain.asUnitResult
import de.davis.keygo.core.domain.model.crypto.asAesKey
-import de.davis.keygo.core.domain.onSuccess
-import de.davis.keygo.core.domain.zip
+import de.davis.keygo.core.identity.common.domain.CipherFactory
+import de.davis.keygo.core.identity.common.domain.model.BiometricWrappedKeyData
+import de.davis.keygo.core.identity.common.domain.model.CryptographicMode
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.identity.common.domain.repository.BiometricWrappedKeyRepository
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.asUnitResult
+import de.davis.keygo.core.util.onSuccess
+import de.davis.keygo.core.util.zip
import org.koin.core.annotation.Single
import java.security.SecureRandom
import javax.crypto.Cipher
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithPasswordUseCase.kt b/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithPasswordUseCase.kt
index 27266884..29244189 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithPasswordUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithPasswordUseCase.kt
@@ -1,19 +1,19 @@
package de.davis.keygo.auth.domain.usecase
-import de.davis.keygo.auth.domain.factory.CipherFactory
-import de.davis.keygo.auth.domain.model.CryptographicMode
-import de.davis.keygo.auth.domain.model.CryptographyError
import de.davis.keygo.auth.domain.repository.DeviceInfoRepository
import de.davis.keygo.auth.domain.repository.KeyDerivationRepository
import de.davis.keygo.auth.domain.repository.PasswordWrappedKeyRepository
import de.davis.keygo.core.di.annotation.PasswordQualifier
-import de.davis.keygo.core.domain.Result
import de.davis.keygo.core.domain.Session
-import de.davis.keygo.core.domain.asResult
-import de.davis.keygo.core.domain.asUnitResult
import de.davis.keygo.core.domain.model.crypto.asAesKey
-import de.davis.keygo.core.domain.onSuccess
-import de.davis.keygo.core.domain.zip
+import de.davis.keygo.core.identity.common.domain.CipherFactory
+import de.davis.keygo.core.identity.common.domain.model.CryptographicMode
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.asResult
+import de.davis.keygo.core.util.asUnitResult
+import de.davis.keygo.core.util.onSuccess
+import de.davis.keygo.core.util.zip
import org.koin.core.annotation.Single
@Single
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/presentation/AuthScreen.kt b/app/src/main/kotlin/de/davis/keygo/auth/presentation/AuthScreen.kt
index 05dd8e04..10e9b7df 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/presentation/AuthScreen.kt
+++ b/app/src/main/kotlin/de/davis/keygo/auth/presentation/AuthScreen.kt
@@ -3,7 +3,9 @@ package de.davis.keygo.auth.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import de.davis.keygo.auth.presentation.model.AuthUIEvent
+import de.davis.keygo.core.identity.biometric.presentation.BiometricPromptSupport
+import de.davis.keygo.core.identity.biometric.presentation.LocalBiometricManager
+import de.davis.keygo.core.identity.biometric.presentation.model.BiometricRequest
import de.davis.keygo.core.presentation.ObserveAsEvents
import org.koin.androidx.compose.koinViewModel
@@ -16,18 +18,18 @@ fun AuthScreen(onSuccess: () -> Unit) {
onSuccess()
}
- BiometricAuthHandler(
- flow = viewModel.biometricRequests,
- onAuthenticationSucceeded = {
- viewModel.onEvent(AuthUIEvent.BiometricSuccess(it))
- },
- onAuthenticationError = { _, _ ->
- viewModel.onEvent(AuthUIEvent.BiometricError)
- },
- onAuthenticationFailed = {
- viewModel.onEvent(AuthUIEvent.BiometricFailure)
+ BiometricPromptSupport {
+ val biometricManager = LocalBiometricManager.current
+
+ ObserveAsEvents(viewModel.biometricRequests) {
+ when (it) {
+ is BiometricRequest.Class3 -> {
+ val result = biometricManager.authenticate(it)
+ viewModel.onBiometricResult(result)
+ }
+ }
}
- )
- AuthContent(state = state, onEvent = viewModel::onEvent)
+ AuthContent(state = state, onEvent = viewModel::onEvent)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/presentation/AuthViewModel.kt b/app/src/main/kotlin/de/davis/keygo/auth/presentation/AuthViewModel.kt
index 952692c6..8999e0e0 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/presentation/AuthViewModel.kt
+++ b/app/src/main/kotlin/de/davis/keygo/auth/presentation/AuthViewModel.kt
@@ -1,28 +1,30 @@
package de.davis.keygo.auth.presentation
-import android.util.Log
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.snapshotFlow
-import androidx.lifecycle.ViewModel
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import de.davis.keygo.auth.domain.model.BiometricAvailability
-import de.davis.keygo.auth.domain.model.BiometricRequest
-import de.davis.keygo.auth.domain.model.CryptographicMode
+import androidx.navigation.toRoute
import de.davis.keygo.auth.domain.usecase.CreateAccessUseCase
-import de.davis.keygo.auth.domain.usecase.GetBiometricCryptoSetupAvailabilityUseCase
-import de.davis.keygo.auth.domain.usecase.GetBiometricHardwareAvailabilityUseCase
-import de.davis.keygo.auth.domain.usecase.HasValidAccessUseCase
-import de.davis.keygo.auth.domain.usecase.PrepareBiometricCipherUseCase
-import de.davis.keygo.auth.domain.usecase.UnlockWithBiometricsUseCase
import de.davis.keygo.auth.domain.usecase.UnlockWithPasswordUseCase
import de.davis.keygo.auth.presentation.model.AuthState
import de.davis.keygo.auth.presentation.model.AuthUIEvent
import de.davis.keygo.auth.presentation.model.UIPasswordError
-import de.davis.keygo.core.domain.Result
-import de.davis.keygo.core.domain.asResult
import de.davis.keygo.core.domain.estimator.PasswordStrengthEstimator
-import de.davis.keygo.core.domain.onFailure
-import de.davis.keygo.core.domain.onSuccess
+import de.davis.keygo.core.domain.usecase.HasValidAccessUseCase
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricAvailability
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricEvent
+import de.davis.keygo.core.identity.biometric.domain.usecase.GetBiometricCryptoSetupAvailabilityUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.GetBiometricHardwareAvailabilityUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.PrepareBiometricCipherUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.UnlockWithBiometricsUseCase
+import de.davis.keygo.core.identity.biometric.presentation.BiometricViewModel
+import de.davis.keygo.core.identity.common.domain.model.CryptographicMode
+import de.davis.keygo.core.presentation.model.RouteDestination
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.asResult
+import de.davis.keygo.core.util.onFailure
+import de.davis.keygo.core.util.onSuccess
import de.davis.keygo.migration.create_access.domain.usecase.ClearMainPasswordUseCase
import de.davis.keygo.migration.create_access.domain.usecase.HasMainPasswordUseCase
import de.davis.keygo.migration.create_access.domain.usecase.ValidateMainPassword
@@ -47,9 +49,12 @@ import kotlin.time.Duration.Companion.milliseconds
@KoinViewModel
class AuthViewModel(
+ savedStateHandle: SavedStateHandle,
getBiometricCryptoSetupAvailability: GetBiometricCryptoSetupAvailabilityUseCase,
getBiometricHardwareAvailability: GetBiometricHardwareAvailabilityUseCase,
hasValidAccess: HasValidAccessUseCase,
+ prepareBiometricCipher: PrepareBiometricCipherUseCase,
+ unlockWithBiometrics: UnlockWithBiometricsUseCase,
// ---- Migration ----
hasMainPassword: HasMainPasswordUseCase,
@@ -58,11 +63,17 @@ class AuthViewModel(
// -------------------
private val passwordStrengthEstimator: PasswordStrengthEstimator,
- private val prepareBiometricCipher: PrepareBiometricCipherUseCase,
- private val unlockWithBiometrics: UnlockWithBiometricsUseCase,
private val unlockWithPasswordUseCase: UnlockWithPasswordUseCase,
private val createAccess: CreateAccessUseCase
-) : ViewModel() {
+) : BiometricViewModel(
+ getBiometricCryptoSetupAvailability,
+ getBiometricHardwareAvailability,
+ hasValidAccess,
+ prepareBiometricCipher,
+ unlockWithBiometrics
+) {
+
+ private val authRoute = savedStateHandle.toRoute()
private val passwordTextFieldState = TextFieldState()
private val confirmPasswordTextFieldState = TextFieldState()
@@ -84,7 +95,7 @@ class AuthViewModel(
else false
val biometricsUsable = isBiometricHardwareAvailable && isBiometricCryptoSetupAvailable
- if (biometricsUsable) requestBiometricAuthentication()
+ if (biometricsUsable && authRoute.showBiometricPromptIfPossible) requestBiometricAuthentication()
_uiState.update {
@@ -169,9 +180,6 @@ class AuthViewModel(
.launchIn(viewModelScope)
}
- private val biometricRequestChannel = Channel()
- val biometricRequests = biometricRequestChannel.receiveAsFlow()
-
private val navigationEventChannel = Channel()
val navigationEvent = navigationEventChannel.receiveAsFlow()
@@ -282,19 +290,7 @@ class AuthViewModel(
return
}
- requestBiometricAuthentication(mode = CryptographicMode.Wrap)
- }
-
- private fun requestBiometricAuthentication(mode: CryptographicMode = CryptographicMode.Unwrap) {
- viewModelScope.launch {
- prepareBiometricCipher(mode = mode)
- .onSuccess {
- biometricRequestChannel.send(BiometricRequest.Class3(it))
- }
- .onFailure {
- Log.e(TAG, "Failed to prepare biometric cipher. Error: $it")
- }
- }
+ requestBiometricAuthentication(mode = CryptographicMode.Wrap, creatingAccess = true)
}
private fun loading(
@@ -316,8 +312,27 @@ class AuthViewModel(
}
}
- companion object {
- private const val TAG = "AuthViewModel"
+ override fun onBiometricSucceeded(event: BiometricEvent.OnAuthenticationSucceeded) {
+ val cipher = event.cipher ?: return
+ loading {
+ when (val state = uiState.value) {
+ is AuthState.Migrating,
+ is AuthState.CreateAccess -> {
+ createAccess(
+ password = state.passwordTextFieldState.text.toString(),
+ biometricCipher = cipher
+ ).handleAuthenticationResult()
+ }
+
+ is AuthState.Login -> {
+ unlockWithBiometrics(cipher).handleAuthenticationResult()
+ }
+ }
+ }
+ }
+
+ override fun onUnlocked() {
+ // Won't be called, as we override the onBiometricSucceeded function
}
}
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/presentation/BiometricAuthHandler.kt b/app/src/main/kotlin/de/davis/keygo/auth/presentation/BiometricAuthHandler.kt
deleted file mode 100644
index a6996951..00000000
--- a/app/src/main/kotlin/de/davis/keygo/auth/presentation/BiometricAuthHandler.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package de.davis.keygo.auth.presentation
-
-import androidx.activity.compose.LocalActivity
-import androidx.biometric.BiometricManager
-import androidx.biometric.BiometricPrompt
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.res.stringResource
-import androidx.fragment.app.FragmentActivity
-import de.davis.keygo.R
-import de.davis.keygo.auth.domain.model.BiometricRequest
-import de.davis.keygo.core.presentation.ObserveAsEvents
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.asExecutor
-import kotlinx.coroutines.flow.Flow
-
-@Composable
-fun BiometricAuthHandler(
- flow: Flow,
- onAuthenticationSucceeded: (BiometricPrompt.AuthenticationResult) -> Unit,
- onAuthenticationError: (Int, CharSequence) -> Unit,
- onAuthenticationFailed: () -> Unit
-) {
- val fragmentActivity = LocalActivity.current as? FragmentActivity
- requireNotNull(fragmentActivity) { "BiometricAuthHandler must be used within a FragmentActivity context." }
-
- val prompt = remember(fragmentActivity) {
- BiometricPrompt(
- fragmentActivity,
- Dispatchers.Default.asExecutor(),
- object : BiometricPrompt.AuthenticationCallback() {
- override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
- onAuthenticationSucceeded(result)
- }
-
- override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
- onAuthenticationError(errorCode, errString)
- }
-
- override fun onAuthenticationFailed() {
- onAuthenticationFailed()
- }
- }
- )
- }
-
- val title = stringResource(R.string.authenticate)
- val negativeButtonText = stringResource(R.string.cancel)
-
- ObserveAsEvents(flow = flow) { request ->
- when (request) {
- BiometricRequest.Class2 -> {
- prompt.authenticate(
- BiometricPrompt.PromptInfo.Builder()
- .setTitle(title)
- .setNegativeButtonText(negativeButtonText)
- .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK)
- .build()
- )
- }
-
- is BiometricRequest.Class3 -> {
- prompt.authenticate(
- BiometricPrompt.PromptInfo.Builder()
- .setTitle(title)
- .setNegativeButtonText(negativeButtonText)
- .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
- .build(),
- BiometricPrompt.CryptoObject(request.cipher)
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/presentation/model/AuthState.kt b/app/src/main/kotlin/de/davis/keygo/auth/presentation/model/AuthState.kt
index 4e7da0c9..6a81b45c 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/presentation/model/AuthState.kt
+++ b/app/src/main/kotlin/de/davis/keygo/auth/presentation/model/AuthState.kt
@@ -1,7 +1,7 @@
package de.davis.keygo.auth.presentation.model
import androidx.compose.foundation.text.input.TextFieldState
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.Password
sealed interface AuthState {
@@ -54,7 +54,7 @@ sealed interface AuthState {
override val useBiometrics: Boolean = true,
val confirmPasswordTextFieldState: TextFieldState = TextFieldState(),
val confirmPasswordError: UIPasswordError = UIPasswordError.None,
- val score: Score = Score.None,
+ val score: Password.Score = Password.Score.None,
) : AuthState, BiometricAuthState
fun copyDefaultState(
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt
new file mode 100644
index 00000000..d435f991
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/data/repository/DigitalAssetLinkRepositoryImpl.kt
@@ -0,0 +1,47 @@
+package de.davis.keygo.autofill.data.repository
+
+import de.davis.keygo.autofill.domain.repository.DigitalAssetLinkRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+import org.koin.core.annotation.Single
+import java.net.URLEncoder
+
+@Single
+internal class DigitalAssetLinkRepositoryImpl(
+ private val http: OkHttpClient
+) : DigitalAssetLinkRepository {
+
+ override suspend fun isLinked(
+ packageName: String,
+ signature: String,
+ domain: String
+ ): Boolean = withContext(Dispatchers.IO) {
+ fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
+ val response = http.newCall(
+ Request.Builder()
+ .url(API_ENDPOINT.format(enc(domain), enc(packageName), enc(signature)))
+ .build()
+ ).execute()
+
+ response.use {
+ if (!it.isSuccessful) return@use false
+
+ JSONObject(it.body.string()).optBoolean("linked", false)
+ }
+ }
+
+ companion object {
+
+ private const val RELATION = "delegate_permission/common.get_login_creds"
+
+ private const val API_ENDPOINT =
+ "https://digitalassetlinks.googleapis.com/v1/assetlinks:check" +
+ "?source.web.site=%s" +
+ "&relation=$RELATION" +
+ "&target.androidApp.packageName=%s" +
+ "&target.androidApp.certificate.sha256Fingerprint=%s"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/data/repository/SignatureInfoProviderImpl.kt b/app/src/main/kotlin/de/davis/keygo/autofill/data/repository/SignatureInfoProviderImpl.kt
new file mode 100644
index 00000000..2b320ffb
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/data/repository/SignatureInfoProviderImpl.kt
@@ -0,0 +1,56 @@
+package de.davis.keygo.autofill.data.repository
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Log
+import de.davis.keygo.autofill.domain.SignatureInfoProvider
+import org.koin.core.annotation.Single
+import java.security.MessageDigest
+
+@Single
+internal class SignatureInfoProviderImpl(
+ private val applicationContext: Context
+) : SignatureInfoProvider {
+
+ override fun getSignatureInfo(packageName: String): Set = runCatching {
+ val pm = applicationContext.packageManager
+
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
+ PackageManager.GET_SIGNING_CERTIFICATES
+ else
+ @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
+
+ val info = pm.getPackageInfo(packageName, flags)
+ val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
+ info.signingInfo?.apkContentsSigners?.toList().orEmpty()
+ else
+ @Suppress("DEPRECATION") info.signatures?.toList().orEmpty()
+
+ val md = MessageDigest.getInstance("SHA-256")
+ signatures.filterNotNull().map { sig ->
+ md.digest(sig.toByteArray()).toHexString(format = HEX_FORMAT)
+ }.toSet()
+ }.fold(
+ onSuccess = {
+ Log.d(TAG, "Signatures for $packageName: $it")
+ it
+ },
+ onFailure = {
+ Log.e(TAG, "Error getting signatures for $packageName", it)
+ emptySet()
+ }
+ )
+
+ companion object {
+ private const val TAG = "SignatureInfoProvider"
+
+ private val HEX_FORMAT = HexFormat {
+ upperCase = true
+ bytes {
+ bytesPerGroup = 1
+ groupSeparator = ":"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt b/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt
new file mode 100644
index 00000000..c49be739
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/di/AutofillModule.kt
@@ -0,0 +1,32 @@
+package de.davis.keygo.autofill.di
+
+import android.content.Context
+import de.davis.keygo.autofill.presentation.dataset.DatasetBuilderApi33Impl
+import de.davis.keygo.autofill.presentation.dataset.DatasetBuilderLegacyImpl
+import okhttp3.Cache
+import okhttp3.OkHttpClient
+import org.koin.core.annotation.ComponentScan
+import org.koin.core.annotation.Module
+import org.koin.core.annotation.Single
+
+@Module
+@ComponentScan("de.davis.keygo.autofill.**")
+object AutofillModule {
+
+ @Single
+ internal fun provideDatasetBuilder() =
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU)
+ DatasetBuilderApi33Impl()
+ else
+ DatasetBuilderLegacyImpl()
+
+ @Single
+ internal fun provideOkHttpClient(applicationContext: Context) = OkHttpClient.Builder()
+ .cache(
+ Cache(
+ directory = applicationContext.cacheDir.resolve("autofill_cache"),
+ maxSize = 3 * 1024 * 1024 // 3 MB
+ )
+ )
+ .build()
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/SignatureInfoProvider.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/SignatureInfoProvider.kt
new file mode 100644
index 00000000..dc47ff89
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/SignatureInfoProvider.kt
@@ -0,0 +1,6 @@
+package de.davis.keygo.autofill.domain
+
+interface SignatureInfoProvider {
+
+ fun getSignatureInfo(packageName: String): Set
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/repository/DigitalAssetLinkRepository.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/repository/DigitalAssetLinkRepository.kt
new file mode 100644
index 00000000..f5de2d19
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/repository/DigitalAssetLinkRepository.kt
@@ -0,0 +1,6 @@
+package de.davis.keygo.autofill.domain.repository
+
+interface DigitalAssetLinkRepository {
+
+ suspend fun isLinked(packageName: String, signature: String, domain: String): Boolean
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/AddRegistrableDomainsToPasswordUseCase.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/AddRegistrableDomainsToPasswordUseCase.kt
new file mode 100644
index 00000000..8db5807c
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/AddRegistrableDomainsToPasswordUseCase.kt
@@ -0,0 +1,23 @@
+package de.davis.keygo.autofill.domain.usecase
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.repository.PasswordRepository
+import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver
+import org.koin.core.annotation.Single
+
+@Single
+class AddRegistrableDomainsToPasswordUseCase(
+ private val passwordRepository: PasswordRepository,
+ private val registrableDomainResolver: RegistrableDomainResolver
+) {
+
+ suspend operator fun invoke(vaultItemId: ItemId, domain: String) {
+ val domainInfo = DomainInfo(
+ value = domain,
+ eTLD1 = registrableDomainResolver.resolve(domain)
+ )
+
+ passwordRepository.updatePasswordWithDomainInfo(vaultItemId, setOf(domainInfo))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCase.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCase.kt
new file mode 100644
index 00000000..ffd98a76
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCase.kt
@@ -0,0 +1,25 @@
+package de.davis.keygo.autofill.domain.usecase
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.repository.PasswordRepository
+import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver
+import org.koin.core.annotation.Single
+
+@Single
+class DoesItemHaveDomainReferencesUseCase(
+ private val passwordRepository: PasswordRepository,
+ private val registrableDomainResolver: RegistrableDomainResolver
+) {
+
+ suspend operator fun invoke(itemVaultId: ItemId, domain: String): Boolean {
+ if (domain.isBlank()) return false
+
+ val passwordDomains = passwordRepository.getPasswordById(itemVaultId)
+ ?.domainInfos
+ ?: return false
+
+ val eTLD1ToCheck = registrableDomainResolver.resolve(domain)
+
+ return passwordDomains.any { it.value == domain || it.eTLD1 == eTLD1ToCheck }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt
new file mode 100644
index 00000000..e3a23ed8
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/domain/usecase/IsAppLinkedToWebsiteUseCase.kt
@@ -0,0 +1,31 @@
+package de.davis.keygo.autofill.domain.usecase
+
+import de.davis.keygo.autofill.domain.SignatureInfoProvider
+import de.davis.keygo.autofill.domain.repository.DigitalAssetLinkRepository
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.coroutineScope
+import org.koin.core.annotation.Single
+
+@Single
+class IsAppLinkedToWebsiteUseCase(
+ private val digitalAssetLinkCheck: DigitalAssetLinkRepository,
+ private val signatureInfoProvider: SignatureInfoProvider,
+) {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ suspend operator fun invoke(
+ packageName: String,
+ domain: String
+ ): Boolean = coroutineScope {
+ val signatures = signatureInfoProvider.getSignatureInfo(packageName)
+ if (signatures.isEmpty()) return@coroutineScope false
+
+ signatures.any { sign ->
+ digitalAssetLinkCheck.isLinked(
+ packageName = packageName,
+ signature = sign,
+ domain = domain
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt
new file mode 100644
index 00000000..e6322c10
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/AutofillDatasetProvider.kt
@@ -0,0 +1,86 @@
+package de.davis.keygo.autofill.presentation
+
+import android.content.Context
+import android.os.Build
+import android.service.autofill.Dataset
+import android.service.autofill.Field
+import android.service.autofill.FillRequest
+import android.service.autofill.Presentations
+import android.widget.RemoteViews
+import androidx.annotation.ChecksSdkIntAtLeast
+import de.davis.keygo.autofill.presentation.dataset.inline.InlineDatasetBuilder
+import de.davis.keygo.autofill.presentation.dataset.menu.MenuDatasetBuilder
+import de.davis.keygo.autofill.presentation.model.AutofillValue
+import de.davis.keygo.autofill.presentation.model.Form
+import org.koin.core.annotation.Single
+import android.view.autofill.AutofillValue as AndroidAutofillValue
+
+@Single
+internal class AutofillDatasetProvider(
+ applicationContext: Context,
+ private val inlineDatasetBuilder: InlineDatasetBuilder,
+ private val menuDatasetBuilder: MenuDatasetBuilder,
+) {
+
+ suspend fun getAutofillDatasets(
+ request: FillRequest,
+ form: Form
+ ): List {
+ if (systemSupportsInlineSuggestions(request))
+ return inlineDatasetBuilder.buildInlineDatasets(
+ specs = request.inlineSuggestionsRequest!!.inlinePresentationSpecs,
+ form = form
+ )
+
+ return menuDatasetBuilder.buildMenuDatasets(form = form)
+ }
+
+ fun getFillingDataset(values: List) = getDefaultDataset()
+ .applyValues(values)
+ .build()
+
+ private fun getDefaultDataset() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+ Dataset.Builder(
+ Presentations.Builder()
+ .setMenuPresentation(dummyRemoteViews)
+ .build()
+ )
+ else Dataset.Builder()
+
+ private fun Dataset.Builder.applyValues(values: List) = apply {
+ values.forEach {
+ val value = AndroidAutofillValue.forText(it.value)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ setField(
+ it.autofillId,
+ Field.Builder()
+ .setValue(value)
+ .build()
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ setValue(
+ it.autofillId,
+ value,
+ dummyRemoteViews
+ )
+ }
+ }
+ }
+
+ @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R)
+ private fun systemSupportsInlineSuggestions(request: FillRequest): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
+ request.inlineSuggestionsRequest?.let {
+ val maxSuggestion = it.maxSuggestionCount
+ val specCount = it.inlinePresentationSpecs.count()
+ maxSuggestion > 0 && specCount > 0
+ } ?: false
+ else false
+
+ private val dummyRemoteViews: RemoteViews =
+ RemoteViews(
+ applicationContext.packageName,
+ android.R.layout.simple_list_item_1
+ )
+}
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/ChromiumAutofillFieldTypes.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/ChromiumAutofillFieldTypes.kt
new file mode 100644
index 00000000..0161fa10
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/ChromiumAutofillFieldTypes.kt
@@ -0,0 +1,11 @@
+package de.davis.keygo.autofill.presentation
+
+import de.davis.keygo.autofill.presentation.model.FieldType
+
+/**
+ * Based of the official chromium implementations: components/autofill/core/browser/field_types.h
+ * This map is NOT complete!
+ */
+val CHROMIUM_AUTOFILL_HINTS = mapOf(
+ "HTML_TYPE_ONE_TIME_CODE" to FieldType.TOTP
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Classifier.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Classifier.kt
new file mode 100644
index 00000000..500a6ff9
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/Classifier.kt
@@ -0,0 +1,114 @@
+package de.davis.keygo.autofill.presentation
+
+import android.view.View
+import de.davis.keygo.autofill.presentation.model.FieldFeatures
+import de.davis.keygo.autofill.presentation.model.FieldType
+
+internal object Classifier {
+
+ fun classify(features: FieldFeatures): FieldType {
+ var type = classifyTokens(features.autofillHints)
+ if (type !is FieldType.Undefined) return type
+
+ type = classifyHtmlAttributes(features.htmlAttributes)
+ if (type !is FieldType.Undefined) return type
+
+ //TODO: handle input types
+
+ type = classifyTokens(features.tokens)
+ if (type !is FieldType.Undefined) return type
+
+ return type
+ }
+
+ private fun classifyHtmlAttributes(htmlAttributes: Map): FieldType {
+ val type = htmlAttributes["type"]
+ if (type == null) return FieldType.Undefined
+
+ when (type) {
+ "password" -> return FieldType.Credentials.Password
+ "email" -> return FieldType.Credentials.EMail
+ }
+
+ // Chromium only
+ htmlAttributes["computed-autofill-hints"]?.let { computedAutofillHints ->
+ CHROMIUM_AUTOFILL_HINTS[computedAutofillHints]?.let {
+ return it
+ }
+ }
+
+ return htmlAttributes["name"]?.let {
+ classifyToken(it)
+ } ?: FieldType.Undefined
+ }
+
+ private fun classifyTokens(tokens: Set): FieldType {
+ tokens.forEach {
+ when (val type = classifyToken(it)) {
+ FieldType.Undefined -> {}
+ else -> return type
+ }
+ }
+
+ return FieldType.Undefined
+ }
+
+ private fun classifyToken(token: String): FieldType {
+ val type = when (token) {
+ View.AUTOFILL_HINT_USERNAME -> FieldType.Credentials.Username
+ View.AUTOFILL_HINT_PHONE -> FieldType.Credentials.Phone
+ View.AUTOFILL_HINT_EMAIL_ADDRESS -> FieldType.Credentials.EMail
+
+ View.AUTOFILL_HINT_PASSWORD -> FieldType.Credentials.Password
+
+ // Token for the autocomplete parameter for html form inputs, see: http://is.gd/whatwg_autocomplete
+ // or https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute
+ "one-time-code" -> FieldType.TOTP
+
+ else -> FieldType.Undefined
+ }
+
+ if (type !is FieldType.Undefined)
+ return type
+
+ if (USERNAME_REGEX.containsMatchIn(token)) return FieldType.Credentials.Username
+ if (EMAIL_REGEX.containsMatchIn(token)) return FieldType.Credentials.EMail
+ if (TOTP_REGEX.containsMatchIn(token)) return FieldType.TOTP
+
+ return FieldType.Undefined
+ }
+
+ private val TOTP_REGEX = Regex(
+ pattern = """
+ (?xi) # ignore case; allow comments/whitespace
+ ^(?!.*\b(?:sms|text(?:\s?message)?|email|e-?mail|mail|phone|call|voice)\b) # not SMS/email/phone
+ (?!.*\b(?:backup|recovery)\s+codes?\b) # not backup/recovery codes
+ .*?
+ (?: # signals of a TOTP-style field
+ (?()
+
+ parser.use {
+ var type = it.eventType
+ buildSet {
+ while (type != XmlPullParser.END_DOCUMENT) {
+ if (type != XmlPullParser.START_TAG || it.name != "compatibility-package") {
+ type = it.next()
+ continue
+ }
+
+ val packageName = it.getAttributeValue(
+ "http://schemas.android.com/apk/res/android",
+ "name"
+ )
+ add(packageName)
+ type = it.next()
+ }
+ }
+ }
+ }
+
+ /**
+ * Extracts relevant form fields from the provided [AssistStructure.ViewNode].
+ * It traverses the view hierarchy, identifying input fields that are important for autofill.
+ *
+ * @param windowNode The window node of the assist structure to extract from.
+ * @param manualRequest Indicates if the extraction is triggered by a manual user request.
+ * @return A [Form] containing the extracted fields and associated URLs, or null if no focused
+ * field is found or the focused field could not be classified.
+ */
+ fun extractRelevant(
+ windowNode: AssistStructure.WindowNode,
+ manualRequest: Boolean
+ ): Form? {
+ val result = mutableListOf()
+ traverse(windowNode.rootViewNode, manualRequest, result)
+
+ // We filter out the fields that are not in the same group as the focused field
+ // This forces us to only fill one type at a time (e.g. credentials or credit cards).
+ Log.d(TAG, "Extracted fields: $result")
+ val (focusedFieldType, focusedDomain) = result.firstOrNull { it.focused }
+ ?.let { it.type to it.url }
+ ?: result.firstOrNull()?.let { it.type to it.url }
+ ?: return null
+ if (focusedFieldType is FieldType.Undefined) return null
+
+ val windowPackageName = windowNode.packageName
+ val isBrowser = windowPackageName in browsers
+ val isSuspicious = !isBrowser && !focusedDomain.isNullOrBlank()
+
+ if (isSuspicious)
+ Log.w(
+ TAG,
+ "Window package name $windowPackageName is not a browser but has web URL: $focusedDomain"
+ )
+
+ return Form(
+ url = focusedDomain,
+ fields = result.filter { it.type.group == focusedFieldType.group && it.url == focusedDomain },
+ type = focusedFieldType.toFormType(),
+ isBrowser = isBrowser,
+ appPackageName = windowPackageName,
+ isSuspicious = isSuspicious
+ )
+ }
+
+ private fun traverse(
+ node: AssistStructure.ViewNode,
+ manualRequest: Boolean,
+ outFields: MutableList,
+ currentUrl: String? = null
+ ) {
+ val currentUrl = node.getUrl() ?: currentUrl
+
+ if (isSignalLeaf(node)) {
+ if (node.autofillId == null)
+ return
+
+ val isImportant = node.isImportantForAutofill() || manualRequest
+ if (!isImportant) {
+ Log.d(
+ TAG,
+ "Skipping node [not important]: ${node.className} - HTML-Tag: ${node.htmlInfo?.tag}"
+ )
+ return
+ }
+
+ if (!node.looksLikeInput()) {
+ Log.d(
+ TAG,
+ "Skipping node [not input]: ${node.className} - HTML-Tag: ${node.htmlInfo?.tag}"
+ )
+ return
+ }
+
+ val features = node.toFieldFeatures()
+ val type = Classifier.classify(features)
+ if (type is FieldType.Undefined) {
+ Log.d(
+ TAG,
+ "Skipping node [undefined type]: ${node.className} with $features"
+ )
+ return
+ }
+
+ outFields += FormField(
+ autofillId = node.autofillId!!,
+ type = type,
+ focused = node.isFocused,
+ url = currentUrl,
+ )
+ return
+ }
+
+ (0 until node.childCount).forEach {
+ traverse(node.getChildAt(it), manualRequest, outFields, currentUrl)
+ }
+ }
+
+ private fun isSignalLeaf(node: AssistStructure.ViewNode): Boolean {
+ val noChildren = node.childCount == 0
+ val onlyCosmeticChild = node.childCount == 1
+ && node.getChildAt(0).className == TextView::class.qualifiedName
+ && node.getChildAt(0).childCount == 0
+ && node.getChildAt(0).text.isNullOrBlank()
+ && node.getChildAt(0).autofillHints?.all { it.isNullOrBlank() } == true
+
+ return noChildren || onlyCosmeticChild
+ }
+
+ private fun AssistStructure.ViewNode.isImportantForAutofill(): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
+ when (importantForAutofill) {
+ View.IMPORTANT_FOR_AUTOFILL_AUTO,
+ View.IMPORTANT_FOR_AUTOFILL_YES,
+ View.IMPORTANT_FOR_AUTOFILL_YES_EXCLUDE_DESCENDANTS -> true
+
+ else -> false
+ }
+ else true
+
+ private fun AssistStructure.ViewNode.looksLikeInput(): Boolean {
+ // HTML signals (native browsers / WebView with HtmlInfo)
+ val htmlTag = htmlInfo?.tag?.lowercase()
+ val isHtmlInput = htmlTag == "input" || htmlTag == "textarea"
+
+ // Android widget signals
+ val isEditableView = isEditableView()
+ return isHtmlInput || isEditableView
+ }
+
+ private fun AssistStructure.ViewNode.isEditableView(): Boolean = isEnabled && when (className) {
+ EditText::class.qualifiedName,
+ MultiAutoCompleteTextView::class.qualifiedName,
+ AutoCompleteTextView::class.qualifiedName -> true
+
+ else -> false
+ }
+
+ private fun AssistStructure.ViewNode.getUrl() = webDomain
+ ?.takeIf { it.isNotBlank() }
+ ?.let {
+ val scheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) webScheme ?: "https"
+ else "https"
+ "$scheme://$it"
+ }
+
+ companion object {
+ private const val TAG = "Extractor"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt
new file mode 100644
index 00000000..0b60ced7
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/KeyGoAutofillService.kt
@@ -0,0 +1,179 @@
+package de.davis.keygo.autofill.presentation
+
+import android.app.assist.AssistStructure
+import android.os.Build
+import android.os.CancellationSignal
+import android.service.autofill.AutofillService
+import android.service.autofill.FillCallback
+import android.service.autofill.FillRequest
+import android.service.autofill.FillResponse
+import android.service.autofill.SaveCallback
+import android.service.autofill.SaveRequest
+import android.util.Log
+import android.view.autofill.AutofillId
+import androidx.core.os.bundleOf
+import de.davis.keygo.autofill.presentation.dataset.applySaveInfo
+import de.davis.keygo.autofill.presentation.dataset.getForm
+import de.davis.keygo.autofill.presentation.model.Form
+import de.davis.keygo.autofill.presentation.model.FormField
+import de.davis.keygo.autofill.presentation.model.SaveRequestData
+import de.davis.keygo.core.domain.usecase.HasValidAccessUseCase
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
+
+class KeyGoAutofillService : AutofillService() {
+
+ private val extractor by inject()
+ private val datasetProvider by inject()
+ private val hasValidAccess by inject()
+
+ override fun onFillRequest(
+ request: FillRequest,
+ cancellationSignal: CancellationSignal,
+ callback: FillCallback
+ ) {
+ val handler = CoroutineExceptionHandler { _, exception ->
+ Log.w(TAG, "Error during autofill extraction", exception)
+ callback.onSuccess(null)
+ }
+
+ val job = CoroutineScope(Dispatchers.IO + handler).launch {
+ if (!hasValidAccess()) {
+ Log.w(TAG, "No valid access - not filling")
+ callback.onSuccess(null)
+ return@launch
+ }
+
+ val structure = request.fillContexts.lastOrNull()?.structure
+ if (structure == null) {
+ callback.onSuccess(null)
+ return@launch
+ }
+
+ val windowNode = (0 until structure.windowNodeCount).mapNotNull {
+ structure.getWindowNodeAt(it)
+ }.lastOrNull()
+ if (windowNode == null) {
+ callback.onSuccess(null)
+ return@launch
+ }
+
+ if (windowNode.packageName == packageName) {
+ callback.onSuccess(null)
+ return@launch
+ }
+
+ val isManual = request.flags and FillRequest.FLAG_MANUAL_REQUEST != 0
+ val form = extractor.extractRelevant(windowNode, manualRequest = isManual)
+ ?: run {
+ Log.w(TAG, "Could not extract form")
+ callback.onSuccess(null)
+ return@launch
+ }
+
+ if (!form.hasFields()) {
+ callback.onSuccess(null)
+ return@launch
+ }
+
+ val inCompatibilityMode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ (request.flags and FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST) != 0
+ else true
+ Log.d(TAG, "In Compatibility Mode: $inCompatibilityMode")
+ Log.d(TAG, "Extracted form: $form")
+
+ val dataset = datasetProvider.getAutofillDatasets(request, form)
+ val response = FillResponse.Builder().apply {
+ dataset.forEach(::addDataset)
+ applySaveInfo(
+ form = form,
+ clientInfo = request.clientState ?: bundleOf(),
+ requestId = request.id,
+ inCompatibilityMode = inCompatibilityMode
+ )
+ }.build()
+ callback.onSuccess(response)
+ }
+
+ cancellationSignal.setOnCancelListener {
+ job.cancel()
+ }
+ }
+
+ override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
+ Log.d(TAG, "Save request received: $request")
+ val clientState = request.clientState
+ if (clientState == null) {
+ callback.onFailure("No client state found")
+ return
+ }
+
+ val form = clientState.getForm()
+ ?.satisfyFields(request) // Fill the form fields with values from the request
+ ?: run {
+ callback.onFailure("No form in client state")
+ return
+ }
+
+ val saveRequestData = SaveRequestData(form)
+ Log.d(
+ TAG,
+ "To be saved - $saveRequestData"
+ )
+
+ val savePendingIntent = getSavePendingIntent(saveRequestData)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ callback.onSuccess(savePendingIntent.intentSender)
+ return
+ }
+
+ // For older android versions, we just start a new activity
+ runCatching {
+ savePendingIntent.send()
+ }.onFailure {
+ Log.w(TAG, "Error starting save activity", it)
+ callback.onFailure("Error starting save activity")
+ return
+ }.onSuccess {
+ callback.onSuccess()
+ }
+ }
+
+ private fun Form.satisfyFields(request: SaveRequest): Form {
+ val satisfiedFields = fields.mapNotNull { field ->
+ val node = request.find(field) ?: return@mapNotNull null
+ if (node.autofillValue == null) return@mapNotNull null
+ val value = node.autofillValue?.textValue?.toString()
+ field.copy(autofillValue = value)
+ }
+ return copy(fields = satisfiedFields)
+ }
+
+ private fun SaveRequest.find(formField: FormField): AssistStructure.ViewNode? {
+ val structure = fillContexts.find { it.requestId == formField.requestId }
+ ?.structure
+ ?: return null
+ return (0 until structure.windowNodeCount).firstNotNullOfOrNull {
+ structure.getWindowNodeAt(it)
+ ?.rootViewNode
+ ?.findChildById(formField.autofillId)
+ }
+ }
+
+ private fun AssistStructure.ViewNode.findChildById(id: AutofillId): AssistStructure.ViewNode? {
+ if (autofillId == id) return this
+ for (i in 0 until childCount) {
+ val child = getChildAt(i).findChildById(id)
+ if (child != null) return child
+ }
+ return null
+ }
+
+ companion object {
+ private const val TAG = "KeyGoAutofillService"
+ }
+}
+
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt
new file mode 100644
index 00000000..60b920a6
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/PendingIntentExt.kt
@@ -0,0 +1,35 @@
+package de.davis.keygo.autofill.presentation
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import de.davis.keygo.autofill.presentation.activity.AutofillActivity
+import de.davis.keygo.autofill.presentation.model.FillRequestData
+import de.davis.keygo.autofill.presentation.model.SaveRequestData
+
+private const val AUTOFILL_PENDING_INTENT_FLAGS =
+ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
+
+internal fun Context.getSelectionPendingIntent(
+ fillRequestData: FillRequestData
+) = PendingIntent.getActivity(
+ this,
+ fillRequestData.requestId,
+ AutofillActivity.newIntent(this, fillRequestData),
+ AUTOFILL_PENDING_INTENT_FLAGS
+)
+
+internal fun Context.getOnLongClickPendingIntent() = PendingIntent.getService(
+ this,
+ 0,
+ Intent(),
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
+)
+
+internal fun Context.getSavePendingIntent(saveRequestData: SaveRequestData) =
+ PendingIntent.getActivity(
+ this,
+ saveRequestData.requestId,
+ AutofillActivity.newIntent(this, saveRequestData),
+ AUTOFILL_PENDING_INTENT_FLAGS
+ )
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/SubtitleProvider.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/SubtitleProvider.kt
new file mode 100644
index 00000000..9dbe9622
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/SubtitleProvider.kt
@@ -0,0 +1,14 @@
+package de.davis.keygo.autofill.presentation
+
+import android.content.Context
+import de.davis.keygo.R
+import de.davis.keygo.autofill.presentation.model.FormType
+import de.davis.keygo.core.item.domain.model.lite.LitePassword
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItem
+
+internal fun LiteVaultItem.subtitle(context: Context, formType: FormType) = when (formType) {
+ is FormType.TOTP -> context.getString(R.string.totp)
+ else -> when (this) {
+ is LitePassword -> username ?: "----"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/WindowNodeExt.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/WindowNodeExt.kt
new file mode 100644
index 00000000..603e9a18
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/WindowNodeExt.kt
@@ -0,0 +1,5 @@
+package de.davis.keygo.autofill.presentation
+
+import android.app.assist.AssistStructure
+
+internal val AssistStructure.WindowNode.packageName get() = title.split("/").first()
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillActivity.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillActivity.kt
new file mode 100644
index 00000000..72fec56b
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillActivity.kt
@@ -0,0 +1,152 @@
+package de.davis.keygo.autofill.presentation.activity
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.service.autofill.Dataset
+import android.view.autofill.AutofillManager
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.compose.rememberNavController
+import de.davis.keygo.autofill.presentation.activity.component.AssociationDialog
+import de.davis.keygo.autofill.presentation.activity.component.SuspicionDialog
+import de.davis.keygo.autofill.presentation.model.AssociationDialogVisibility
+import de.davis.keygo.autofill.presentation.model.AutofillEvent
+import de.davis.keygo.autofill.presentation.model.AutofillUiEvent
+import de.davis.keygo.autofill.presentation.model.Request
+import de.davis.keygo.autofill.presentation.model.RequestData
+import de.davis.keygo.autofill.presentation.model.SuspicionDialogVisibility
+import de.davis.keygo.core.identity.biometric.presentation.BiometricPromptSupport
+import de.davis.keygo.core.identity.biometric.presentation.LocalBiometricManager
+import de.davis.keygo.core.identity.biometric.presentation.model.BiometricRequest
+import de.davis.keygo.core.presentation.ObserveAsEvents
+import de.davis.keygo.core.presentation.model.RouteDestination
+import de.davis.keygo.core.presentation.theme.KeyGoTheme
+import org.koin.androidx.compose.koinViewModel
+
+
+/**
+ * This activity is transparent and does not show up in the recent apps list. It is used to gather
+ * the right data that should be used for autofill. If needed this activity can show a dialog or
+ * request biometrics to authenticate the user.
+ *
+ * The transparency allows us to have a more integrated user experience, as the user doesn't see a
+ * dedicated UI just to authenticate themself or to show a dialog.
+ */
+internal class AutofillActivity : FragmentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Set default result, that is being sent if the user leaves the activity without making
+ // a selection.
+ setResult(RESULT_CANCELED)
+ setContent {
+ KeyGoTheme {
+ BiometricPromptSupport {
+ val viewModel = koinViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val dialogVisibility = uiState.associationDialogVisibility
+ val suspicionDialogVisibility = uiState.suspicionDialogVisibility
+
+ val biometricManager = LocalBiometricManager.current
+
+ ObserveAsEvents(viewModel.events) {
+ when (it) {
+ AutofillEvent.Abort -> cancel()
+ is AutofillEvent.Fill -> finishWithResult(it.dataset)
+ }
+ }
+
+ // Request biometric prompt, while the background is transparent (shows the app
+ // behind it)
+ ObserveAsEvents(viewModel.biometricRequests) {
+ when (it) {
+ is BiometricRequest.Class3 -> {
+ val result = biometricManager.authenticate(it)
+ viewModel.onBiometricResult(result)
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.start()
+ }
+
+ if (uiState.request !is Request.None) {
+ val navController = rememberNavController()
+ AutofillUi(
+ navController = navController,
+ onItemSelected = { viewModel.onEvent(AutofillUiEvent.OnItemSelected(it)) },
+ onSaved = ::finishWithResult,
+ onAuthenticationSucceeded = {
+ when (uiState.request) {
+ is Request.JustAuthenticateWithPwd -> viewModel.onEvent(
+ AutofillUiEvent.OnAuthenticated
+ )
+
+ else -> {
+ navController.navigate(uiState.request.destination) {
+ popUpTo { inclusive = true }
+ }
+ }
+ }
+ },
+ showBiometricPromptIfPossible = uiState.request !is Request.JustAuthenticateWithPwd
+ )
+ }
+
+ if (dialogVisibility is AssociationDialogVisibility.Visible)
+ AssociationDialog(
+ itemName = dialogVisibility.itemName,
+ domain = dialogVisibility.domain,
+ onDismissRequest = {},
+ onConfirm = { viewModel.onEvent(AutofillUiEvent.OnAssociate) },
+ onDismiss = { viewModel.onEvent(AutofillUiEvent.OnCancelAssociation) }
+ )
+
+ if (suspicionDialogVisibility is SuspicionDialogVisibility.Visible)
+ SuspicionDialog(
+ onContinue = { viewModel.onEvent(AutofillUiEvent.OnContinueInSuspicion) },
+ onAbort = { viewModel.onEvent(AutofillUiEvent.OnAbortInSuspicion) },
+ appPackageName = suspicionDialogVisibility.appPackageName,
+ website = suspicionDialogVisibility.website
+ )
+ }
+ }
+ }
+ }
+
+ private fun cancel() {
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+
+ private fun finishWithResult(dataset: Dataset? = null) {
+ setResult(
+ RESULT_OK,
+ dataset?.let {
+ Intent().apply {
+ putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, it)
+ }
+ }
+ )
+ finish()
+ }
+
+ companion object {
+
+ fun newIntent(context: Context, requestData: RequestData): Intent = Intent(
+ context,
+ AutofillActivity::class.java
+ ).apply {
+ putExtra(
+ AutofillViewModel.KEY_AUTOFILL_INFORMATION,
+ requestData
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillUi.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillUi.kt
new file mode 100644
index 00000000..6f4cbb78
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillUi.kt
@@ -0,0 +1,103 @@
+package de.davis.keygo.autofill.presentation.activity
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import de.davis.keygo.auth.presentation.authGraph
+import de.davis.keygo.autofill.presentation.model.SaveItemDestination
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.presentation.model.RouteDestination
+import de.davis.keygo.dashboard.presentation.dashboardGraph
+import de.davis.keygo.item.core.presentation.model.DetailPaneInformation
+import de.davis.keygo.item.core.presentation.model.DetailType
+import de.davis.keygo.item.create.presentation.EditVaultItemScreen
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.json.Json
+import kotlin.reflect.typeOf
+
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+fun AutofillUi(
+ navController: NavHostController,
+ onItemSelected: (ItemId) -> Unit,
+ onSaved: () -> Unit,
+ onAuthenticationSucceeded: () -> Unit,
+ showBiometricPromptIfPossible: Boolean
+) {
+ Scaffold { innerPadding ->
+ val listPaneNavigator = rememberListDetailPaneScaffoldNavigator()
+
+ NavHost(
+ navController = navController,
+ startDestination = RouteDestination.Auth(showBiometricPromptIfPossible = showBiometricPromptIfPossible),
+ modifier = Modifier.Companion
+ .padding(innerPadding)
+ .consumeWindowInsets(innerPadding)
+ ) {
+ authGraph(
+ onSuccess = {
+ onAuthenticationSucceeded()
+ }
+ )
+
+ dashboardGraph(
+ listNavigator = listPaneNavigator,
+ onItemClicked = onItemSelected,
+ autoSelect = false
+ )
+
+ composable(
+ typeMap = mapOf(
+ typeOf() to serializerNavType(
+ DetailPaneInformation.CreateRaw.serializer()
+ )
+ )
+ ) { s ->
+ val destination = s.toRoute()
+ EditVaultItemScreen(
+ detailPaneInformation = destination.createRaw,
+ navigate = {
+ onSaved()
+ }
+ )
+ }
+ }
+ }
+}
+
+private val json = Json {
+ ignoreUnknownKeys = true
+ // important for sealed hierarchies:
+ classDiscriminator = "type"
+}
+
+private inline fun serializerNavType(
+ serializer: KSerializer
+): NavType = object : NavType(isNullableAllowed = false) {
+ override fun put(bundle: Bundle, key: String, value: T) {
+ bundle.putString(key, json.encodeToString(serializer, value))
+ }
+
+ override fun get(bundle: Bundle, key: String): T {
+ val s = requireNotNull(bundle.getString(key))
+ return json.decodeFromString(serializer, s)
+ }
+
+ override fun parseValue(value: String): T =
+ json.decodeFromString(serializer, Uri.decode(value))
+
+ override fun serializeAsValue(value: T): String =
+ Uri.encode(json.encodeToString(serializer, value))
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillViewModel.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillViewModel.kt
new file mode 100644
index 00000000..26302d25
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/AutofillViewModel.kt
@@ -0,0 +1,365 @@
+package de.davis.keygo.autofill.presentation.activity
+
+import android.util.Patterns
+import androidx.biometric.BiometricPrompt
+import androidx.core.util.PatternsCompat
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.viewModelScope
+import de.davis.keygo.R
+import de.davis.keygo.autofill.domain.usecase.AddRegistrableDomainsToPasswordUseCase
+import de.davis.keygo.autofill.domain.usecase.DoesItemHaveDomainReferencesUseCase
+import de.davis.keygo.autofill.domain.usecase.IsAppLinkedToWebsiteUseCase
+import de.davis.keygo.autofill.presentation.AutofillDatasetProvider
+import de.davis.keygo.autofill.presentation.model.AssociationDialogVisibility
+import de.davis.keygo.autofill.presentation.model.AutofillEvent
+import de.davis.keygo.autofill.presentation.model.AutofillUiEvent
+import de.davis.keygo.autofill.presentation.model.AutofillUiState
+import de.davis.keygo.autofill.presentation.model.AutofillValue
+import de.davis.keygo.autofill.presentation.model.FieldType
+import de.davis.keygo.autofill.presentation.model.FillRequestData
+import de.davis.keygo.autofill.presentation.model.Form
+import de.davis.keygo.autofill.presentation.model.FormType
+import de.davis.keygo.autofill.presentation.model.Request
+import de.davis.keygo.autofill.presentation.model.RequestData
+import de.davis.keygo.autofill.presentation.model.SaveRequestData
+import de.davis.keygo.autofill.presentation.model.SuspicionDialogVisibility
+import de.davis.keygo.core.domain.crypto.CryptographicScopeProvider
+import de.davis.keygo.core.domain.crypto.decryptSecretData
+import de.davis.keygo.core.domain.usecase.HasValidAccessUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.GetBiometricCryptoSetupAvailabilityUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.GetBiometricHardwareAvailabilityUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.PrepareBiometricCipherUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.UnlockWithBiometricsUseCase
+import de.davis.keygo.core.identity.biometric.presentation.BiometricViewModel
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.Password
+import de.davis.keygo.core.item.domain.repository.PasswordRepository
+import de.davis.keygo.core.item.domain.repository.VaultItemRepository
+import de.davis.keygo.core.presentation.UIText
+import de.davis.keygo.item.core.presentation.model.DetailPaneInformation
+import de.davis.keygo.totp.domain.repository.TotpGenerator
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.koin.android.annotation.KoinViewModel
+
+
+@KoinViewModel
+internal class AutofillViewModel(
+ savedStateHandle: SavedStateHandle,
+ private val passwordRepository: PasswordRepository,
+ private val vaultItemRepository: VaultItemRepository,
+ private val cryptographicScopeProvider: CryptographicScopeProvider,
+ private val autofillDatasetProvider: AutofillDatasetProvider,
+ private val doesItemHaveDomainReferences: DoesItemHaveDomainReferencesUseCase,
+ private val addRegistrableDomainToPassword: AddRegistrableDomainsToPasswordUseCase,
+ private val isAppLinkedToWebsite: IsAppLinkedToWebsiteUseCase,
+ private val totpGenerator: TotpGenerator,
+
+ getBiometricCryptoSetupAvailability: GetBiometricCryptoSetupAvailabilityUseCase,
+ getBiometricHardwareAvailability: GetBiometricHardwareAvailabilityUseCase,
+ hasValidAccess: HasValidAccessUseCase,
+ prepareBiometricCipher: PrepareBiometricCipherUseCase,
+ unlockWithBiometrics: UnlockWithBiometricsUseCase,
+) : BiometricViewModel(
+ getBiometricCryptoSetupAvailability,
+ getBiometricHardwareAvailability,
+ hasValidAccess,
+ prepareBiometricCipher,
+ unlockWithBiometrics
+) {
+
+ private val requestData = savedStateHandle.get(KEY_AUTOFILL_INFORMATION)
+ ?: throw IllegalArgumentException("Extraction must not be null")
+
+ private val eventChannel = Channel()
+ val events = eventChannel.receiveAsFlow()
+
+ private val _uiState = MutableStateFlow(AutofillUiState())
+ val uiState = _uiState.asStateFlow()
+
+ fun start() {
+ handleRequestDate()
+ }
+
+ private fun Form.toRawItem() = when (type) {
+ // TODO: maybe find suitable names
+ is FormType.Credentials -> DetailPaneInformation.CreateRaw.Password(
+ name = "",
+ password = fields.find { it.type == FieldType.Credentials.Password }?.autofillValue
+ ?: "",
+ username = fields.find { it.type == FieldType.Credentials.Username }?.autofillValue
+ ?: fields.find { it.type == FieldType.Credentials.EMail }?.autofillValue
+ ?: fields.find { it.type == FieldType.Credentials.Phone }?.autofillValue
+ ?: "",
+ url = url,
+ )
+
+ is FormType.TOTP -> throw IllegalArgumentException("TOTP not supported for saving")
+ }
+
+ private fun handleSaveRequest(requestData: SaveRequestData) {
+ _uiState.update { it.copy(request = Request.SaveItem(requestData.form.toRawItem())) }
+ }
+
+ private fun handleRequestDate(ignoreSuspicion: Boolean = false) =
+ viewModelScope.launch {
+ val handleSuspicion = !ignoreSuspicion && requestData.form.isSuspicious
+ val linked = when {
+ handleSuspicion -> requestData.form.url?.let {
+ isAppLinkedToWebsite(
+ packageName = requestData.form.appPackageName,
+ domain = it
+ )
+ } == true
+
+ else -> false
+ }
+
+ val showSuspicionDialog = !linked && handleSuspicion
+
+ _uiState.update {
+ it.copy(
+ suspicionDialogVisibility = if (showSuspicionDialog)
+ SuspicionDialogVisibility.Visible(
+ appPackageName = requestData.form.appPackageName,
+ website = requestData.form.url.orEmpty()
+ )
+ else SuspicionDialogVisibility.Hidden
+ )
+ }
+
+ if (showSuspicionDialog) return@launch
+
+ when (requestData) {
+ is SaveRequestData -> handleSaveRequest(requestData)
+
+ is FillRequestData.Pinned,
+ is FillRequestData.App -> _uiState.update { it.copy(request = Request.SelectItem) }
+
+ is FillRequestData.Suggestion -> handleSuggestionRequest(requestData)
+ }
+ }
+
+ private suspend fun handleSuggestionRequest(suggestionInfo: FillRequestData.Suggestion) {
+ _uiState.update { it.copy(vaultId = suggestionInfo.vaultId) }
+
+ val itemName = vaultItemRepository.getItemName(suggestionInfo.vaultId)
+ ?: throw IllegalArgumentException("Name for vaultId=${suggestionInfo.vaultId} not found")
+
+ requestBiometricAuthentication(
+ title = UIText.ResourceString(R.string.unlock_item, itemName),
+ negativeButton = UIText.ResourceString(R.string.password)
+ )
+ }
+
+ override fun onBiometricFailed(errorCode: Int, errString: String) {
+ super.onBiometricFailed(errorCode, errString)
+ if (errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
+ viewModelScope.launch {
+ eventChannel.send(AutofillEvent.Abort)
+ }
+ return
+ }
+
+ viewModelScope.launch {
+ _uiState.update { it.copy(request = Request.JustAuthenticateWithPwd) }
+ }
+ }
+
+ override fun onUnlocked() {
+ viewModelScope.launch {
+ if (requestData is FillRequestData.Suggestion) {
+ sendFillEvent(requestData.vaultId)
+ return@launch
+ }
+ }
+ }
+
+ private fun onItemSelected(vaultId: ItemId) {
+ viewModelScope.launch {
+ val fillRightAway = doesItemHaveDomainReferences(
+ itemVaultId = vaultId,
+ domain = requestData.form.url.orEmpty()
+ )
+
+ if (fillRightAway) {
+ sendFillEvent(vaultId)
+ return@launch
+ }
+
+ val itemName = vaultItemRepository.getItemName(vaultId)
+ ?: throw IllegalArgumentException("Name for vaultId=$vaultId not found")
+
+ _uiState.update {
+ it.copy(
+ associationDialogVisibility = AssociationDialogVisibility.Visible(
+ itemName = itemName,
+ domain = requestData.form.url
+ ),
+ vaultId = vaultId
+ )
+ }
+ }
+ }
+
+ private fun onAuthenticated() {
+ viewModelScope.launch {
+ if (requestData is FillRequestData.Suggestion) {
+ sendFillEvent(requestData.vaultId)
+ return@launch
+ }
+
+ eventChannel.send(AutofillEvent.Abort)
+ }
+ }
+
+ fun onEvent(event: AutofillUiEvent) {
+ when (event) {
+ AutofillUiEvent.OnAssociate -> associateItem()
+ AutofillUiEvent.OnAuthenticated -> onAuthenticated()
+ AutofillUiEvent.OnCancelAssociation -> hideAssociationDialog()
+ is AutofillUiEvent.OnItemSelected -> onItemSelected(event.itemId)
+
+ AutofillUiEvent.OnContinueInSuspicion -> {
+ _uiState.update { it.copy(suspicionDialogVisibility = SuspicionDialogVisibility.Hidden) }
+ handleRequestDate(ignoreSuspicion = true)
+ }
+
+ AutofillUiEvent.OnAbortInSuspicion -> viewModelScope.launch {
+ eventChannel.send(AutofillEvent.Abort)
+ }
+ }
+ }
+
+ private fun associateItem() {
+ val vaultItemId = uiState.value.vaultId
+ requestData.form.url?.let {
+ viewModelScope.launch {
+ addRegistrableDomainToPassword(
+ vaultItemId = vaultItemId,
+ domain = it
+ )
+ }
+ }
+ hideAssociationDialog()
+ }
+
+ private fun hideAssociationDialog() {
+ _uiState.update { it.copy(associationDialogVisibility = AssociationDialogVisibility.Hidden) }
+ viewModelScope.launch {
+ sendFillEvent(_uiState.value.vaultId)
+ }
+ }
+
+ private suspend fun sendFillEvent(vaultId: ItemId) {
+ if (requestData !is FillRequestData) {
+ eventChannel.send(AutofillEvent.Abort)
+ return
+ }
+
+ val formInformation = requestData.form
+ when (formInformation.type) {
+ is FormType.Credentials -> {
+ val password = passwordRepository.getPasswordById(vaultId) ?: run {
+ eventChannel.send(AutofillEvent.Abort)
+ return
+ }
+
+ sendPasswordFillEvent(password)
+ }
+
+ is FormType.TOTP -> {
+ val totpField = formInformation.fields.firstOrNull {
+ it.type == FieldType.TOTP
+ } ?: run {
+ eventChannel.send(AutofillEvent.Abort)
+ return
+ }
+
+ val password = passwordRepository.getPasswordById(vaultId) ?: run {
+ eventChannel.send(AutofillEvent.Abort)
+ return
+ }
+
+ val totp = password.totpSecret?.let {
+ val secret = cryptographicScopeProvider.scope {
+ it.decryptSecretData().encodeToByteArray()
+ }
+ totpGenerator.observeTotp(secret).first()
+ } ?: run {
+ eventChannel.send(AutofillEvent.Abort)
+ return
+ }
+
+ val value = AutofillValue(
+ autofillId = totpField.autofillId,
+ value = totp.code
+ )
+
+ eventChannel.send(
+ AutofillEvent.Fill(
+ autofillDatasetProvider.getFillingDataset(
+ listOf(value)
+ )
+ )
+ )
+ }
+ }
+ }
+
+ private suspend fun sendPasswordFillEvent(password: Password) {
+ val values = requestData.form.fields.mapNotNull {
+ val type = it.type
+ if (type !is FieldType.Credentials) return@mapNotNull null
+ val value = when (type) {
+ FieldType.Credentials.Password -> cryptographicScopeProvider.scope {
+ password.encryptedData.decryptSecretData()
+ }
+
+ FieldType.Credentials.Username -> password.username
+
+ FieldType.Credentials.EMail -> {
+ password.username?.let { potentialEmail ->
+ val isEmail = PatternsCompat.EMAIL_ADDRESS.matcher(potentialEmail).matches()
+ if (isEmail) potentialEmail else null
+ }
+ }
+
+ FieldType.Credentials.Phone -> {
+ password.username?.let { potentialPhone ->
+ val isPhone = Patterns.PHONE.matcher(potentialPhone).matches()
+ if (isPhone) potentialPhone else null
+ }
+ }
+
+ FieldType.Undefined -> null
+ }
+
+ if (value.isNullOrBlank()) return@mapNotNull null
+
+ AutofillValue(
+ autofillId = it.autofillId,
+ value = value
+ )
+ }
+
+ // If no values could be extracted, abort the autofill request. This may happen on multi-page
+ // authentication screens. Let's say a screen only has a value for the username/email, but
+ // the user selected a item that des not have a username/email set. In this case we just abort.
+ if (values.isEmpty()) {
+ eventChannel.send(AutofillEvent.Abort)
+ return
+ }
+
+ eventChannel.send(AutofillEvent.Fill(autofillDatasetProvider.getFillingDataset(values)))
+ }
+
+ companion object {
+ const val KEY_AUTOFILL_INFORMATION = "extraction"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/component/AssociationDialog.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/component/AssociationDialog.kt
new file mode 100644
index 00000000..abfc26c8
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/component/AssociationDialog.kt
@@ -0,0 +1,79 @@
+package de.davis.keygo.autofill.presentation.activity.component
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AutoMode
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import de.davis.keygo.R
+
+@Composable
+internal fun AssociationDialog(
+ itemName: String,
+ domain: String?,
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ Button(
+ onClick = onConfirm
+ ) {
+ Text(text = stringResource(R.string.suggest))
+ }
+ },
+ dismissButton = {
+ OutlinedButton(
+ onClick = onDismiss
+ ) {
+ Text(text = stringResource(R.string.not_now))
+ }
+ },
+ title = {
+ Text(text = stringResource(R.string.suggest_for_this_app))
+ },
+ text = {
+ Text(
+ text = pluralStringResource(
+ R.plurals.suggest_text,
+ if (domain.isNullOrBlank()) 0 else 1,
+ itemName,
+ domain.orEmpty(),
+ )
+ )
+ },
+ icon = {
+ Icon(imageVector = Icons.Default.AutoMode, contentDescription = null)
+ },
+ modifier = Modifier.Companion.fillMaxWidth()
+ )
+}
+
+@Preview
+@Composable
+private fun AssociationDialogPreview() {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ AssociationDialog(
+ itemName = "My Item",
+ domain = "example.com",
+ onDismissRequest = {},
+ onConfirm = {},
+ onDismiss = {}
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/component/SuspicionDialog.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/component/SuspicionDialog.kt
new file mode 100644
index 00000000..2a50e73e
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/activity/component/SuspicionDialog.kt
@@ -0,0 +1,73 @@
+package de.davis.keygo.autofill.presentation.activity.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.WarningAmber
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import de.davis.keygo.R
+
+@Composable
+internal fun SuspicionDialog(
+ onContinue: () -> Unit,
+ onAbort: () -> Unit,
+ appPackageName: String,
+ website: String,
+ modifier: Modifier = Modifier
+) {
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {
+ Button(
+ onClick = onAbort
+ ) {
+ Text(text = stringResource(R.string.cancel))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onContinue
+ ) {
+ Text(text = stringResource(R.string.fill_anyway))
+ }
+ },
+ icon = {
+ Icon(imageVector = Icons.Default.WarningAmber, contentDescription = null)
+ },
+ title = {
+ Text(text = stringResource(R.string.suspicious_activity))
+ },
+ text = {
+ Text(
+ text = stringResource(
+ R.string.suspicious_activity_description,
+ appPackageName,
+ website
+ )
+ )
+ },
+ modifier = modifier
+ )
+}
+
+@Preview
+@Composable
+private fun SuspicionDialogPreview() {
+ MaterialTheme {
+ SuspicionDialog(
+ onContinue = {},
+ onAbort = {},
+ appPackageName = "com.example.app",
+ website = "example.com",
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt
new file mode 100644
index 00000000..ad97978a
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilder.kt
@@ -0,0 +1,16 @@
+package de.davis.keygo.autofill.presentation.dataset
+
+import android.content.IntentSender
+import android.service.autofill.Dataset
+import android.service.autofill.InlinePresentation
+import android.widget.RemoteViews
+import de.davis.keygo.autofill.presentation.model.Form
+
+internal interface DatasetBuilder {
+ fun buildDataset(
+ intentSender: IntentSender,
+ form: Form,
+ inlinePresentation: InlinePresentation? = null,
+ remoteViews: RemoteViews? = null,
+ ): Dataset
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt
new file mode 100644
index 00000000..5effdff9
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderApi33Impl.kt
@@ -0,0 +1,43 @@
+package de.davis.keygo.autofill.presentation.dataset
+
+import android.content.IntentSender
+import android.os.Build
+import android.service.autofill.Dataset
+import android.service.autofill.Field
+import android.service.autofill.InlinePresentation
+import android.service.autofill.Presentations
+import android.widget.RemoteViews
+import androidx.annotation.RequiresApi
+import de.davis.keygo.autofill.presentation.model.Form
+
+// No Single annotation here, as this class is being provided by the AutofillModule itself
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+internal class DatasetBuilderApi33Impl : DatasetBuilder {
+
+ override fun buildDataset(
+ intentSender: IntentSender,
+ form: Form,
+ inlinePresentation: InlinePresentation?,
+ remoteViews: RemoteViews?,
+ ): Dataset {
+ val presentation = Presentations.Builder().apply {
+ inlinePresentation?.let { setInlinePresentation(it) }
+ remoteViews?.let { setMenuPresentation(it) }
+ }.build()
+
+ return Dataset.Builder(presentation).apply {
+ setAuthentication(intentSender)
+ applyExtraction(form)
+ }.build()
+ }
+
+ private fun Dataset.Builder.applyExtraction(formInformation: Form) {
+ formInformation.fields.forEach {
+ setField(
+ it.autofillId,
+ Field.Builder().build()
+ )
+ }
+ }
+}
+
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderLegacyImpl.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderLegacyImpl.kt
new file mode 100644
index 00000000..8f320129
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/DatasetBuilderLegacyImpl.kt
@@ -0,0 +1,43 @@
+package de.davis.keygo.autofill.presentation.dataset
+
+import android.content.IntentSender
+import android.os.Build
+import android.service.autofill.Dataset
+import android.service.autofill.InlinePresentation
+import android.widget.RemoteViews
+import androidx.annotation.DeprecatedSinceApi
+import de.davis.keygo.autofill.presentation.model.Form
+
+@Suppress("DEPRECATION")
+@DeprecatedSinceApi(Build.VERSION_CODES.TIRAMISU)
+internal class DatasetBuilderLegacyImpl : DatasetBuilder {
+
+ override fun buildDataset(
+ intentSender: IntentSender,
+ form: Form,
+ inlinePresentation: InlinePresentation?,
+ remoteViews: RemoteViews?
+ ): Dataset {
+ val builder = remoteViews?.let {
+ Dataset.Builder(it)
+ } ?: Dataset.Builder()
+
+ return builder.apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
+ inlinePresentation?.let { setInlinePresentation(it) }
+
+ setAuthentication(intentSender)
+ applyExtraction(form)
+ }.build()
+ }
+
+
+ private fun Dataset.Builder.applyExtraction(formInformation: Form) {
+ formInformation.fields.forEach {
+ setValue(
+ it.autofillId,
+ null
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt
new file mode 100644
index 00000000..adb4c5e5
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SaveInfoAppender.kt
@@ -0,0 +1,125 @@
+package de.davis.keygo.autofill.presentation.dataset
+
+import android.os.Build
+import android.os.Bundle
+import android.service.autofill.FillResponse
+import android.service.autofill.RegexValidator
+import android.service.autofill.SaveInfo
+import android.util.Log
+import androidx.core.os.BundleCompat
+import de.davis.keygo.autofill.presentation.model.FieldType
+import de.davis.keygo.autofill.presentation.model.Form
+import de.davis.keygo.autofill.presentation.model.FormField
+import de.davis.keygo.autofill.presentation.model.FormType
+
+private const val TAG = "SaveInfoAppender"
+
+internal fun FillResponse.Builder.applySaveInfo(
+ form: Form,
+ clientInfo: Bundle,
+ requestId: Int,
+ inCompatibilityMode: Boolean,
+) {
+ val (updatedClientState, saveType) = clientInfo.updateState(requestId, form)
+
+ val updatedForm = updatedClientState.getForm()
+ ?: throw IllegalStateException("No form in client state")
+
+ val requiredIds = updatedForm.fields.map { it.autofillId }.toTypedArray()
+
+ Log.d(
+ TAG,
+ "Applied Save Info:\n" +
+ "- Request ID: $requestId\n" +
+ "- Current Form: $updatedForm\n" +
+ "- Save type: $saveType"
+ )
+
+
+ val saveInfo = SaveInfo.Builder(saveType, requiredIds).apply {
+ val password = updatedForm.getPasswordField()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ var flag = password?.let { 0 } ?: SaveInfo.FLAG_DELAY_SAVE
+
+ if (inCompatibilityMode)
+ flag = flag or SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE
+
+ Log.d(TAG, "SaveInfo flags: $flag")
+ setFlags(flag)
+ }
+
+ password?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1)
+ RegexValidator(it.autofillId, "^\\p{ASCII}*$".toPattern())
+ .also(::setValidator)
+ }
+ }.build()
+
+ setSaveInfo(saveInfo)
+ setClientState(updatedClientState)
+}
+
+private fun Form.getPasswordField(): FormField? {
+ if (type != FormType.Credentials) return null
+
+ return fields.find { it.type == FieldType.Credentials.Password }
+}
+
+private const val KEY_FORM = "form"
+private const val KEY_SAVE_TYPE = "saveType"
+
+internal fun Bundle.updateState(
+ requestId: Int,
+ form: Form,
+): Pair {
+ classLoader = Form::class.java.classLoader
+ var saveType = getInt(KEY_SAVE_TYPE, SaveInfo.SAVE_DATA_TYPE_GENERIC)
+
+ val satisfiedForm = form.mapFields { it.copy(requestId = requestId) }
+ val existingForm = getForm()
+ val mergedForm = existingForm?.merge(satisfiedForm) ?: satisfiedForm
+
+ return Bundle(this).apply {
+ putParcelable(
+ KEY_FORM,
+ mergedForm
+ )
+
+ satisfiedForm.fields.forEach {
+ if (it.requestId != requestId) return@forEach
+
+ saveType = saveType or when (it.type) {
+ FieldType.Credentials.Password -> SaveInfo.SAVE_DATA_TYPE_PASSWORD
+
+ FieldType.Credentials.Username -> SaveInfo.SAVE_DATA_TYPE_USERNAME
+
+ FieldType.Credentials.EMail -> SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS
+
+ FieldType.TOTP,
+ FieldType.Credentials.Phone,
+ FieldType.Undefined -> SaveInfo.SAVE_DATA_TYPE_GENERIC
+ }
+ }
+
+ putInt(KEY_SAVE_TYPE, saveType)
+ } to saveType
+}
+
+private fun Form.merge(other: Form): Form {
+ if (this.type != other.type)
+ throw IllegalArgumentException("Cannot merge forms of different type")
+
+ val mergedFields = (this.fields + other.fields)
+ .filter { it.url == this.url } // only keep fields from the same URL
+ .filter { it.type.includeInSaveInfo }
+ .distinctBy { it.autofillId }
+
+ return copy(fields = mergedFields)
+}
+
+internal fun Bundle.getForm(): Form? = getKeyGoParcelable(KEY_FORM)
+
+private inline fun Bundle.getKeyGoParcelable(key: String): T? {
+ classLoader = T::class.java.classLoader
+ return BundleCompat.getParcelable(this, key, T::class.java)
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SuggestionFinder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SuggestionFinder.kt
new file mode 100644
index 00000000..402cb022
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/SuggestionFinder.kt
@@ -0,0 +1,38 @@
+package de.davis.keygo.autofill.presentation.dataset
+
+import de.davis.keygo.autofill.presentation.model.Form
+import de.davis.keygo.autofill.presentation.model.FormType
+import de.davis.keygo.core.item.domain.model.lite.LitePassword
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItem
+import de.davis.keygo.core.item.domain.repository.PasswordRepository
+import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver
+import org.koin.core.annotation.Single
+
+@Single
+internal class SuggestionFinder(
+ private val passwordRepository: PasswordRepository,
+ private val registrableDomainResolver: RegistrableDomainResolver
+) {
+
+ internal suspend fun findVaultSuggestions(
+ form: Form,
+ count: Int
+ ): List {
+ if (count == 0) return emptyList()
+
+ return when (form.type) {
+ is FormType.Credentials -> findPasswordSuggestions(form, count)
+ is FormType.TOTP -> findPasswordSuggestions(form, count, withTOTP = true)
+ }
+ }
+
+ private suspend fun findPasswordSuggestions(
+ form: Form,
+ count: Int,
+ withTOTP: Boolean = false
+ ): List = form.url?.let {
+ registrableDomainResolver.resolve(it)
+ }?.let {
+ passwordRepository.getVaultPasswordsByTLD(etld1 = it, requireTotp = withTOTP, limit = count)
+ } ?: emptyList()
+}
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt
new file mode 100644
index 00000000..ca40964f
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt
@@ -0,0 +1,162 @@
+package de.davis.keygo.autofill.presentation.dataset.inline
+
+import android.content.Context
+import android.graphics.BlendMode
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.service.autofill.Dataset
+import android.service.autofill.InlinePresentation
+import android.widget.inline.InlinePresentationSpec
+import androidx.annotation.RequiresApi
+import de.davis.keygo.R
+import de.davis.keygo.autofill.presentation.dataset.DatasetBuilder
+import de.davis.keygo.autofill.presentation.dataset.SuggestionFinder
+import de.davis.keygo.autofill.presentation.getOnLongClickPendingIntent
+import de.davis.keygo.autofill.presentation.getSelectionPendingIntent
+import de.davis.keygo.autofill.presentation.model.FillRequestData
+import de.davis.keygo.autofill.presentation.model.Form
+import de.davis.keygo.autofill.presentation.model.FormType
+import de.davis.keygo.autofill.presentation.model.appRequestData
+import de.davis.keygo.autofill.presentation.model.pinnedRequestData
+import de.davis.keygo.autofill.presentation.model.suggestionRequestData
+import de.davis.keygo.autofill.presentation.subtitle
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItem
+import org.koin.core.annotation.Single
+
+@Single
+internal class InlineDatasetBuilder(
+ private val inlineSuggestionFactory: InlineSuggestionFactory,
+ private val datasetBuilder: DatasetBuilder,
+ private val suggestionFinder: SuggestionFinder,
+ private val context: Context,
+) {
+
+ // TODO: include a option to generate a secure password -> make the generate UI a standalone
+ // composable like a TextField
+ @RequiresApi(Build.VERSION_CODES.R)
+ suspend fun buildInlineDatasets(
+ specs: List,
+ form: Form
+ ): List = when (form.type) {
+ is FormType.TOTP -> buildTotpInlineDataset(specs, form)
+ else -> buildDefaultInlineDatasets(specs, form)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private suspend fun buildTotpInlineDataset(
+ specs: List,
+ form: Form
+ ) = when (specs.size) {
+ 0 -> emptyList()
+ else -> {
+ val suggestions =
+ suggestionFinder.findVaultSuggestions(form, count = 1)
+
+ suggestions.mapIndexed { index, suggestion ->
+ buildInlineSuggestionDataset(
+ spec = specs[index],
+ index = index,
+ form = form,
+ suggestion = suggestion
+ )
+ }
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private suspend fun buildDefaultInlineDatasets(
+ specs: List,
+ form: Form
+ ) = when (specs.size) {
+ 0 -> emptyList()
+ 1 -> listOf(buildPinnedInlineSuggestionDataset(specs.first(), form))
+ else -> {
+ val suggestions =
+ suggestionFinder.findVaultSuggestions(form, count = specs.size - 2)
+
+ suggestions.mapIndexed { index, suggestion ->
+ buildInlineSuggestionDataset(
+ spec = specs[index],
+ index = index,
+ form = form,
+ suggestion = suggestion
+ )
+ } + listOf(
+ buildAppInlineSuggestionDataset(
+ spec = specs.dropLast(1).last(),
+ form = form,
+ ),
+ buildPinnedInlineSuggestionDataset(
+ spec = specs.last(),
+ form = form
+ )
+ )
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun buildPinnedInlineSuggestionDataset(
+ spec: InlinePresentationSpec,
+ form: Form
+ ): Dataset {
+ val presentation = inlineSuggestionFactory.buildPinnedPresentation(
+ spec = spec,
+ pendingIntent = context.getOnLongClickPendingIntent(),
+ icon = appIcon()
+ )
+
+ return presentation.buildDataset(pinnedRequestData(form))
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun buildAppInlineSuggestionDataset(
+ spec: InlinePresentationSpec,
+ form: Form
+ ): Dataset {
+ val presentation = inlineSuggestionFactory.buildPresentation(
+ spec = spec,
+ pendingIntent = context.getOnLongClickPendingIntent(),
+ icon = appIcon(),
+ title = context.getString(R.string.app_name)
+ )
+
+ return presentation.buildDataset(appRequestData(form))
+ }
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun buildInlineSuggestionDataset(
+ spec: InlinePresentationSpec,
+ index: Int,
+ form: Form,
+ suggestion: LiteVaultItem
+ ): Dataset {
+ val presentation = inlineSuggestionFactory.buildPresentation(
+ spec = spec,
+ pendingIntent = context.getOnLongClickPendingIntent(),
+ title = suggestion.name,
+ subtitle = suggestion.subtitle(context = context, formType = form.type),
+ )
+
+ return presentation.buildDataset(
+ suggestionRequestData(
+ form,
+ suggestion.vaultItemId,
+ index
+ )
+ )
+ }
+
+ private fun InlinePresentation.buildDataset(fillRequestData: FillRequestData) =
+ datasetBuilder.buildDataset(
+ inlinePresentation = this,
+ intentSender = context.getSelectionPendingIntent(fillRequestData).intentSender,
+ form = fillRequestData.form
+ )
+
+ private fun appIcon(): Icon? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply {
+ setTintBlendMode(BlendMode.DST)
+ }
+ else null
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt
new file mode 100644
index 00000000..b5ab3ee8
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/inline/InlineSuggestionFactory.kt
@@ -0,0 +1,74 @@
+package de.davis.keygo.autofill.presentation.dataset.inline
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.service.autofill.InlinePresentation
+import android.widget.inline.InlinePresentationSpec
+import androidx.annotation.RequiresApi
+import androidx.autofill.inline.v1.InlineSuggestionUi
+import org.koin.core.annotation.Single
+
+@Single
+class InlineSuggestionFactory {
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ fun buildPresentation(
+ spec: InlinePresentationSpec,
+ pendingIntent: PendingIntent,
+ title: String,
+ subtitle: String? = null,
+ icon: Icon? = null
+ ): InlinePresentation = buildUniversalPresentation(
+ spec = spec,
+ pendingIntent = pendingIntent,
+ title = title,
+ subtitle = subtitle,
+ icon = icon
+ )
+
+ @RequiresApi(Build.VERSION_CODES.R)
+ fun buildPinnedPresentation(
+ spec: InlinePresentationSpec,
+ pendingIntent: PendingIntent,
+ icon: Icon? = null
+ ): InlinePresentation = buildUniversalPresentation(
+ spec = spec,
+ pendingIntent = pendingIntent,
+ icon = icon,
+ pinned = true
+ )
+
+ @SuppressLint("RestrictedApi")
+ @RequiresApi(Build.VERSION_CODES.R)
+ private fun buildUniversalPresentation(
+ spec: InlinePresentationSpec,
+ pendingIntent: PendingIntent,
+ title: String? = null,
+ subtitle: String? = null,
+ icon: Icon? = null,
+ pinned: Boolean = false
+ ): InlinePresentation {
+ val content = InlineSuggestionUi.newContentBuilder(pendingIntent).apply {
+ title?.let {
+ setContentDescription(it)
+ setTitle(it)
+ }
+
+ subtitle?.let {
+ setSubtitle(it)
+ }
+
+ icon?.let {
+ setStartIcon(it)
+ }
+ }.build()
+
+ return InlinePresentation(
+ content.slice,
+ spec,
+ pinned
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt
new file mode 100644
index 00000000..db5220d0
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt
@@ -0,0 +1,85 @@
+package de.davis.keygo.autofill.presentation.dataset.menu
+
+import android.content.Context
+import android.os.Build
+import android.service.autofill.Dataset
+import androidx.annotation.DeprecatedSinceApi
+import de.davis.keygo.R
+import de.davis.keygo.autofill.presentation.dataset.DatasetBuilder
+import de.davis.keygo.autofill.presentation.dataset.SuggestionFinder
+import de.davis.keygo.autofill.presentation.getSelectionPendingIntent
+import de.davis.keygo.autofill.presentation.model.Form
+import de.davis.keygo.autofill.presentation.model.FormType
+import de.davis.keygo.autofill.presentation.model.appRequestData
+import de.davis.keygo.autofill.presentation.model.suggestionRequestData
+import de.davis.keygo.autofill.presentation.subtitle
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItem
+import org.koin.core.annotation.Single
+
+@Single
+@DeprecatedSinceApi(Build.VERSION_CODES.R)
+internal class MenuDatasetBuilder(
+ private val suggestionFinder: SuggestionFinder,
+ private val menuDatasetBuilder: MenuSuggestionFactory,
+ private val datasetBuilder: DatasetBuilder,
+ private val context: Context
+) {
+
+ suspend fun buildMenuDatasets(
+ form: Form
+ ): List = when (form.type) {
+ is FormType.TOTP -> {
+ val suggestions =
+ suggestionFinder.findVaultSuggestions(form, count = 1)
+
+ suggestions.mapIndexed { index, suggestion ->
+ buildSuggestionDataset(index, form, suggestion)
+ }
+ }
+
+ else -> {
+ val suggestions = suggestionFinder.findVaultSuggestions(form, count = 4)
+
+ suggestions.mapIndexed { index, suggestion ->
+ buildSuggestionDataset(index, form, suggestion)
+ } + listOf(buildAppDataset(form))
+ }
+ }
+
+ private fun buildAppDataset(form: Form): Dataset {
+ val remoteViews = menuDatasetBuilder.buildMenuSuggestion(
+ title = context.getString(R.string.app_name),
+ subtitle = context.getString(R.string.autofill_service),
+ icon = R.mipmap.ic_launcher_round
+ )
+
+ return datasetBuilder.buildDataset(
+ remoteViews = remoteViews,
+ intentSender = context.getSelectionPendingIntent(appRequestData(form)).intentSender,
+ form = form,
+ )
+ }
+
+ private fun buildSuggestionDataset(
+ index: Int,
+ form: Form,
+ suggestion: LiteVaultItem
+ ): Dataset {
+ val remoteViews = menuDatasetBuilder.buildMenuSuggestion(
+ title = suggestion.name,
+ subtitle = suggestion.subtitle(context = context, formType = form.type),
+ )
+
+ return datasetBuilder.buildDataset(
+ remoteViews = remoteViews,
+ intentSender = context.getSelectionPendingIntent(
+ suggestionRequestData(
+ form,
+ suggestion.vaultItemId,
+ index
+ )
+ ).intentSender,
+ form = form,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuSuggestionFactory.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuSuggestionFactory.kt
new file mode 100644
index 00000000..862632d2
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/dataset/menu/MenuSuggestionFactory.kt
@@ -0,0 +1,27 @@
+package de.davis.keygo.autofill.presentation.dataset.menu
+
+import android.content.Context
+import android.widget.RemoteViews
+import androidx.annotation.DrawableRes
+import de.davis.keygo.R
+import org.koin.core.annotation.Single
+
+@Single
+class MenuSuggestionFactory(
+ private val context: Context
+) {
+
+ fun buildMenuSuggestion(
+ title: String,
+ subtitle: String? = null,
+ @DrawableRes
+ icon: Int? = null
+ ): RemoteViews = RemoteViews(
+ context.packageName,
+ R.layout.autofill_menu
+ ).apply {
+ setTextViewText(R.id.title, title)
+ subtitle?.let { setTextViewText(R.id.subtitle, it) }
+ icon?.let { setImageViewResource(R.id.icon, it) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/FieldTypeToFormTypeMapper.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/FieldTypeToFormTypeMapper.kt
new file mode 100644
index 00000000..9b46db80
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/FieldTypeToFormTypeMapper.kt
@@ -0,0 +1,10 @@
+package de.davis.keygo.autofill.presentation.mapper
+
+import de.davis.keygo.autofill.presentation.model.FieldType
+import de.davis.keygo.autofill.presentation.model.FormType
+
+fun FieldType.toFormType(): FormType = when (this) {
+ is FieldType.Credentials -> FormType.Credentials
+ is FieldType.TOTP -> FormType.TOTP
+ else -> throw IllegalArgumentException("Cannot convert $this to FormType")
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt
new file mode 100644
index 00000000..b831f0ae
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/mapper/ViewNodeToFieldFeaturesMapper.kt
@@ -0,0 +1,31 @@
+package de.davis.keygo.autofill.presentation.mapper
+
+import android.app.assist.AssistStructure
+import de.davis.keygo.autofill.presentation.model.FieldFeatures
+
+internal fun AssistStructure.ViewNode.toFieldFeatures(): FieldFeatures {
+ val autofillHints = autofillHints
+ ?.filterNot { it.isNullOrBlank() }
+ ?.toSet()
+ ?: emptySet()
+
+ val htmlAttributes = htmlInfo
+ ?.attributes
+ ?.filterNot { it.first.isNullOrBlank() || it.second.isNullOrBlank() }
+ ?.associate { it.first to it.second }
+ ?: emptyMap()
+
+ val tokens = listOfNotNull(
+ idEntry?.ifBlank { null },
+ hint?.ifBlank { null },
+ text?.ifBlank { null },
+ ).map(CharSequence::toString)
+ .map(String::lowercase).toSet()
+
+
+ return FieldFeatures(
+ autofillHints = autofillHints,
+ htmlAttributes = htmlAttributes,
+ tokens = tokens,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AssociationDialogVisibility.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AssociationDialogVisibility.kt
new file mode 100644
index 00000000..972f31c8
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AssociationDialogVisibility.kt
@@ -0,0 +1,9 @@
+package de.davis.keygo.autofill.presentation.model
+
+sealed interface AssociationDialogVisibility {
+ data object Hidden : AssociationDialogVisibility
+ data class Visible(
+ val itemName: String,
+ val domain: String?
+ ) : AssociationDialogVisibility
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillEvent.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillEvent.kt
new file mode 100644
index 00000000..f09e0183
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillEvent.kt
@@ -0,0 +1,10 @@
+package de.davis.keygo.autofill.presentation.model
+
+import android.service.autofill.Dataset
+
+sealed interface AutofillEvent {
+
+ data object Abort : AutofillEvent
+
+ data class Fill(val dataset: Dataset) : AutofillEvent
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillUiEvent.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillUiEvent.kt
new file mode 100644
index 00000000..1cf85aff
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillUiEvent.kt
@@ -0,0 +1,13 @@
+package de.davis.keygo.autofill.presentation.model
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+
+sealed interface AutofillUiEvent {
+ data object OnAuthenticated : AutofillUiEvent
+ data class OnItemSelected(val itemId: ItemId) : AutofillUiEvent
+ data object OnAssociate : AutofillUiEvent
+ data object OnCancelAssociation : AutofillUiEvent
+
+ data object OnContinueInSuspicion : AutofillUiEvent
+ data object OnAbortInSuspicion : AutofillUiEvent
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillUiState.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillUiState.kt
new file mode 100644
index 00000000..af19eabd
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillUiState.kt
@@ -0,0 +1,11 @@
+package de.davis.keygo.autofill.presentation.model
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemIdNone
+
+data class AutofillUiState(
+ val request: Request<*> = Request.None,
+ val associationDialogVisibility: AssociationDialogVisibility = AssociationDialogVisibility.Hidden,
+ val suspicionDialogVisibility: SuspicionDialogVisibility = SuspicionDialogVisibility.Hidden,
+ val vaultId: ItemId = ItemIdNone
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillValue.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillValue.kt
new file mode 100644
index 00000000..cb21fe90
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/AutofillValue.kt
@@ -0,0 +1,8 @@
+package de.davis.keygo.autofill.presentation.model
+
+import android.view.autofill.AutofillId
+
+data class AutofillValue(
+ val autofillId: AutofillId,
+ val value: String
+)
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldFeatures.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldFeatures.kt
new file mode 100644
index 00000000..c7953800
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldFeatures.kt
@@ -0,0 +1,7 @@
+package de.davis.keygo.autofill.presentation.model
+
+data class FieldFeatures(
+ val autofillHints: Set,
+ val htmlAttributes: Map,
+ val tokens: Set,
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldType.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldType.kt
new file mode 100644
index 00000000..bd5c90cf
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FieldType.kt
@@ -0,0 +1,42 @@
+package de.davis.keygo.autofill.presentation.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import kotlin.reflect.KClass
+
+@Parcelize
+sealed interface FieldType : Parcelable {
+
+ val group: KClass
+ val includeInSaveInfo: Boolean
+ get() = true
+
+ @Parcelize
+ sealed interface Credentials : FieldType {
+ override val group: KClass
+ get() = Credentials::class
+
+ data object Username : Credentials
+ data object EMail : Credentials
+ data object Phone : Credentials
+
+ data object Password : Credentials
+ }
+
+
+ @Parcelize
+ data object TOTP : FieldType {
+ override val group: KClass
+ get() = TOTP::class
+
+ override val includeInSaveInfo: Boolean
+ get() = false
+ }
+
+ @Parcelize
+ data object Undefined : FieldType {
+
+ override val group: KClass
+ get() = Undefined::class
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Form.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Form.kt
new file mode 100644
index 00000000..97fc7b69
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Form.kt
@@ -0,0 +1,20 @@
+package de.davis.keygo.autofill.presentation.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class Form(
+ val type: FormType,
+ val fields: List,
+ val url: String?,
+ val isBrowser: Boolean,
+ val appPackageName: String,
+ val isSuspicious: Boolean
+) : Parcelable {
+
+ fun hasFields() = fields.isNotEmpty()
+
+ fun mapFields(transform: (FormField) -> FormField) =
+ copy(fields = fields.map(transform))
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FormField.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FormField.kt
new file mode 100644
index 00000000..9aa3c6da
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FormField.kt
@@ -0,0 +1,15 @@
+package de.davis.keygo.autofill.presentation.model
+
+import android.os.Parcelable
+import android.view.autofill.AutofillId
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class FormField(
+ val autofillId: AutofillId,
+ val type: FieldType,
+ val focused: Boolean,
+ val url: String? = null,
+ val autofillValue: String? = null,
+ val requestId: Int = -1,
+) : Parcelable
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FormType.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FormType.kt
new file mode 100644
index 00000000..839260cb
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/FormType.kt
@@ -0,0 +1,11 @@
+package de.davis.keygo.autofill.presentation.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+sealed interface FormType : Parcelable {
+
+ data object Credentials : FormType
+ data object TOTP : FormType
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Request.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Request.kt
new file mode 100644
index 00000000..99fa3270
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/Request.kt
@@ -0,0 +1,28 @@
+package de.davis.keygo.autofill.presentation.model
+
+import de.davis.keygo.core.presentation.model.RouteDestination
+import de.davis.keygo.item.core.presentation.model.DetailPaneInformation
+
+sealed interface Request {
+ val destination: T
+
+ data object SelectItem : Request {
+ override val destination: RouteDestination = RouteDestination.Home.Root()
+ }
+
+ data class SaveItem(val createRaw: DetailPaneInformation.CreateRaw) :
+ Request {
+ override val destination: SaveItemDestination
+ get() = SaveItemDestination(createRaw)
+ }
+
+ data object JustAuthenticateWithPwd : Request {
+ override val destination: Nothing
+ get() = throw NotImplementedError("This should never be called")
+ }
+
+ data object None : Request {
+ override val destination: Nothing
+ get() = throw NotImplementedError("This should never be called")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/RequestData.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/RequestData.kt
new file mode 100644
index 00000000..92ae374d
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/RequestData.kt
@@ -0,0 +1,62 @@
+package de.davis.keygo.autofill.presentation.model
+
+import android.os.Parcelable
+import de.davis.keygo.core.item.domain.alias.ItemId
+import kotlinx.parcelize.IgnoredOnParcel
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+sealed interface RequestData : Parcelable {
+ val form: Form
+ val requestId: Int
+}
+
+data class SaveRequestData(
+ override val form: Form,
+) : RequestData {
+
+ @IgnoredOnParcel
+ override val requestId: Int = 2001
+}
+
+@Parcelize
+sealed interface FillRequestData : RequestData {
+
+ data class Suggestion(
+ override val form: Form,
+ val vaultId: ItemId,
+ val index: Int
+ ) : FillRequestData {
+
+ // We use the index as an offset to ensure unique requestIds for each suggestion. Otherwise
+ // the pending intent would be overridden by the last one created, causing only that one to
+ // be received.
+ @IgnoredOnParcel
+ override val requestId: Int = 1003 + index
+ }
+
+ data class App(
+ override val form: Form,
+ ) : FillRequestData {
+ @IgnoredOnParcel
+ override val requestId: Int = 1002
+ }
+
+ data class Pinned(
+ override val form: Form,
+ ) : FillRequestData {
+ @IgnoredOnParcel
+ override val requestId: Int = 1001
+ }
+}
+
+fun pinnedRequestData(formInformation: Form) = FillRequestData.Pinned(form = formInformation)
+
+fun appRequestData(formInformation: Form) = FillRequestData.App(form = formInformation)
+
+fun suggestionRequestData(formInformation: Form, vaultId: ItemId, index: Int) =
+ FillRequestData.Suggestion(
+ form = formInformation,
+ vaultId = vaultId,
+ index = index
+ )
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/SaveItemDestination.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/SaveItemDestination.kt
new file mode 100644
index 00000000..7a8a521d
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/SaveItemDestination.kt
@@ -0,0 +1,9 @@
+package de.davis.keygo.autofill.presentation.model
+
+import de.davis.keygo.item.core.presentation.model.DetailPaneInformation
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SaveItemDestination(
+ val createRaw: DetailPaneInformation.CreateRaw
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/SuspicionDialogVisibility.kt b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/SuspicionDialogVisibility.kt
new file mode 100644
index 00000000..5f717542
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/autofill/presentation/model/SuspicionDialogVisibility.kt
@@ -0,0 +1,6 @@
+package de.davis.keygo.autofill.presentation.model
+
+sealed interface SuspicionDialogVisibility {
+ data class Visible(val appPackageName: String, val website: String) : SuspicionDialogVisibility
+ data object Hidden : SuspicionDialogVisibility
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/converter/crypto/CryptoConverter.kt b/app/src/main/kotlin/de/davis/keygo/core/data/converter/crypto/CryptoConverter.kt
deleted file mode 100644
index 73f3f03d..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/data/converter/crypto/CryptoConverter.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package de.davis.keygo.core.data.converter.crypto
-
-import androidx.room.TypeConverter
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
-
-object CryptoConverter {
-
- @TypeConverter
- fun fromCryptographicDataToByteArray(value: CryptographicData?): ByteArray? = value?.data
-
- @TypeConverter
- fun fromByteArrayToCryptographicData(value: ByteArray?): CryptographicData? =
- value?.let { CryptographicData(it) }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/crypto/CryptographicScopeProviderImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/data/crypto/CryptographicScopeProviderImpl.kt
index 7050f368..65747562 100644
--- a/app/src/main/kotlin/de/davis/keygo/core/data/crypto/CryptographicScopeProviderImpl.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/data/crypto/CryptographicScopeProviderImpl.kt
@@ -3,16 +3,13 @@ package de.davis.keygo.core.data.crypto
import de.davis.keygo.core.domain.Session
import de.davis.keygo.core.domain.crypto.CryptographicScope
import de.davis.keygo.core.domain.crypto.CryptographicScopeProvider
-import de.davis.keygo.core.domain.model.crypto.AesKey
-import org.koin.core.annotation.ScopeId
import org.koin.core.annotation.Single
@Single
internal class CryptographicScopeProviderImpl(
- @ScopeId(name = Session.SCOPE_ID)
- private val aesKey: AesKey
+ private val session: Session
) : CryptographicScopeProvider {
override suspend fun scope(block: suspend CryptographicScope.() -> R): R =
- CryptographicScopeImpl(aesKey).block()
+ CryptographicScopeImpl(session.scope.get()).block()
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/estimator/PasswordStrengthEstimatorImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/data/estimator/PasswordStrengthEstimatorImpl.kt
index e52f3717..0ef80548 100644
--- a/app/src/main/kotlin/de/davis/keygo/core/data/estimator/PasswordStrengthEstimatorImpl.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/data/estimator/PasswordStrengthEstimatorImpl.kt
@@ -1,7 +1,7 @@
package de.davis.keygo.core.data.estimator
import de.davis.keygo.core.domain.estimator.PasswordStrengthEstimator
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.Password
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.gosimple.nbvcxz.Nbvcxz
@@ -10,11 +10,12 @@ import org.koin.core.annotation.Single
@Single
class PasswordStrengthEstimatorImpl(private val nbvcxz: Nbvcxz) : PasswordStrengthEstimator {
- override suspend fun estimate(password: String): Score = withContext(Dispatchers.Default) {
- if (password.isEmpty())
- return@withContext Score.None
+ override suspend fun estimate(password: String): Password.Score =
+ withContext(Dispatchers.Default) {
+ if (password.isEmpty())
+ return@withContext Password.Score.None
- val result = nbvcxz.estimate(password)
- Score(result.basicScore + 1 /* 1..5 */)
- }
+ val result = nbvcxz.estimate(password)
+ Password.Score(result.basicScore + 1 /* 1..5 */)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/PasswordDao.kt b/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/PasswordDao.kt
deleted file mode 100644
index 1b4b5592..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/PasswordDao.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package de.davis.keygo.core.data.local.dao
-
-import androidx.room.Dao
-import androidx.room.Query
-import androidx.room.Transaction
-import androidx.room.Upsert
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.generated.item.data.local.entity.PasswordEntity
-import de.davis.keygo.generated.item.data.local.relation.VaultPassword
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-internal interface PasswordDao {
-
- @Transaction
- @Query("SELECT * FROM VaultItemEntity")
- fun getVaultPasswords(): Flow>
-
-
- @Transaction
- @Query("SELECT * FROM VaultItemEntity WHERE vault_item_id = :vaultId")
- fun observeVaultPassword(vaultId: ItemId): Flow
-
- // TODO: provide a lighter entity for search results
- @Query("SELECT * FROM VaultItemEntity WHERE vault_item_id IN (SELECT vault_item_id FROM PasswordEntity WHERE username LIKE '%' || :username || '%' OR website LIKE '%' || :website || '%')")
- suspend fun searchVaultPassword(username: String?, website: String?): List
-
- @Transaction
- @Query("SELECT * FROM VaultItemEntity WHERE vault_item_id = :vaultId")
- suspend fun getVaultPassword(vaultId: ItemId): VaultPassword?
-
- @Upsert
- suspend fun upsert(password: PasswordEntity): ItemId
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/VaultDao.kt b/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/VaultDao.kt
deleted file mode 100644
index a5bdbce4..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/data/local/dao/VaultDao.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package de.davis.keygo.core.data.local.dao
-
-import androidx.room.Dao
-import androidx.room.Query
-import androidx.room.Upsert
-import de.davis.keygo.core.data.local.model.VaultItemMatch
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.generated.item.data.local.entity.VaultItemEntity
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-internal interface VaultDao {
-
- @Upsert
- suspend fun upsert(vaultItem: VaultItemEntity): ItemId
-
- @Query("DELETE FROM VaultItemEntity WHERE vault_item_id = :vaultItemId")
- suspend fun delete(vaultItemId: ItemId)
-
- @Query("SELECT * FROM VaultItemEntity WHERE vault_item_id = :id")
- suspend fun getVaultItemById(id: ItemId): VaultItemEntity?
-
- @Query("SELECT *, (name LIKE '%' || :query || '%') AS matchedName, (note LIKE '%' || :query || '%') AS matchedNote FROM VaultItemEntity WHERE name LIKE '%' || :query || '%' OR COALESCE(note, '') LIKE '%' || :query || '%'")
- suspend fun searchVaultItem(query: String): List
-
- @Query("SELECT * FROM VaultItemEntity")
- fun getAllVaultItems(): Flow>
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/local/datasource/KeyGoDatabase.kt b/app/src/main/kotlin/de/davis/keygo/core/data/local/datasource/KeyGoDatabase.kt
deleted file mode 100644
index e814482b..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/data/local/datasource/KeyGoDatabase.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package de.davis.keygo.core.data.local.datasource
-
-import android.content.Context
-import androidx.room.Database
-import androidx.room.Room
-import androidx.room.RoomDatabase
-import androidx.room.TypeConverters
-import de.davis.keygo.core.data.converter.crypto.CryptoConverter
-import de.davis.keygo.core.data.local.dao.PasswordDao
-import de.davis.keygo.core.data.local.dao.VaultDao
-import de.davis.keygo.generated.item.data.local.entity.PasswordEntity
-import de.davis.keygo.generated.item.data.local.entity.VaultItemEntity
-import org.koin.core.module.dsl.singleOf
-import org.koin.dsl.module
-
-@Database(entities = [VaultItemEntity::class, PasswordEntity::class], version = 1)
-@TypeConverters(CryptoConverter::class)
-abstract class KeyGoDatabase : RoomDatabase() {
-
- internal abstract fun vaultDao(): VaultDao
- internal abstract fun passwordDao(): PasswordDao
-
- companion object {
- val koinModule = module {
- single { create(get()) }
-
- singleOf(KeyGoDatabase::vaultDao)
- singleOf(KeyGoDatabase::passwordDao)
- }
-
- private fun create(applicationContext: Context) = Room.databaseBuilder(
- applicationContext,
- KeyGoDatabase::class.java,
- name = "secure_element_database"
- ).fallbackToDestructiveMigration(false).build()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/local/model/VaultItemMatch.kt b/app/src/main/kotlin/de/davis/keygo/core/data/local/model/VaultItemMatch.kt
deleted file mode 100644
index 2a2b19bc..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/data/local/model/VaultItemMatch.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package de.davis.keygo.core.data.local.model
-
-import androidx.room.Embedded
-import de.davis.keygo.generated.item.data.local.entity.VaultItemEntity
-
-internal data class VaultItemMatch(
- @Embedded
- val item: VaultItemEntity,
- val matchedName: Boolean,
- val matchedNote: Boolean
-)
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/mapper/DomainVaultMapper.kt b/app/src/main/kotlin/de/davis/keygo/core/data/mapper/DomainVaultMapper.kt
deleted file mode 100644
index 9443f05e..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/data/mapper/DomainVaultMapper.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package de.davis.keygo.core.data.mapper
-
-import de.davis.keygo.core.data.local.model.VaultItemMatch
-import de.davis.keygo.core.domain.model.VaultSearchResult
-
-internal fun VaultItemMatch.toDomain() = VaultSearchResult(
- vaultItemId = item.vaultItemId,
- name = item.name,
- encryptedData = item.encryptedData,
- note = item.note,
- matchedName = matchedName,
- matchedNote = matchedNote
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt
deleted file mode 100644
index 17c068ec..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/data/repository/PasswordRepositoryImpl.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package de.davis.keygo.core.data.repository
-
-import de.davis.keygo.core.data.local.dao.PasswordDao
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.model.Password
-import de.davis.keygo.core.domain.repository.PasswordRepository
-import de.davis.keygo.generated.item.data.local.relation.VaultPassword
-import de.davis.keygo.generated.item.data.mapper.toData
-import de.davis.keygo.generated.item.data.mapper.toDomain
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
-import org.koin.core.annotation.Single
-
-@Single
-internal class PasswordRepositoryImpl(
- private val passwordDao: PasswordDao
-) : PasswordRepository {
-
- override suspend fun createNewOrUpdatePassword(password: Password): ItemId =
- passwordDao.upsert(password.toData())
-
- override fun observeVaultPasswords(): Flow> =
- passwordDao.getVaultPasswords().map { vaultPassword ->
- vaultPassword.map { it.toDomain() }
- }
-
- override suspend fun searchVaultPasswords(
- username: String?,
- website: String?
- ): List = passwordDao.searchVaultPassword(username, website)
- .map(VaultPassword::toDomain)
-
- override suspend fun getVaultPasswordById(vaultId: ItemId): Password? =
- passwordDao.getVaultPassword(vaultId)?.toDomain()
-
- override fun observeVaultPasswordById(vaultId: ItemId): Flow =
- passwordDao.observeVaultPassword(vaultId).map(VaultPassword::toDomain)
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/data/repository/VaultItemRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/data/repository/VaultItemRepositoryImpl.kt
deleted file mode 100644
index cc71dbf5..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/data/repository/VaultItemRepositoryImpl.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package de.davis.keygo.core.data.repository
-
-import de.davis.keygo.core.data.local.dao.VaultDao
-import de.davis.keygo.core.data.mapper.toDomain
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.model.VaultItem
-import de.davis.keygo.core.domain.model.VaultSearchResult
-import de.davis.keygo.core.domain.repository.VaultItemRepository
-import de.davis.keygo.generated.item.data.mapper.toData
-import de.davis.keygo.generated.item.data.mapper.toDomain
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
-import org.koin.core.annotation.Single
-
-@Single
-internal class VaultItemRepositoryImpl(
- private val vaultDao: VaultDao
-) : VaultItemRepository {
-
- override suspend fun deleteVaultItem(vaultItemId: ItemId) {
- vaultDao.delete(vaultItemId)
- }
-
- override suspend fun getVaultItem(vaultItemId: ItemId): VaultItem.Basic? =
- vaultDao.getVaultItemById(vaultItemId)?.toDomain()
-
- override suspend fun createNewOrUpdateVaultItem(vaultItem: VaultItem): ItemId =
- vaultDao.upsert(vaultItem.toData())
-
- override suspend fun searchVaultItem(query: String): List =
- vaultDao.searchVaultItem(query).map { it.toDomain() }
-
- override fun observeVaultItems(): Flow> =
- vaultDao.getAllVaultItems()
- .map { vaultItems -> vaultItems.map { it.toDomain() } }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/di/CoreModule.kt b/app/src/main/kotlin/de/davis/keygo/core/di/CoreModule.kt
index dfc5c71f..240e988d 100644
--- a/app/src/main/kotlin/de/davis/keygo/core/di/CoreModule.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/di/CoreModule.kt
@@ -1,5 +1,9 @@
package de.davis.keygo.core.di
+import android.content.Context
+import androidx.datastore.dataStore
+import de.davis.keygo.auth.data.local.model.ProtoBiometricKeyData
+import de.davis.keygo.core.di.annotation.BiometricQualifier
import me.gosimple.nbvcxz.Nbvcxz
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@@ -16,4 +20,18 @@ object CoreModule {
@Single
internal fun provideNbvcxz() = Nbvcxz()
+
+
+ private val Context.protoBiometricKeyDataStore by dataStore(
+ "biometric_key_data.pb",
+ DefaultProtoSerializer(
+ defaultInstance = ProtoBiometricKeyData.getDefaultInstance(),
+ parser = ProtoBiometricKeyData.parser()
+ )
+ )
+
+ @Single
+ @BiometricQualifier
+ internal fun provideProtoBiometricKeyDataStore(context: Context) =
+ context.protoBiometricKeyDataStore
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/di/annotation/BiometricQualifier.kt b/app/src/main/kotlin/de/davis/keygo/core/di/annotation/BiometricQualifier.kt
similarity index 66%
rename from app/src/main/kotlin/de/davis/keygo/auth/di/annotation/BiometricQualifier.kt
rename to app/src/main/kotlin/de/davis/keygo/core/di/annotation/BiometricQualifier.kt
index a8dcd455..12130cd2 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/di/annotation/BiometricQualifier.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/di/annotation/BiometricQualifier.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.auth.di.annotation
+package de.davis.keygo.core.di.annotation
import org.koin.core.annotation.Named
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/crypto/SecretDataExt.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/crypto/SecretDataExt.kt
new file mode 100644
index 00000000..3c36ae5c
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/core/domain/crypto/SecretDataExt.kt
@@ -0,0 +1,29 @@
+package de.davis.keygo.core.domain.crypto
+
+import de.davis.keygo.core.domain.model.crypto.CryptographicData
+import de.davis.keygo.core.item.domain.model.SecretData
+import kotlinx.coroutines.Dispatchers
+import kotlin.coroutines.CoroutineContext
+
+context(scope: CryptographicScope)
+suspend fun SecretData.decryptSecretData(ctx: CoroutineContext = Dispatchers.Default): T =
+ with(scope) {
+ decryptedDataType.decode(
+ data.asCryptographicData().decrypt(ctx)
+ )
+ }
+
+context(scope: CryptographicScope)
+suspend inline fun T.encryptSecretData(ctx: CoroutineContext = Dispatchers.Default): SecretData =
+ with(scope) {
+ val decryptedDataType = SecretData.DecryptedDataType.getDecryptedDataType()
+ val encoded = decryptedDataType.encode(this@encryptSecretData)
+
+ SecretData(
+ data = encoded.encrypt(ctx).data,
+ decryptedDataType = decryptedDataType
+ )
+ }
+
+
+private fun ByteArray.asCryptographicData() = CryptographicData(this)
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/estimator/PasswordStrengthEstimator.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/estimator/PasswordStrengthEstimator.kt
index ebd84309..aeb3ce1d 100644
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/estimator/PasswordStrengthEstimator.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/domain/estimator/PasswordStrengthEstimator.kt
@@ -1,10 +1,10 @@
package de.davis.keygo.core.domain.estimator
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.Password
interface PasswordStrengthEstimator {
- suspend fun estimate(password: String): Score
+ suspend fun estimate(password: String): Password.Score
- suspend operator fun invoke(password: String): Score = estimate(password)
+ suspend operator fun invoke(password: String): Password.Score = estimate(password)
}
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/model/Password.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/model/Password.kt
deleted file mode 100644
index 42fb0f78..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/model/Password.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package de.davis.keygo.core.domain.model
-
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
-import de.davis.keygo.processor.annotation.Id
-import de.davis.keygo.processor.annotation.VaultEntity
-
-@VaultEntity(resString = "password", defaultIconType = "Password")
-data class Password(
- @Id
- val passwordId: ItemId = 0,
- val username: String?,
- val website: String?,
- val score: Score,
- val totpSecret: TotpSecret?,
- override val vaultItemId: ItemId = 0,
- override val name: String,
- override val encryptedData: CryptographicData,
- override val note: String?
-) : VaultItem
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/model/Score.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/model/Score.kt
deleted file mode 100644
index b90f23ca..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/model/Score.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package de.davis.keygo.core.domain.model
-
-enum class Score {
- None,
- Ridiculous,
- Weak,
- Moderate,
- Strong,
- Excellent,
- ;
-
- val isNone: Boolean
- get() = this == None
-
- companion object {
- operator fun invoke(@androidx.annotation.IntRange(from = 1, to = 5) value: Int): Score =
- when (value) {
- 1 -> Ridiculous
- 2 -> Weak
- 3 -> Moderate
- 4 -> Strong
- 5 -> Excellent
- else -> None
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/model/TotpSecret.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/model/TotpSecret.kt
deleted file mode 100644
index 6ebefd13..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/model/TotpSecret.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.davis.keygo.core.domain.model
-
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
-
-@JvmInline
-value class TotpSecret(val encodedSecret: CryptographicData)
-
-fun CryptographicData.asTotpSecret(): TotpSecret = TotpSecret(this)
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/model/VaultItem.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/model/VaultItem.kt
deleted file mode 100644
index c122b5f2..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/model/VaultItem.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package de.davis.keygo.core.domain.model
-
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
-import de.davis.keygo.processor.annotation.BasicModel
-import de.davis.keygo.processor.annotation.Id
-import de.davis.keygo.processor.annotation.RootVaultEntity
-
-@RootVaultEntity
-sealed interface VaultItem {
- @Id
- val vaultItemId: ItemId
- val name: String
- val note: String?
- val encryptedData: CryptographicData
-
- @BasicModel
- data class Basic(
- override val vaultItemId: ItemId = 0,
- override val name: String,
- override val encryptedData: CryptographicData,
- override val note: String?
- ) : VaultItem
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/model/VaultSearchResult.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/model/VaultSearchResult.kt
deleted file mode 100644
index c9daa704..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/model/VaultSearchResult.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package de.davis.keygo.core.domain.model
-
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
-import de.davis.keygo.processor.annotation.Ignore
-
-@Ignore
-data class VaultSearchResult(
- override val vaultItemId: ItemId,
- override val name: String,
- override val encryptedData: CryptographicData,
- override val note: String?,
- val matchedName: Boolean,
- val matchedNote: Boolean,
-) : VaultItem
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/model/crypto/CryptographicData.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/model/crypto/CryptographicData.kt
index dc44a1ad..134701d9 100644
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/model/crypto/CryptographicData.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/domain/model/crypto/CryptographicData.kt
@@ -20,9 +20,4 @@ data class CryptographicData(val data: ByteArray) {
companion object {
val EMPTY: CryptographicData get() = CryptographicData(byteArrayOf())
}
-}
-
-@OptIn(ExperimentalStdlibApi::class)
-fun String.asCryptographicData(): CryptographicData {
- return CryptographicData(hexToByteArray())
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt
deleted file mode 100644
index a92e68b3..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/repository/PasswordRepository.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package de.davis.keygo.core.domain.repository
-
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.model.Password
-import kotlinx.coroutines.flow.Flow
-
-interface PasswordRepository {
-
- suspend fun createNewOrUpdatePassword(password: Password): ItemId
- fun observeVaultPasswords(): Flow>
-
- suspend fun searchVaultPasswords(
- username: String? = null,
- website: String? = null
- ): List
-
- suspend fun getVaultPasswordById(vaultId: ItemId): Password?
- fun observeVaultPasswordById(vaultId: ItemId): Flow
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/repository/VaultItemRepository.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/repository/VaultItemRepository.kt
deleted file mode 100644
index d12dd39f..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/repository/VaultItemRepository.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package de.davis.keygo.core.domain.repository
-
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.model.VaultItem
-import de.davis.keygo.core.domain.model.VaultSearchResult
-import kotlinx.coroutines.flow.Flow
-
-interface VaultItemRepository {
-
- suspend fun deleteVaultItem(vaultItemId: ItemId)
- suspend fun getVaultItem(vaultItemId: ItemId): VaultItem.Basic?
-
- suspend fun createNewOrUpdateVaultItem(vaultItem: VaultItem): ItemId
- suspend fun searchVaultItem(query: String): List
- fun observeVaultItems(): Flow>
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/HasValidAccessUseCase.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/usecase/HasValidAccessUseCase.kt
similarity index 90%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/HasValidAccessUseCase.kt
rename to app/src/main/kotlin/de/davis/keygo/core/domain/usecase/HasValidAccessUseCase.kt
index 453ff5df..a52af95b 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/HasValidAccessUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/domain/usecase/HasValidAccessUseCase.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.auth.domain.usecase
+package de.davis.keygo.core.domain.usecase
import de.davis.keygo.auth.domain.repository.PasswordWrappedKeyRepository
import de.davis.keygo.core.di.annotation.PasswordQualifier
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/usecase/UpsertVaultItem.kt b/app/src/main/kotlin/de/davis/keygo/core/domain/usecase/UpsertVaultItem.kt
deleted file mode 100644
index 222b725b..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/usecase/UpsertVaultItem.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package de.davis.keygo.core.domain.usecase
-
-import de.davis.keygo.core.domain.Result
-import de.davis.keygo.core.domain.model.Password
-import de.davis.keygo.core.domain.model.VaultItem
-import de.davis.keygo.core.domain.repository.PasswordRepository
-import de.davis.keygo.core.domain.repository.VaultItemRepository
-import org.koin.core.annotation.Single
-
-@Single
-class UpsertVaultItem(
- private val vaultItemRepository: VaultItemRepository,
- private val passwordRepository: PasswordRepository,
-) {
-
- suspend operator fun invoke(item: I): Result = runCatching {
- val id = vaultItemRepository.createNewOrUpdateVaultItem(item)
- .takeIf { it != -1L }
- ?: item.vaultItemId
-
- when (item) {
- is Password -> {
- passwordRepository.createNewOrUpdatePassword(item.copy(vaultItemId = id))
- }
-
- else -> {}
- }
- }.fold(
- onFailure = { Result.Failure(it) },
- onSuccess = { Result.Success(Unit) }
- )
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/data/mapper/BiometricWrappedKeyMapper.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/mapper/BiometricWrappedKeyMapper.kt
similarity index 76%
rename from app/src/main/kotlin/de/davis/keygo/auth/data/mapper/BiometricWrappedKeyMapper.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/mapper/BiometricWrappedKeyMapper.kt
index 5fd5a196..f830a366 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/data/mapper/BiometricWrappedKeyMapper.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/mapper/BiometricWrappedKeyMapper.kt
@@ -1,9 +1,9 @@
-package de.davis.keygo.auth.data.mapper
+package de.davis.keygo.core.identity.biometric.data.mapper
import com.google.protobuf.kotlin.toByteString
import de.davis.keygo.auth.data.local.model.ProtoBiometricKeyData
import de.davis.keygo.auth.data.local.model.protoBiometricKeyData
-import de.davis.keygo.auth.domain.model.BiometricWrappedKeyData
+import de.davis.keygo.core.identity.common.domain.model.BiometricWrappedKeyData
fun ProtoBiometricKeyData.toDomain() = BiometricWrappedKeyData(
wrappedKey = key.toByteArray(),
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/BiometricAvailabilityRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/repository/BiometricAvailabilityRepositoryImpl.kt
similarity index 84%
rename from app/src/main/kotlin/de/davis/keygo/auth/data/repository/BiometricAvailabilityRepositoryImpl.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/repository/BiometricAvailabilityRepositoryImpl.kt
index 40a8ba41..154fe068 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/BiometricAvailabilityRepositoryImpl.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/repository/BiometricAvailabilityRepositoryImpl.kt
@@ -1,10 +1,10 @@
-package de.davis.keygo.auth.data.repository
+package de.davis.keygo.core.identity.biometric.data.repository
import android.content.Context
import androidx.biometric.BiometricManager
-import de.davis.keygo.auth.domain.model.BiometricAvailability
-import de.davis.keygo.auth.domain.model.BiometricClass
-import de.davis.keygo.auth.domain.repository.BiometricAvailabilityRepository
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricAvailability
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricClass
+import de.davis.keygo.core.identity.biometric.domain.repository.BiometricAvailabilityRepository
import org.koin.core.annotation.Single
@Single
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/BiometricKekRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/repository/BiometricKekRepositoryImpl.kt
similarity index 87%
rename from app/src/main/kotlin/de/davis/keygo/auth/data/repository/BiometricKekRepositoryImpl.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/repository/BiometricKekRepositoryImpl.kt
index 882f4755..cf9a58a1 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/BiometricKekRepositoryImpl.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/repository/BiometricKekRepositoryImpl.kt
@@ -1,17 +1,17 @@
-package de.davis.keygo.auth.data.repository
+package de.davis.keygo.core.identity.biometric.data.repository
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
-import de.davis.keygo.auth.domain.model.KeyStoreError
-import de.davis.keygo.auth.domain.repository.BiometricKekRepository
-import de.davis.keygo.core.domain.Result
-import de.davis.keygo.core.domain.asResult
import de.davis.keygo.core.domain.crypto.CryptographicConstants
import de.davis.keygo.core.domain.model.crypto.AesKey
import de.davis.keygo.core.domain.model.crypto.asAesKey
+import de.davis.keygo.core.identity.biometric.domain.model.KeyStoreError
+import de.davis.keygo.core.identity.biometric.domain.repository.BiometricKekRepository
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.asResult
import org.koin.core.annotation.Single
import java.security.KeyStore
import javax.crypto.KeyGenerator
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/BiometricWrappedKeyRepositoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/repository/BiometricWrappedKeyRepositoryImpl.kt
similarity index 53%
rename from app/src/main/kotlin/de/davis/keygo/auth/data/repository/BiometricWrappedKeyRepositoryImpl.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/repository/BiometricWrappedKeyRepositoryImpl.kt
index 057049bd..62b06d21 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/BiometricWrappedKeyRepositoryImpl.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/data/repository/BiometricWrappedKeyRepositoryImpl.kt
@@ -1,16 +1,15 @@
-package de.davis.keygo.auth.data.repository
+package de.davis.keygo.core.identity.biometric.data.repository
import androidx.datastore.core.DataStore
import de.davis.keygo.auth.data.local.model.ProtoBiometricKeyData
-import de.davis.keygo.auth.data.mapper.toDomain
-import de.davis.keygo.auth.data.mapper.toProto
-import de.davis.keygo.auth.di.annotation.BiometricQualifier
-import de.davis.keygo.auth.domain.model.BiometricWrappedKeyData
-import de.davis.keygo.auth.domain.repository.WrappedKeyRepository
+import de.davis.keygo.core.di.annotation.BiometricQualifier
+import de.davis.keygo.core.identity.biometric.data.mapper.toDomain
+import de.davis.keygo.core.identity.biometric.data.mapper.toProto
+import de.davis.keygo.core.identity.common.data.repository.DefaultWrappedKeyRepository
+import de.davis.keygo.core.identity.common.domain.model.BiometricWrappedKeyData
+import de.davis.keygo.core.identity.common.domain.repository.WrappedKeyRepository
import org.koin.core.annotation.Single
-typealias BiometricWrappedKeyRepository = WrappedKeyRepository
-
@Suppress("FunctionName")
@Single(binds = [WrappedKeyRepository::class])
@BiometricQualifier
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricAvailability.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/BiometricAvailability.kt
similarity index 86%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricAvailability.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/BiometricAvailability.kt
index 8c187478..c7788f5a 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricAvailability.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/BiometricAvailability.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.auth.domain.model
+package de.davis.keygo.core.identity.biometric.domain.model
sealed interface BiometricAvailability {
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricClass.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/BiometricClass.kt
similarity index 65%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricClass.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/BiometricClass.kt
index 6e9b83f6..0fafdb12 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricClass.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/BiometricClass.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.auth.domain.model
+package de.davis.keygo.core.identity.biometric.domain.model
sealed interface BiometricClass {
data object Class2 : BiometricClass
diff --git a/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/BiometricEvent.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/BiometricEvent.kt
new file mode 100644
index 00000000..2672a969
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/BiometricEvent.kt
@@ -0,0 +1,11 @@
+package de.davis.keygo.core.identity.biometric.domain.model
+
+import javax.crypto.Cipher
+
+sealed interface BiometricEvent {
+ data class OnAuthenticationSucceeded(
+ val cipher: Cipher? = null
+ ) : BiometricEvent
+
+ data class OnAuthenticationError(val errorCode: Int, val errString: String) : BiometricEvent
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/KeyStoreError.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/KeyStoreError.kt
similarity index 56%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/model/KeyStoreError.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/KeyStoreError.kt
index 692995cd..eb6c2a0c 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/KeyStoreError.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/model/KeyStoreError.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.auth.domain.model
+package de.davis.keygo.core.identity.biometric.domain.model
sealed interface KeyStoreError {
data object KeyNotFound : KeyStoreError
diff --git a/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/repository/BiometricAvailabilityRepository.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/repository/BiometricAvailabilityRepository.kt
new file mode 100644
index 00000000..65e8b910
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/repository/BiometricAvailabilityRepository.kt
@@ -0,0 +1,9 @@
+package de.davis.keygo.core.identity.biometric.domain.repository
+
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricAvailability
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricClass
+
+interface BiometricAvailabilityRepository {
+
+ fun availability(biometricClass: BiometricClass): BiometricAvailability
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/BiometricKekRepository.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/repository/BiometricKekRepository.kt
similarity index 52%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/repository/BiometricKekRepository.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/repository/BiometricKekRepository.kt
index 2263f5e0..e963b9d6 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/BiometricKekRepository.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/repository/BiometricKekRepository.kt
@@ -1,12 +1,12 @@
-package de.davis.keygo.auth.domain.repository
+package de.davis.keygo.core.identity.biometric.domain.repository
-import de.davis.keygo.auth.domain.model.KeyStoreError
-import de.davis.keygo.core.domain.Result
import de.davis.keygo.core.domain.model.crypto.AesKey
+import de.davis.keygo.core.identity.biometric.domain.model.KeyStoreError
+import de.davis.keygo.core.util.Result
interface BiometricKekRepository {
fun hasKek(): Boolean
fun getKek(): Result
fun createKek(): AesKey
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/GetBiometricCryptoSetupAvailabilityUseCase.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/GetBiometricCryptoSetupAvailabilityUseCase.kt
similarity index 66%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/GetBiometricCryptoSetupAvailabilityUseCase.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/GetBiometricCryptoSetupAvailabilityUseCase.kt
index f2c88d79..e015e64b 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/GetBiometricCryptoSetupAvailabilityUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/GetBiometricCryptoSetupAvailabilityUseCase.kt
@@ -1,9 +1,9 @@
-package de.davis.keygo.auth.domain.usecase
+package de.davis.keygo.core.identity.biometric.domain.usecase
-import de.davis.keygo.auth.data.repository.BiometricWrappedKeyRepository
-import de.davis.keygo.auth.di.annotation.BiometricQualifier
-import de.davis.keygo.auth.domain.model.BiometricAvailability
-import de.davis.keygo.auth.domain.repository.BiometricKekRepository
+import de.davis.keygo.core.di.annotation.BiometricQualifier
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricAvailability
+import de.davis.keygo.core.identity.biometric.domain.repository.BiometricKekRepository
+import de.davis.keygo.core.identity.common.domain.repository.BiometricWrappedKeyRepository
import org.koin.core.annotation.Single
@Single
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/GetBiometricHardwareAvailabilityUseCase.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/GetBiometricHardwareAvailabilityUseCase.kt
similarity index 54%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/GetBiometricHardwareAvailabilityUseCase.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/GetBiometricHardwareAvailabilityUseCase.kt
index 05814a0b..4087a301 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/GetBiometricHardwareAvailabilityUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/GetBiometricHardwareAvailabilityUseCase.kt
@@ -1,8 +1,8 @@
-package de.davis.keygo.auth.domain.usecase
+package de.davis.keygo.core.identity.biometric.domain.usecase
-import de.davis.keygo.auth.domain.model.BiometricAvailability
-import de.davis.keygo.auth.domain.model.BiometricClass
-import de.davis.keygo.auth.domain.repository.BiometricAvailabilityRepository
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricAvailability
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricClass
+import de.davis.keygo.core.identity.biometric.domain.repository.BiometricAvailabilityRepository
import org.koin.core.annotation.Single
@Single
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/PrepareBiometricCipherUseCase.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/PrepareBiometricCipherUseCase.kt
similarity index 59%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/PrepareBiometricCipherUseCase.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/PrepareBiometricCipherUseCase.kt
index 4824b97b..a297366e 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/PrepareBiometricCipherUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/PrepareBiometricCipherUseCase.kt
@@ -1,13 +1,13 @@
-package de.davis.keygo.auth.domain.usecase
+package de.davis.keygo.core.identity.biometric.domain.usecase
-import de.davis.keygo.auth.data.repository.BiometricWrappedKeyRepository
-import de.davis.keygo.auth.di.annotation.BiometricQualifier
-import de.davis.keygo.auth.domain.factory.CipherFactory
-import de.davis.keygo.auth.domain.model.CryptographicMode
-import de.davis.keygo.auth.domain.model.CryptographyError
-import de.davis.keygo.auth.domain.repository.BiometricKekRepository
-import de.davis.keygo.core.domain.Result
-import de.davis.keygo.core.domain.getOrNull
+import de.davis.keygo.core.di.annotation.BiometricQualifier
+import de.davis.keygo.core.identity.biometric.domain.repository.BiometricKekRepository
+import de.davis.keygo.core.identity.common.domain.CipherFactory
+import de.davis.keygo.core.identity.common.domain.model.CryptographicMode
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.identity.common.domain.repository.BiometricWrappedKeyRepository
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.getOrNull
import org.koin.core.annotation.Single
import javax.crypto.Cipher
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithBiometricsUseCase.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/UnlockWithBiometricsUseCase.kt
similarity index 59%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithBiometricsUseCase.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/UnlockWithBiometricsUseCase.kt
index e2bd2b2f..8a8e0aba 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithBiometricsUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/domain/usecase/UnlockWithBiometricsUseCase.kt
@@ -1,15 +1,15 @@
-package de.davis.keygo.auth.domain.usecase
+package de.davis.keygo.core.identity.biometric.domain.usecase
-import de.davis.keygo.auth.data.repository.BiometricWrappedKeyRepository
-import de.davis.keygo.auth.di.annotation.BiometricQualifier
-import de.davis.keygo.auth.domain.factory.CipherFactory
-import de.davis.keygo.auth.domain.model.CryptographyError
-import de.davis.keygo.core.domain.Result
+import de.davis.keygo.core.di.annotation.BiometricQualifier
import de.davis.keygo.core.domain.Session
-import de.davis.keygo.core.domain.asResult
-import de.davis.keygo.core.domain.asUnitResult
-import de.davis.keygo.core.domain.onSuccess
-import de.davis.keygo.core.domain.zip
+import de.davis.keygo.core.identity.common.domain.CipherFactory
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.identity.common.domain.repository.BiometricWrappedKeyRepository
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.asResult
+import de.davis.keygo.core.util.asUnitResult
+import de.davis.keygo.core.util.onSuccess
+import de.davis.keygo.core.util.zip
import org.koin.core.annotation.Single
import javax.crypto.Cipher
diff --git a/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/BiometricManager.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/BiometricManager.kt
new file mode 100644
index 00000000..b4f75354
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/BiometricManager.kt
@@ -0,0 +1,63 @@
+package de.davis.keygo.core.identity.biometric.presentation
+
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.fragment.app.FragmentActivity
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricEvent
+import de.davis.keygo.core.identity.biometric.presentation.model.BiometricRequest
+import de.davis.keygo.core.presentation.resolve
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+
+class BiometricManager(private val activity: FragmentActivity) {
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ suspend fun authenticate(request: BiometricRequest) = suspendCancellableCoroutine { c ->
+ val prompt = BiometricPrompt(
+ activity,
+ Dispatchers.Main.asExecutor(),
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ c.resume(
+ BiometricEvent.OnAuthenticationSucceeded(
+ cipher = result.cryptoObject?.cipher
+ )
+ )
+ }
+
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ c.resume(
+ BiometricEvent.OnAuthenticationError(
+ errorCode,
+ errString.toString()
+ )
+ )
+ }
+
+ override fun onAuthenticationFailed() {
+ // We do not resume, as this causes the coroutine to be finished and we cannot
+ // handle further attempts. The Android framework may still send further events,
+ // which we could handle .
+ }
+ }
+ )
+
+ when (request) {
+ is BiometricRequest.Class3 -> prompt.authenticate(
+ BiometricPrompt.PromptInfo.Builder()
+ .setTitle(request.title.resolve(activity))
+ .setNegativeButtonText(request.negativeButtonText.resolve(activity))
+ .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
+ .build(),
+ BiometricPrompt.CryptoObject(request.cipher)
+ )
+ }
+
+ c.invokeOnCancellation {
+ prompt.cancelAuthentication()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/BiometricPromptSupport.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/BiometricPromptSupport.kt
new file mode 100644
index 00000000..350a86ed
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/BiometricPromptSupport.kt
@@ -0,0 +1,25 @@
+package de.davis.keygo.core.identity.biometric.presentation
+
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.fragment.app.FragmentActivity
+
+@Composable
+fun BiometricPromptSupport(content: @Composable () -> Unit) {
+ val activity = LocalActivity.current as? FragmentActivity
+ requireNotNull(activity) { "BiometricPromptSupport must be used within a FragmentActivity context." }
+
+ val biometricManager = remember(activity) { BiometricManager(activity) }
+
+ CompositionLocalProvider(
+ LocalBiometricManager provides biometricManager,
+ content = content
+ )
+}
+
+val LocalBiometricManager = staticCompositionLocalOf {
+ error("No LocalBiometricManager provided")
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/BiometricViewModel.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/BiometricViewModel.kt
new file mode 100644
index 00000000..6da19794
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/BiometricViewModel.kt
@@ -0,0 +1,99 @@
+package de.davis.keygo.core.identity.biometric.presentation
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import de.davis.keygo.R
+import de.davis.keygo.core.domain.usecase.HasValidAccessUseCase
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricAvailability
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricEvent
+import de.davis.keygo.core.identity.biometric.domain.usecase.GetBiometricCryptoSetupAvailabilityUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.GetBiometricHardwareAvailabilityUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.PrepareBiometricCipherUseCase
+import de.davis.keygo.core.identity.biometric.domain.usecase.UnlockWithBiometricsUseCase
+import de.davis.keygo.core.identity.biometric.presentation.model.BiometricRequest
+import de.davis.keygo.core.identity.common.domain.model.CryptographicMode
+import de.davis.keygo.core.presentation.UIText
+import de.davis.keygo.core.util.onFailure
+import de.davis.keygo.core.util.onSuccess
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+
+abstract class BiometricViewModel(
+ private val getBiometricCryptoSetupAvailability: GetBiometricCryptoSetupAvailabilityUseCase,
+ private val getBiometricHardwareAvailability: GetBiometricHardwareAvailabilityUseCase,
+ private val hasValidAccess: HasValidAccessUseCase,
+ private val prepareBiometricCipher: PrepareBiometricCipherUseCase,
+ protected val unlockWithBiometrics: UnlockWithBiometricsUseCase,
+) : ViewModel() {
+
+ private val biometricRequestChannel = Channel()
+ val biometricRequests = biometricRequestChannel.receiveAsFlow()
+
+ fun requestBiometricAuthentication(
+ mode: CryptographicMode = CryptographicMode.Unwrap,
+ creatingAccess: Boolean = false,
+ title: UIText = UIText.ResourceString(R.string.authenticate),
+ negativeButton: UIText = UIText.ResourceString(R.string.cancel),
+ ) {
+ viewModelScope.launch {
+ val hasAccess = hasValidAccess()
+
+ val isBiometricHardwareAvailable =
+ getBiometricHardwareAvailability() == BiometricAvailability.Available
+ val isBiometricCryptoSetupAvailable = if (hasAccess)
+ getBiometricCryptoSetupAvailability() == BiometricAvailability.Available
+ else false
+
+ val biometricsUsable =
+ isBiometricHardwareAvailable && (isBiometricCryptoSetupAvailable || creatingAccess)
+
+ if (!biometricsUsable) return@launch
+
+ prepareBiometricCipher(mode = mode).onSuccess {
+ biometricRequestChannel.send(
+ BiometricRequest.Class3(
+ title = title,
+ negativeButtonText = negativeButton,
+ cipher = it
+ )
+ )
+ }.onFailure {
+ Log.e(TAG, "Failed to prepare biometric cipher.")
+ }
+ }
+ }
+
+ fun onBiometricResult(result: BiometricEvent) {
+ when (result) {
+ is BiometricEvent.OnAuthenticationError -> onBiometricFailed(
+ errorCode = result.errorCode,
+ errString = result.errString
+ )
+
+ is BiometricEvent.OnAuthenticationSucceeded -> onBiometricSucceeded(result)
+ }
+ }
+
+ protected open fun onBiometricSucceeded(event: BiometricEvent.OnAuthenticationSucceeded) {
+ val cipher = event.cipher ?: return
+ viewModelScope.launch {
+ unlockWithBiometrics(cipher).onSuccess {
+ onUnlocked()
+ }.onFailure {
+ Log.e(TAG, "Failed to unlock with biometrics.")
+ }
+ }
+ }
+
+ protected open fun onBiometricFailed(errorCode: Int, errString: String) {
+ Log.d(TAG, "Biometric authentication failed: $errString ($errorCode)")
+ }
+
+ protected abstract fun onUnlocked()
+
+ companion object {
+ private const val TAG = "BiometricViewModel"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/model/BiometricRequest.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/model/BiometricRequest.kt
new file mode 100644
index 00000000..69381b2b
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/biometric/presentation/model/BiometricRequest.kt
@@ -0,0 +1,16 @@
+package de.davis.keygo.core.identity.biometric.presentation.model
+
+import de.davis.keygo.core.presentation.UIText
+import javax.crypto.Cipher
+
+sealed interface BiometricRequest {
+
+ val title: UIText
+ val negativeButtonText: UIText
+
+ data class Class3(
+ override val title: UIText,
+ override val negativeButtonText: UIText,
+ val cipher: Cipher,
+ ) : BiometricRequest
+}
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/data/factory/CipherFactoryImpl.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/common/data/CipherFactoryImpl.kt
similarity index 92%
rename from app/src/main/kotlin/de/davis/keygo/auth/data/factory/CipherFactoryImpl.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/common/data/CipherFactoryImpl.kt
index c55f7ef0..f3613b80 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/data/factory/CipherFactoryImpl.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/common/data/CipherFactoryImpl.kt
@@ -1,12 +1,12 @@
-package de.davis.keygo.auth.data.factory
+package de.davis.keygo.core.identity.common.data
-import de.davis.keygo.auth.domain.factory.CipherFactory
-import de.davis.keygo.auth.domain.model.CryptographicMode
-import de.davis.keygo.auth.domain.model.CryptographyError
-import de.davis.keygo.core.domain.Result
import de.davis.keygo.core.domain.crypto.CryptographicConstants
import de.davis.keygo.core.domain.model.crypto.AesKey
import de.davis.keygo.core.domain.model.crypto.asAesKey
+import de.davis.keygo.core.identity.common.domain.CipherFactory
+import de.davis.keygo.core.identity.common.domain.model.CryptographicMode
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.util.Result
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import java.security.InvalidKeyException
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/DefaultWrappedKeyRepository.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/common/data/repository/DefaultWrappedKeyRepository.kt
similarity index 83%
rename from app/src/main/kotlin/de/davis/keygo/auth/data/repository/DefaultWrappedKeyRepository.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/common/data/repository/DefaultWrappedKeyRepository.kt
index 48396bc9..967e2d75 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/data/repository/DefaultWrappedKeyRepository.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/common/data/repository/DefaultWrappedKeyRepository.kt
@@ -1,7 +1,7 @@
-package de.davis.keygo.auth.data.repository
+package de.davis.keygo.core.identity.common.data.repository
import androidx.datastore.core.DataStore
-import de.davis.keygo.auth.domain.repository.WrappedKeyRepository
+import de.davis.keygo.core.identity.common.domain.repository.WrappedKeyRepository
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/factory/CipherFactory.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/CipherFactory.kt
similarity index 76%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/factory/CipherFactory.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/CipherFactory.kt
index 97aa2df1..0cc8ad27 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/factory/CipherFactory.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/CipherFactory.kt
@@ -1,9 +1,9 @@
-package de.davis.keygo.auth.domain.factory
+package de.davis.keygo.core.identity.common.domain
-import de.davis.keygo.auth.domain.model.CryptographicMode
-import de.davis.keygo.auth.domain.model.CryptographyError
-import de.davis.keygo.core.domain.Result
import de.davis.keygo.core.domain.model.crypto.AesKey
+import de.davis.keygo.core.identity.common.domain.model.CryptographicMode
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.util.Result
import kotlinx.coroutines.Dispatchers
import javax.crypto.Cipher
import kotlin.coroutines.CoroutineContext
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricWrappedKeyData.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/model/BiometricWrappedKeyData.kt
similarity index 92%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricWrappedKeyData.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/model/BiometricWrappedKeyData.kt
index e191d1de..de22a330 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/BiometricWrappedKeyData.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/model/BiometricWrappedKeyData.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.auth.domain.model
+package de.davis.keygo.core.identity.common.domain.model
data class BiometricWrappedKeyData(
val wrappedKey: ByteArray,
@@ -23,4 +23,4 @@ data class BiometricWrappedKeyData(
}
fun isValid(): Boolean = wrappedKey.isNotEmpty() && iv.isNotEmpty()
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/CryptographicMode.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/model/CryptographicMode.kt
similarity index 68%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/model/CryptographicMode.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/model/CryptographicMode.kt
index 11bcaa3b..e42542ba 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/CryptographicMode.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/model/CryptographicMode.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.auth.domain.model
+package de.davis.keygo.core.identity.common.domain.model
sealed interface CryptographicMode {
data object Wrap : CryptographicMode
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/CryptographyError.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/model/CryptographyError.kt
similarity index 88%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/model/CryptographyError.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/model/CryptographyError.kt
index 8f7a399a..3f1f159a 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/model/CryptographyError.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/model/CryptographyError.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.auth.domain.model
+package de.davis.keygo.core.identity.common.domain.model
sealed interface CryptographyError {
data object IllegalState : CryptographyError
diff --git a/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/repository/BiometricWrappedKeyRepository.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/repository/BiometricWrappedKeyRepository.kt
new file mode 100644
index 00000000..dd281639
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/repository/BiometricWrappedKeyRepository.kt
@@ -0,0 +1,5 @@
+package de.davis.keygo.core.identity.common.domain.repository
+
+import de.davis.keygo.core.identity.common.domain.model.BiometricWrappedKeyData
+
+typealias BiometricWrappedKeyRepository = WrappedKeyRepository
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/BiometricWrappedKeyRepository.kt b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/repository/WrappedKeyRepository.kt
similarity index 72%
rename from app/src/main/kotlin/de/davis/keygo/auth/domain/repository/BiometricWrappedKeyRepository.kt
rename to app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/repository/WrappedKeyRepository.kt
index d32ac90a..02c7ca37 100644
--- a/app/src/main/kotlin/de/davis/keygo/auth/domain/repository/BiometricWrappedKeyRepository.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/identity/common/domain/repository/WrappedKeyRepository.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.auth.domain.repository
+package de.davis.keygo.core.identity.common.domain.repository
interface WrappedKeyRepository {
diff --git a/app/src/main/kotlin/de/davis/keygo/core/presentation/LocalComposition.kt b/app/src/main/kotlin/de/davis/keygo/core/presentation/LocalComposition.kt
deleted file mode 100644
index 4891a33d..00000000
--- a/app/src/main/kotlin/de/davis/keygo/core/presentation/LocalComposition.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package de.davis.keygo.core.presentation
-
-import androidx.compose.runtime.staticCompositionLocalOf
-
-val LocalIsInSinglePaneMode = staticCompositionLocalOf {
- error("No LocalIsInSinglePaneMode provided")
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/presentation/LocalIsInSinglePaneMode.kt b/app/src/main/kotlin/de/davis/keygo/core/presentation/LocalIsInSinglePaneMode.kt
new file mode 100644
index 00000000..600e5344
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/core/presentation/LocalIsInSinglePaneMode.kt
@@ -0,0 +1,7 @@
+package de.davis.keygo.core.presentation
+
+import androidx.compose.runtime.staticCompositionLocalOf
+
+val LocalIsInSinglePaneMode = staticCompositionLocalOf {
+ false
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/presentation/component/StrengthIndicator.kt b/app/src/main/kotlin/de/davis/keygo/core/presentation/component/StrengthIndicator.kt
index a33af264..e6ff178b 100644
--- a/app/src/main/kotlin/de/davis/keygo/core/presentation/component/StrengthIndicator.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/presentation/component/StrengthIndicator.kt
@@ -24,22 +24,22 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.davis.keygo.R
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.Password
@SuppressLint("UnusedTransitionTargetStateParameter")
@Composable
fun StrengthIndicator(
- score: Score,
+ score: Password.Score,
forceCompact: Boolean = false,
) {
val targetTrackColor =
- if (score.isNone || score == Score.Excellent)
+ if (score.isNone || score == Password.Score.Excellent)
MaterialTheme.colorScheme.secondaryContainer
else
MaterialTheme.colorScheme.errorContainer
val targetIndicatorColor =
- if (score.isNone || score == Score.Excellent)
+ if (score.isNone || score == Password.Score.Excellent)
MaterialTheme.colorScheme.onSecondaryContainer
else
MaterialTheme.colorScheme.error
@@ -80,13 +80,13 @@ fun StrengthIndicator(
exit = shrinkVertically(),
) {
val text = when (score) {
- Score.None,
- Score.Ridiculous -> stringResource(R.string.password_strength_ridiculous)
+ Password.Score.None,
+ Password.Score.Ridiculous -> stringResource(R.string.password_strength_ridiculous)
- Score.Weak -> stringResource(R.string.password_strength_weak)
- Score.Moderate -> stringResource(R.string.password_strength_moderate)
- Score.Strong -> stringResource(R.string.password_strength_strong)
- Score.Excellent -> stringResource(R.string.password_strength_excellent)
+ Password.Score.Weak -> stringResource(R.string.password_strength_weak)
+ Password.Score.Moderate -> stringResource(R.string.password_strength_moderate)
+ Password.Score.Strong -> stringResource(R.string.password_strength_strong)
+ Password.Score.Excellent -> stringResource(R.string.password_strength_excellent)
}
Text(text = text)
diff --git a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/NavigationEvent.kt b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/NavigationEvent.kt
index 7650f727..40efa756 100644
--- a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/NavigationEvent.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/NavigationEvent.kt
@@ -1,7 +1,7 @@
package de.davis.keygo.core.presentation.model
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.generated.item.VaultItemType
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.generated.domain.model.VaultItemType
sealed interface NavigationEvent {
diff --git a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt
index 1a91c8a2..4aa62aaf 100644
--- a/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt
+++ b/app/src/main/kotlin/de/davis/keygo/core/presentation/model/RouteDestination.kt
@@ -38,7 +38,11 @@ sealed interface RouteDestination {
}
@Serializable
- data class Auth(val totpInfo: String? = null, val queries: String? = null) : RouteDestination {
+ data class Auth(
+ val totpInfo: String? = null,
+ val queries: String? = null,
+ val showBiometricPromptIfPossible: Boolean = true
+ ) : RouteDestination {
val uri
get() = if (!totpInfo.isNullOrBlank() && !queries.isNullOrBlank())
"otpauth://totp/$totpInfo?$queries"
diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/domain/usecase/FilterUseCase.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/domain/usecase/FilterUseCase.kt
index 4616a246..9daa8fad 100644
--- a/app/src/main/kotlin/de/davis/keygo/dashboard/domain/usecase/FilterUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/dashboard/domain/usecase/FilterUseCase.kt
@@ -1,18 +1,18 @@
package de.davis.keygo.dashboard.domain.usecase
-import de.davis.keygo.core.domain.model.VaultItem
+import de.davis.keygo.core.item.domain.model.lite.LiteItem
import de.davis.keygo.dashboard.domain.model.Filter
import org.koin.core.annotation.Single
@Single
class FilterUseCase {
- operator fun invoke(filter: Filter, vaultItems: List): List {
+ operator fun invoke(filter: Filter, namedItems: List): List {
return when (filter) {
is Filter.Alphanumerical -> {
when (filter.direction) {
- is Filter.Direction.Ascending -> vaultItems.sortedWith(compareByAlphanumeric { it.name })
- is Filter.Direction.Descending -> vaultItems.sortedWith(
+ is Filter.Direction.Ascending -> namedItems.sortedWith(compareByAlphanumeric { it.name })
+ is Filter.Direction.Descending -> namedItems.sortedWith(
compareByDescendingAlphanumeric { it.name }
)
}
diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt
index a8943127..729038d0 100644
--- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt
+++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardGraph.kt
@@ -19,7 +19,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
-import de.davis.keygo.core.domain.alias.ItemIdNone
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemIdNone
import de.davis.keygo.core.presentation.LocalIsInSinglePaneMode
import de.davis.keygo.core.presentation.ObserveAsEvents
import de.davis.keygo.core.presentation.model.NavigationEvent
@@ -35,7 +36,11 @@ import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun NavGraphBuilder.dashboardGraph(
- listNavigator: ThreePaneScaffoldNavigator
+ listNavigator: ThreePaneScaffoldNavigator,
+ onItemClicked: suspend (ItemId) -> Unit = { id ->
+ listNavigator.navigateTo(ThreePaneScaffoldRole.Primary, DetailType.View(id))
+ },
+ autoSelect: Boolean = true
) {
composable {
val isSinglePaneMode by remember(listNavigator.scaffoldDirective) {
@@ -68,13 +73,12 @@ fun NavGraphBuilder.dashboardGraph(
LaunchedEffect(uiState.openedItemId) {
if (uiState.openedItemId != ItemIdNone)
- listNavigator.navigateTo(
- ThreePaneScaffoldRole.Primary,
- DetailType.View(uiState.openedItemId)
- )
+ onItemClicked(uiState.openedItemId)
}
- LaunchedEffect(isSinglePaneMode, listNavigator.currentDestination) {
+ LaunchedEffect(autoSelect, isSinglePaneMode, listNavigator.currentDestination) {
+ if (!autoSelect) return@LaunchedEffect
+
if (!isSinglePaneMode && uiState.openedItemId == ItemIdNone) {
// If we are in multi pane mode and no item is opened, we request to open the first item
viewModel.onEvent(DashboardUIEvent.OpenFirstItem)
diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardList.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardList.kt
index e513ea89..5c788ce9 100644
--- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardList.kt
+++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardList.kt
@@ -46,11 +46,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.window.core.layout.WindowSizeClass
import de.davis.keygo.R
-import de.davis.keygo.core.domain.alias.ItemIdNone
-import de.davis.keygo.core.domain.model.Password
-import de.davis.keygo.core.domain.model.Score
-import de.davis.keygo.core.domain.model.VaultSearchResult
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
+import de.davis.keygo.core.item.domain.alias.ItemIdNone
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.Password
+import de.davis.keygo.core.item.domain.model.SecretData
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItemSearchResult
+import de.davis.keygo.core.item.generated.domain.model.VaultItemType
+import de.davis.keygo.core.item.generated.presentation.presentation
import de.davis.keygo.core.presentation.LocalIsInSinglePaneMode
import de.davis.keygo.core.presentation.component.KeyGoCard
import de.davis.keygo.core.presentation.component.KeyGoCardProp
@@ -58,8 +60,6 @@ import de.davis.keygo.dashboard.presentation.component.KeyGoLazyColumn
import de.davis.keygo.dashboard.presentation.component.SearchResult
import de.davis.keygo.dashboard.presentation.model.DashboardListUIState
import de.davis.keygo.dashboard.presentation.model.DashboardUIEvent
-import de.davis.keygo.generated.item.VaultItemType
-import de.davis.keygo.generated.item.getString
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
@@ -214,7 +214,7 @@ fun DashboardList(uiState: DashboardListUIState, onEvent: (DashboardUIEvent) ->
},
modifier = Modifier.fillMaxWidth()
) {
- Text(text = it.getString())
+ Text(text = it.presentation.first)
}
}
}
@@ -248,7 +248,7 @@ fun DashboardList(uiState: DashboardListUIState, onEvent: (DashboardUIEvent) ->
@Composable
private fun DashboardSearchResult(
- searchResult: ImmutableList,
+ searchResult: ImmutableList,
onCollapse: suspend () -> Unit,
onEvent: (DashboardUIEvent) -> Unit
) {
@@ -271,13 +271,19 @@ private fun PreviewContent(empty: Boolean = false) {
items = buildList {
repeat(25.takeIf { !empty } ?: 0) {
val p = Password(
- passwordId = it.toLong(),
+ id = it.toLong(),
username = "User $it",
- website = "Website",
- score = Score.Weak,
+ domainInfos = setOf(
+ DomainInfo(
+ passwordId = it.toLong(),
+ value = "www.example.com",
+ eTLD1 = "example.com"
+ )
+ ),
+ score = Password.Score.Weak,
vaultItemId = it.toLong(),
name = "${if (it >= 5) 'A' else 'B'} Item $it",
- encryptedData = CryptographicData.EMPTY,
+ encryptedData = SecretData.EMPTY_STRING,
note = "This is a note for item $it",
totpSecret = null
)
diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardViewModel.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardViewModel.kt
index f8927ae5..ce22a26a 100644
--- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardViewModel.kt
+++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/DashboardViewModel.kt
@@ -8,11 +8,11 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.alias.ItemIdNone
-import de.davis.keygo.core.domain.model.VaultSearchResult
-import de.davis.keygo.core.domain.repository.VaultItemRepository
import de.davis.keygo.core.domain.snackbar.SnackbarManager
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemIdNone
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItemSearchResult
+import de.davis.keygo.core.item.domain.repository.VaultItemRepository
import de.davis.keygo.core.presentation.model.RouteDestination
import de.davis.keygo.core.presentation.snackbar.ItemDeletedMessage
import de.davis.keygo.dashboard.domain.model.Filter
@@ -55,7 +55,7 @@ class DashboardViewModel(
private val submittedSearchQuery = MutableStateFlow("")
- private val searchResult = MutableStateFlow(emptyList())
+ private val searchResult = MutableStateFlow(emptyList())
private val filter = MutableStateFlow(Filter.Alphanumerical())
private val flaggedForDeletion = MutableStateFlow(setOf())
@@ -66,18 +66,18 @@ class DashboardViewModel(
) { flaggedForDeletion, searchResult ->
filterItems(
filter = Filter.Alphanumerical(),
- vaultItems = searchResult.filterNot { it.vaultItemId in flaggedForDeletion }
+ namedItems = searchResult.filterNot { it.vaultItemId in flaggedForDeletion }
)
}
private val repoFilteredItems = combine(
- vaultItemRepository.observeVaultItems(),
+ vaultItemRepository.observeLiteVaultItems(),
filter,
flaggedForDeletion
) { items, filter, flaggedForDeletion ->
filterItems(
filter = filter,
- vaultItems = items.filterNot { it.vaultItemId in flaggedForDeletion }
+ namedItems = items.filterNot { it.vaultItemId in flaggedForDeletion }
)
}
@@ -144,7 +144,7 @@ class DashboardViewModel(
.launchIn(viewModelScope)
}
- private suspend fun performSearch(query: String): List {
+ private suspend fun performSearch(query: String): List {
return vaultItemRepository.searchVaultItem(query)
}
@@ -199,7 +199,7 @@ class DashboardViewModel(
},
onDismiss = {
viewModelScope.launch {
- vaultItemRepository.deleteVaultItem(id)
+ vaultItemRepository.deleteItem(id)
// Inside this coroutine to ensure it only runs after the deletion
updateDeletionFlag(id = id, flag = false)
diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/component/KeyGoLazyColumn.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/component/KeyGoLazyColumn.kt
index 894db8e3..175864e5 100644
--- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/component/KeyGoLazyColumn.kt
+++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/component/KeyGoLazyColumn.kt
@@ -34,22 +34,22 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.alias.ItemIdNone
-import de.davis.keygo.core.domain.model.Password
-import de.davis.keygo.core.domain.model.Score
-import de.davis.keygo.core.domain.model.VaultItem
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemIdNone
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.Password
+import de.davis.keygo.core.item.domain.model.SecretData
+import de.davis.keygo.core.item.domain.model.lite.LiteItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
-private fun headerProducer(item: VaultItem) = item.name.firstOrNull() ?: ' '
+private fun headerProducer(item: LiteItem) = item.name.firstOrNull() ?: ' '
@Composable
fun KeyGoLazyColumn(
- items: ImmutableList,
+ items: ImmutableList,
selectedItemIds: ImmutableSet,
openedItemId: ItemId,
onDeleteRequest: (ItemId) -> Unit,
@@ -177,7 +177,7 @@ fun KeyGoLazyColumn(
@Composable
private fun LazyItemScope.KeyGoLazyItem(
- item: VaultItem,
+ item: LiteItem,
header: @Composable () -> Unit,
onDeleteRequest: () -> Unit,
modifier: Modifier = Modifier,
@@ -216,13 +216,19 @@ private fun KeyGoLazyColumnPreview() {
items = buildList {
repeat(25) {
val p = Password(
- passwordId = it.toLong(),
+ id = it.toLong(),
username = "User $it",
- website = "Website",
- score = Score.Weak,
+ domainInfos = setOf(
+ DomainInfo(
+ passwordId = it.toLong(),
+ value = "www.example.com",
+ eTLD1 = "example.com"
+ )
+ ),
+ score = Password.Score.Weak,
vaultItemId = it.toLong(),
name = "${if (it >= 5) 'A' else 'B'} Item $it",
- encryptedData = CryptographicData.EMPTY,
+ encryptedData = SecretData.EMPTY_STRING,
note = "This is a note for item $it",
totpSecret = null
)
diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/component/SearchResult.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/component/SearchResult.kt
index 60d0ba83..dba88d59 100644
--- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/component/SearchResult.kt
+++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/component/SearchResult.kt
@@ -22,16 +22,14 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.davis.keygo.R
-import de.davis.keygo.core.domain.model.VaultItem
-import de.davis.keygo.core.domain.model.VaultSearchResult
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItemSearchResult
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
fun SearchResult(
- searchResult: ImmutableList,
- onClick: (VaultItem) -> Unit,
+ searchResult: ImmutableList,
+ onClick: (LiteVaultItemSearchResult) -> Unit,
modifier: Modifier = Modifier,
cardColors: CardColors = CardDefaults.cardColors()
) {
@@ -70,8 +68,8 @@ private fun EmptySearchResult() {
@Composable
private fun SearchResultContent(
- searchResult: ImmutableList,
- onClick: (VaultItem) -> Unit,
+ searchResult: ImmutableList,
+ onClick: (LiteVaultItemSearchResult) -> Unit,
cardColors: CardColors
) {
LazyColumn(
@@ -113,19 +111,15 @@ private fun SearchResultPreview() {
Surface(modifier = Modifier.fillMaxSize()) {
SearchResult(
searchResult = persistentListOf(
- VaultSearchResult(
+ LiteVaultItemSearchResult(
vaultItemId = 1,
name = "Test",
- note = "Test",
- encryptedData = CryptographicData.EMPTY,
matchedName = true,
matchedNote = false
),
- VaultSearchResult(
+ LiteVaultItemSearchResult(
vaultItemId = 2,
name = "Test2",
- note = "Test2",
- encryptedData = CryptographicData.EMPTY,
matchedName = true,
matchedNote = true
),
diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardEvent.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardEvent.kt
index 9a4545ab..2b028d20 100644
--- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardEvent.kt
+++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardEvent.kt
@@ -1,6 +1,6 @@
package de.davis.keygo.dashboard.presentation.model
-import de.davis.keygo.generated.item.VaultItemType
+import de.davis.keygo.core.item.generated.domain.model.VaultItemType
sealed interface DashboardEvent {
data class CreateNewItemRequest(val itemType: VaultItemType) : DashboardEvent
diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardListUIState.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardListUIState.kt
index a3e42fa4..3f614588 100644
--- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardListUIState.kt
+++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardListUIState.kt
@@ -1,10 +1,10 @@
package de.davis.keygo.dashboard.presentation.model
import androidx.compose.foundation.text.input.TextFieldState
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.alias.ItemIdNone
-import de.davis.keygo.core.domain.model.VaultItem
-import de.davis.keygo.core.domain.model.VaultSearchResult
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemIdNone
+import de.davis.keygo.core.item.domain.model.lite.LiteItem
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItemSearchResult
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
@@ -12,8 +12,8 @@ import kotlinx.collections.immutable.persistentSetOf
data class DashboardListUIState(
val textFieldState: TextFieldState,
- val items: ImmutableList = persistentListOf(),
- val searchResult: ImmutableList = persistentListOf(),
+ val items: ImmutableList = persistentListOf(),
+ val searchResult: ImmutableList = persistentListOf(),
val selectedItemIds: ImmutableSet = persistentSetOf(),
val openedItemId: ItemId = ItemIdNone,
)
diff --git a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardUIEvent.kt b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardUIEvent.kt
index 6c407760..2d8fb666 100644
--- a/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardUIEvent.kt
+++ b/app/src/main/kotlin/de/davis/keygo/dashboard/presentation/model/DashboardUIEvent.kt
@@ -1,8 +1,8 @@
package de.davis.keygo.dashboard.presentation.model
-import de.davis.keygo.core.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.generated.domain.model.VaultItemType
import de.davis.keygo.dashboard.domain.model.Filter
-import de.davis.keygo.generated.item.VaultItemType
sealed interface DashboardUIEvent {
diff --git a/app/src/main/kotlin/de/davis/keygo/item/core/domain/model/Upsert.kt b/app/src/main/kotlin/de/davis/keygo/item/core/domain/model/Upsert.kt
deleted file mode 100644
index 03cbd78b..00000000
--- a/app/src/main/kotlin/de/davis/keygo/item/core/domain/model/Upsert.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package de.davis.keygo.item.core.domain.model
-
-import de.davis.keygo.core.domain.alias.ItemId
-
-sealed interface Upsert {
- val name: FieldUpdate
- val password: FieldUpdate
- val totpSecret: FieldUpdate
- val username: FieldUpdate
- val website: FieldUpdate
- val note: FieldUpdate
-
- data class Create(
- override val name: FieldUpdate,
- override val password: FieldUpdate,
- override val totpSecret: FieldUpdate,
- override val username: FieldUpdate,
- override val website: FieldUpdate,
- override val note: FieldUpdate,
- ) : Upsert
-
- data class Update(
- val vaultId: ItemId,
- override val name: FieldUpdate = keep(),
- override val password: FieldUpdate = keep(),
- override val totpSecret: FieldUpdate = keep(),
- override val username: FieldUpdate = keep(),
- override val website: FieldUpdate = keep(),
- override val note: FieldUpdate = keep(),
- ) : Upsert
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/core/domain/model/UpsertPassword.kt b/app/src/main/kotlin/de/davis/keygo/item/core/domain/model/UpsertPassword.kt
new file mode 100644
index 00000000..b6548e7b
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/item/core/domain/model/UpsertPassword.kt
@@ -0,0 +1,52 @@
+package de.davis.keygo.item.core.domain.model
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.DomainInfo
+
+@ConsistentCopyVisibility
+data class UpsertPassword private constructor(
+ val upsertType: UpsertType,
+ val name: FieldUpdate,
+ val password: FieldUpdate,
+ val totpSecret: FieldUpdate,
+ val username: FieldUpdate,
+ val domains: FieldUpdate>,
+ val note: FieldUpdate,
+) {
+ companion object {
+ fun create(
+ name: String,
+ password: String,
+ totpSecret: String? = null,
+ username: String? = null,
+ domains: Set = emptySet(),
+ note: String? = null,
+ ) = UpsertPassword(
+ upsertType = UpsertType.Create,
+ name = FieldUpdate.Set(name),
+ password = FieldUpdate.Set(password),
+ note = if (!note.isNullOrBlank()) FieldUpdate.Set(note) else FieldUpdate.Clear,
+ totpSecret = if (!totpSecret.isNullOrBlank()) FieldUpdate.Set(totpSecret) else FieldUpdate.Clear,
+ username = if (!username.isNullOrBlank()) FieldUpdate.Set(username) else FieldUpdate.Clear,
+ domains = if (domains.isNotEmpty()) FieldUpdate.Set(domains) else FieldUpdate.Clear,
+ )
+
+ fun update(
+ vaultId: ItemId,
+ name: FieldUpdate = keep(),
+ password: FieldUpdate = keep(),
+ totpSecret: FieldUpdate = keep(),
+ username: FieldUpdate = keep(),
+ domains: FieldUpdate> = keep(),
+ note: FieldUpdate = keep(),
+ ) = UpsertPassword(
+ upsertType = UpsertType.Update(vaultId),
+ name = name,
+ password = password,
+ note = note,
+ totpSecret = totpSecret,
+ username = username,
+ domains = domains,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/core/domain/model/UpsertType.kt b/app/src/main/kotlin/de/davis/keygo/item/core/domain/model/UpsertType.kt
new file mode 100644
index 00000000..60e04d81
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/item/core/domain/model/UpsertType.kt
@@ -0,0 +1,8 @@
+package de.davis.keygo.item.core.domain.model
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+
+sealed interface UpsertType {
+ data object Create : UpsertType
+ data class Update(val vaultItemId: ItemId) : UpsertType
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCase.kt b/app/src/main/kotlin/de/davis/keygo/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCase.kt
index 03129a22..a5dfd18a 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCase.kt
@@ -1,16 +1,17 @@
package de.davis.keygo.item.core.domain.usecase
-import de.davis.keygo.core.domain.Result
import de.davis.keygo.core.domain.crypto.CryptographicScopeProvider
+import de.davis.keygo.core.domain.crypto.encryptSecretData
import de.davis.keygo.core.domain.estimator.PasswordStrengthEstimator
-import de.davis.keygo.core.domain.mapFailure
-import de.davis.keygo.core.domain.model.Password
-import de.davis.keygo.core.domain.model.asTotpSecret
-import de.davis.keygo.core.domain.repository.PasswordRepository
-import de.davis.keygo.core.domain.usecase.UpsertVaultItem
+import de.davis.keygo.core.item.domain.model.Password
+import de.davis.keygo.core.item.domain.repository.PasswordRepository
+import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.mapFailure
import de.davis.keygo.item.core.domain.model.FieldUpdate
import de.davis.keygo.item.core.domain.model.PasswordError
-import de.davis.keygo.item.core.domain.model.Upsert
+import de.davis.keygo.item.core.domain.model.UpsertPassword
+import de.davis.keygo.item.core.domain.model.UpsertType
import de.davis.keygo.item.core.domain.model.getValue
import de.davis.keygo.item.core.domain.model.on
import de.davis.keygo.item.core.domain.model.onSet
@@ -24,7 +25,7 @@ import kotlin.contracts.ExperimentalContracts
class CreateNewOrUpdatePasswordUseCase(
private val cryptographicScopeProvider: CryptographicScopeProvider,
private val passwordRepository: PasswordRepository,
- private val upsertVaultItem: UpsertVaultItem,
+ private val upsertVaultItem: UpsertVaultItemUseCase,
private val passwordStrengthEstimator: PasswordStrengthEstimator
) {
@@ -36,9 +37,9 @@ class CreateNewOrUpdatePasswordUseCase(
is FieldUpdate.Set -> field.value.isNotBlank()
}
- private fun validate(upsert: Upsert): Set {
+ private fun validate(upsert: UpsertPassword): Set {
val errors = mutableSetOf()
- val allowKeep = upsert is Upsert.Update
+ val allowKeep = upsert.upsertType is UpsertType.Update
if (!isValid(field = upsert.name, allowKeep = allowKeep))
errors.add(PasswordError.BlankName)
@@ -49,7 +50,7 @@ class CreateNewOrUpdatePasswordUseCase(
return errors
}
- suspend operator fun invoke(upsert: Upsert): Result> =
+ suspend operator fun invoke(upsert: UpsertPassword): Result> =
coroutineScope {
val errors = validate(upsert)
if (errors.isNotEmpty())
@@ -59,7 +60,7 @@ class CreateNewOrUpdatePasswordUseCase(
val encryptedPassword = upsert.password.onSet { password ->
async {
cryptographicScopeProvider.scope {
- password.encodeToByteArray().encrypt()
+ password.encryptSecretData()
}
}
}
@@ -72,18 +73,18 @@ class CreateNewOrUpdatePasswordUseCase(
val totpSecret = upsert.totpSecret.onSet { totpSecret ->
async {
cryptographicScopeProvider.scope {
- totpSecret.encodeToByteArray().encrypt()
- }.asTotpSecret()
+ totpSecret.encryptSecretData()
+ }
}
}
- val updatedPassword = when (upsert) {
- is Upsert.Create -> {
+ val updatedPassword = when (upsert.upsertType) {
+ UpsertType.Create -> {
// Validation ensures that the values are not null
Password(
name = upsert.name.getValue() ?: "",
username = upsert.username.getValue(),
- website = upsert.website.getValue(),
+ domainInfos = upsert.domains.getValue().orEmpty(),
encryptedData = encryptedPassword!!.await(),
totpSecret = totpSecret?.await(),
score = passwordStrength!!.await(),
@@ -91,14 +92,15 @@ class CreateNewOrUpdatePasswordUseCase(
)
}
- is Upsert.Update -> {
- val dbPassword = passwordRepository.getVaultPasswordById(upsert.vaultId)
- ?: return@coroutineScope Result.Failure(setOf(PasswordError.InvalidVaultId))
+ is UpsertType.Update -> {
+ val dbPassword =
+ passwordRepository.getPasswordById(upsert.upsertType.vaultItemId)
+ ?: return@coroutineScope Result.Failure(setOf(PasswordError.InvalidVaultId))
dbPassword.copy(
name = upsert.name.withoutClearingOn(dbPassword.name),
username = upsert.username.on(dbPassword.username),
- website = upsert.website.on(dbPassword.website),
+ domainInfos = upsert.domains.on(dbPassword.domainInfos).orEmpty(),
encryptedData = encryptedPassword?.await() ?: dbPassword.encryptedData,
totpSecret = upsert.totpSecret.on(dbPassword.totpSecret, totpSecret),
score = passwordStrength?.await() ?: dbPassword.score,
diff --git a/app/src/main/kotlin/de/davis/keygo/item/core/presentation/component/ChipFormGroup.kt b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/component/ChipFormGroup.kt
new file mode 100644
index 00000000..e6845e7a
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/component/ChipFormGroup.kt
@@ -0,0 +1,210 @@
+package de.davis.keygo.item.core.presentation.component
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.InputTransformation
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.delete
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.InputChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldLabelScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import de.davis.keygo.item.create.presentation.component.FormGroup
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.toPersistentSet
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.filter
+
+@Composable
+fun ChipFormGroup(
+ title: String,
+ items: ImmutableSet,
+ containsForInput: (String) -> Boolean,
+ onSubmit: (Set) -> Unit,
+ onDelete: (T) -> Unit,
+ modifier: Modifier = Modifier,
+ label: @Composable (TextFieldLabelScope.() -> Unit)? = null,
+ prefix: @Composable (() -> Unit)? = null,
+ placeholder: @Composable (() -> Unit)? = null,
+ lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ delimiters: Set = setOf(',', ' '),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ chipBuilder: @Composable (T, Boolean) -> Unit
+) {
+ val state = rememberTextFieldState()
+ var lastSelected by rememberSaveable { mutableStateOf(false) }
+
+ val currentOnSubmit by rememberUpdatedState(onSubmit)
+ val currentContainsForInput by rememberUpdatedState(containsForInput)
+
+ fun handleText(text: String, keepLast: Boolean = true) {
+ lastSelected = false
+ if (keepLast && text.none { it in delimiters })
+ return
+
+ val items = text.split(*delimiters.toCharArray())
+ .filter { it.isNotBlank() }
+
+ val (itemsToSubmit, keep) = if (!keepLast || text.last() in delimiters) items to ""
+ else items.dropLast(1) to items.last()
+
+ val submissionSet = itemsToSubmit
+ .filterNot { currentContainsForInput(it) }
+ .toSet()
+ if (submissionSet.isNotEmpty()) currentOnSubmit(submissionSet)
+
+ state.setTextAndPlaceCursorAtEnd(keep)
+ }
+
+ val focused by interactionSource.collectIsFocusedAsState()
+ LaunchedEffect(Unit) {
+ snapshotFlow { focused }
+ .distinctUntilChanged()
+ .drop(1)
+ .filter { !it }
+ .collectLatest {
+ handleText(state.text.toString(), keepLast = false)
+ }
+ }
+
+
+ LaunchedEffect(state) {
+ snapshotFlow { state.text.toString() }.collectLatest { text ->
+ handleText(text)
+ }
+ }
+
+ val noLeadingDelimiters = remember(delimiters) {
+ InputTransformation {
+ var i = 0
+ while (i < length && charAt(i) in delimiters) i++
+ if (i > 0) delete(0, i)
+ }
+ }
+
+ FormGroup(
+ title = title
+ ) {
+ AnimatedContent(
+ targetState = items.isNotEmpty(),
+ transitionSpec = { (fadeIn() + expandVertically()) togetherWith (fadeOut() + shrinkVertically()) }
+ ) { hasItems ->
+ if (!hasItems) return@AnimatedContent
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items.forEachIndexed { index, item ->
+ key(item.hashCode()) {
+ chipBuilder(item, if (index == items.size - 1) lastSelected else false)
+ }
+ }
+ }
+ }
+
+ OutlinedTextField(
+ state = state,
+ modifier = modifier
+ .fillMaxWidth()
+ .onPreviewKeyEvent {
+ if (state.text.isNotBlank() || items.isEmpty()) return@onPreviewKeyEvent false
+
+ if (it.type == KeyEventType.KeyDown && it.key == Key.Backspace) {
+ if (lastSelected) onDelete(items.last())
+ lastSelected = !lastSelected
+
+ true
+ } else false
+ },
+ label = label,
+ prefix = prefix,
+ placeholder = placeholder,
+ lineLimits = lineLimits,
+ keyboardOptions = keyboardOptions,
+ inputTransformation = noLeadingDelimiters,
+ interactionSource = interactionSource
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ChipFormGroupPreview() {
+ val items = remember {
+ mutableStateListOf()
+ }
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxWidth()) {
+ ChipFormGroup(
+ title = "Chips",
+ items = items.toPersistentSet(),
+ containsForInput = {
+ it in items
+ },
+ onSubmit = {
+ items += it
+ },
+ onDelete = {
+ items -= it
+ },
+ label = {
+ Text("Add some chips")
+ },
+ prefix = {
+ Text("https://")
+ },
+ placeholder = {
+ Text("example.com")
+ }
+ ) { item, selected ->
+ InputChip(
+ selected = selected,
+ onClick = { },
+ label = { Text(text = item) },
+ trailingIcon = if (selected) {
+ { Icon(imageVector = Icons.Default.Close, contentDescription = null) }
+ } else null
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/core/presentation/component/ChipTextField.kt b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/component/ChipTextField.kt
new file mode 100644
index 00000000..503c85d8
--- /dev/null
+++ b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/component/ChipTextField.kt
@@ -0,0 +1,453 @@
+package de.davis.keygo.item.core.presentation.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.OutputTransformation
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.foundation.text.input.delete
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.foundation.text.selection.LocalTextSelectionColors
+import androidx.compose.material3.InputChip
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldColors
+import androidx.compose.material3.TextFieldLabelPosition
+import androidx.compose.material3.TextFieldLabelScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.takeOrElse
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import de.davis.keygo.core.presentation.component.minimizedLabelHalfHeight
+import kotlinx.coroutines.android.awaitFrame
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun ChipTextField(
+ items: List,
+ onNewItem: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ isError: Boolean = false,
+ label: @Composable (TextFieldLabelScope.() -> Unit)? = null,
+ labelPosition: TextFieldLabelPosition = TextFieldLabelPosition.Attached(),
+ textStyle: TextStyle = LocalTextStyle.current,
+ outputTransformation: OutputTransformation? = null,
+ contentPadding: PaddingValues = OutlinedTextFieldDefaults.contentPadding(),
+ colors: TextFieldColors = OutlinedTextFieldDefaults.colors(),
+ chipBuilder: @Composable (I) -> Unit
+) {
+ val state = rememberTextFieldState()
+ val interactionSource = remember { MutableInteractionSource() }
+ val lineLimits = TextFieldLineLimits.SingleLine
+ val labelPosition = when (labelPosition) {
+ is TextFieldLabelPosition.Attached -> TextFieldLabelPosition.Attached(
+ alwaysMinimize = items.isNotEmpty()
+ )
+
+ else -> labelPosition
+ }
+
+ val textColor = textStyle.color.takeOrElse {
+ val focused = interactionSource.collectIsFocusedAsState().value
+ with(colors) {
+ when {
+ !enabled -> disabledTextColor
+ isError -> errorTextColor
+ focused -> focusedTextColor
+ else -> unfocusedTextColor
+ }
+ }
+ }
+ val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
+
+ CompositionLocalProvider(
+ LocalTextSelectionColors provides colors.textSelectionColors
+ ) {
+ BasicTextField(
+ state = state,
+ modifier = modifier
+ .then(
+ if (label != null && labelPosition !is TextFieldLabelPosition.Above) {
+ Modifier
+ // Merge semantics at the beginning of the modifier chain to ensure
+ // padding is considered part of the text field.
+ .semantics(mergeDescendants = true) {}
+ .padding(top = minimizedLabelHalfHeight())
+ } else {
+ Modifier
+ }
+ )
+ .defaultMinSize(
+ minWidth = OutlinedTextFieldDefaults.MinWidth,
+ minHeight = OutlinedTextFieldDefaults.MinHeight,
+ )
+ .onPreviewKeyEvent {
+ if (it.key == Key.Comma) {
+ if (state.text.removePrefix(",").isNotEmpty()) {
+ onNewItem(state.text.toString())
+ }
+ state.clearText()
+ true
+ } else false
+ },
+ textStyle = mergedTextStyle,
+ cursorBrush = SolidColor(if (isError) colors.errorCursorColor else colors.cursorColor),
+ interactionSource = interactionSource,
+ outputTransformation = outputTransformation,
+ lineLimits = lineLimits,
+ decorator = { inner ->
+ OutlinedTextFieldDefaults.decorator(
+ state = state,
+ enabled = true,
+ lineLimits = lineLimits,
+ outputTransformation = outputTransformation,
+ interactionSource = interactionSource,
+ label = label,
+ labelPosition = labelPosition,
+ contentPadding = contentPadding,
+ colors = colors,
+ ).Decoration {
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(interactionSource = interactionSource, null) {},
+ verticalArrangement = Arrangement.Center,
+ itemVerticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items.forEach {
+ chipBuilder(it)
+ }
+
+ Box(
+ modifier = Modifier.weight(1f, fill = false),
+ ) {
+ inner()
+ }
+ }
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun ChipTextField2(
+ items: List,
+ onNewItem: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ isError: Boolean = false,
+ label: @Composable (TextFieldLabelScope.() -> Unit)? = null,
+ labelPosition: TextFieldLabelPosition = TextFieldLabelPosition.Attached(),
+ textStyle: TextStyle = LocalTextStyle.current,
+ outputTransformation: OutputTransformation? = null,
+ contentPadding: PaddingValues = OutlinedTextFieldDefaults.contentPadding(),
+ colors: TextFieldColors = OutlinedTextFieldDefaults.colors(),
+ chipBuilder: @Composable (I) -> Unit,
+
+ // === New ergonomic params (all have sensible defaults) ===
+ delimiters: Set = setOf(',', ';', '\n'),
+ commitOnImeAction: Boolean = true,
+ imeAction: ImeAction = ImeAction.Done,
+ placeholder: (@Composable () -> Unit)? = null,
+ chipSpacing: Dp = 8.dp,
+ normalize: (String) -> String = { it.trim().trimStart(*delimiters.toCharArray()) },
+ allowDuplicates: Boolean = true,
+ isDuplicate: (existing: I, token: String) -> Boolean = { _, _ -> false },
+ onRemoveLastItem: (() -> Unit)? = null,
+) {
+ val state = rememberTextFieldState()
+ val interactionSource = remember { MutableInteractionSource() }
+ val focusRequester = remember { FocusRequester() }
+ val focusManager = LocalFocusManager.current
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ val scope = rememberCoroutineScope()
+
+ // Keep the label minimized if we have chips.
+ val computedLabelPosition = when (labelPosition) {
+ is TextFieldLabelPosition.Attached ->
+ TextFieldLabelPosition.Attached(alwaysMinimize = items.isNotEmpty())
+
+ else -> labelPosition
+ }
+
+ // Resolve text color from the provided colors the same way you did.
+ val focused by interactionSource.collectIsFocusedAsState()
+ val baseTextColor = textStyle.color.takeOrElse {
+ with(colors) {
+ when {
+ !enabled -> disabledTextColor
+ isError -> errorTextColor
+ focused -> focusedTextColor
+ else -> unfocusedTextColor
+ }
+ }
+ }
+ val mergedTextStyle = textStyle.merge(TextStyle(color = baseTextColor))
+
+ // --- Helpers -------------------------------------------------------------------------------
+
+ fun commitToken(raw: String) {
+ val token = normalize(raw)
+ if (token.isEmpty()) return
+ val isDup = !allowDuplicates && items.any { isDuplicate(it, token) }
+ if (!isDup) onNewItem(token)
+ }
+
+ fun commitAllFrom(input: String): String {
+ // Split by delimiters but keep the trailing partial (if any).
+ val acc = StringBuilder()
+ var partial = StringBuilder()
+ for (c in input) {
+ if (c in delimiters) {
+ commitToken(partial.toString())
+ partial = StringBuilder()
+ } else {
+ partial.append(c)
+ }
+ }
+ // Return the leftover partial (not yet committed).
+ return partial.toString()
+ }
+
+ fun commitCurrentAndClear() {
+ val leftover = normalize(state.text.toString())
+ if (leftover.isNotEmpty()) {
+ commitToken(leftover)
+ state.clearText()
+ }
+ }
+
+ // Observe the text as it changes and consume tokens whenever a delimiter appears.
+ LaunchedEffect(state, items, delimiters, allowDuplicates) {
+ snapshotFlow { state.text.toString() }
+ .collect { text ->
+ // Fast check: skip if no delimiter present.
+ if (text.any { it in delimiters }) {
+ val remaining = commitAllFrom(text)
+ if (remaining != text) {
+ state.edit {
+ delete(0, length)
+ append(remaining)
+ }
+ }
+ }
+ }
+ }
+
+ // Keep caret visible as chips wrap.
+ LaunchedEffect(focused) {
+ if (focused) {
+ // A tiny delay lets layout settle before requesting visibility.
+ scope.launch {
+ //delay(10)
+ awaitFrame()
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+ }
+
+ CompositionLocalProvider(
+ LocalTextSelectionColors provides colors.textSelectionColors
+ ) {
+ BasicTextField(
+ state = state,
+ enabled = enabled,
+ textStyle = mergedTextStyle,
+ cursorBrush = SolidColor(if (isError) colors.errorCursorColor else colors.cursorColor),
+ interactionSource = interactionSource,
+ outputTransformation = outputTransformation,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ keyboardOptions = KeyboardOptions(imeAction = imeAction),
+ onKeyboardAction = {
+ commitCurrentAndClear()
+ },
+ modifier = modifier
+ // Merge semantics early so padding is included.
+ .then(
+ if (label != null && computedLabelPosition !is TextFieldLabelPosition.Above) {
+ Modifier
+ .semantics(mergeDescendants = true) {}
+ .padding(top = minimizedLabelHalfHeight())
+ } else {
+ Modifier
+ }
+ )
+ .defaultMinSize(
+ minWidth = OutlinedTextFieldDefaults.MinWidth,
+ minHeight = OutlinedTextFieldDefaults.MinHeight,
+ )
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ // Commit whatever is typed when the user leaves the field.
+ if (!it.isFocused) commitCurrentAndClear()
+ }
+ .onPreviewKeyEvent { event ->
+ when {
+ // Hardware comma / semicolon / enter still supported as a convenience.
+ event.key == Key.Comma || event.key == Key.Semicolon -> {
+ commitCurrentAndClear()
+ true
+ }
+ // Backspace removes last chip when the field is empty.
+ event.key == Key.Backspace && state.text.isEmpty() && onRemoveLastItem != null -> {
+ onRemoveLastItem()
+ true
+ }
+
+ else -> false
+ }
+ },
+ decorator = { inner ->
+ OutlinedTextFieldDefaults
+ .decorator(
+ state = state,
+ enabled = enabled,
+ isError = isError,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ outputTransformation = outputTransformation,
+ interactionSource = interactionSource,
+ label = label,
+ labelPosition = computedLabelPosition,
+ contentPadding = contentPadding,
+ colors = colors,
+ )
+ .Decoration {
+ // Click anywhere in the container to focus the text field.
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clipToBounds()
+ .bringIntoViewRequester(bringIntoViewRequester)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null
+ ) { focusRequester.requestFocus() },
+ verticalArrangement = Arrangement.Center,
+ itemVerticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(chipSpacing)
+ ) {
+ items.forEach { chipBuilder(it) }
+
+ // Placeholder when empty.
+ if (items.isEmpty() && state.text.isEmpty() && enabled) {
+ if (placeholder != null) {
+ Box(
+ modifier = Modifier
+ .padding(end = 2.dp) // tiny gap before caret
+ ) { placeholder() }
+ }
+ }
+
+ // The input itself takes the remaining line space.
+ Box(modifier = Modifier.weight(1f, fill = false)) {
+ inner()
+ }
+ }
+ }
+ }
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ChipTextFieldEmptyPreview() {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxWidth()) {
+ val items = remember {
+ mutableStateListOf()
+ }
+ ChipTextField2(
+ items = items,
+ onNewItem = {
+ items.add(it)
+ },
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ label = {
+ Text(text = "Domains")
+ },
+ chipBuilder = {
+ InputChip(
+ selected = false,
+ onClick = {},
+ label = { Text(it) },
+ )
+ }
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ChipTextFieldWithItemsPreview() {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxWidth()) {
+ val items = remember {
+ mutableStateListOf("example.com", "example.org")
+ }
+ ChipTextField(
+ items = items,
+ onNewItem = {
+ items.add(it)
+ },
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ label = {
+ Text(text = "Domains")
+ }
+ ) {
+ InputChip(
+ selected = false,
+ onClick = {},
+ label = { Text(it) },
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/core/presentation/model/DetailPaneInformation.kt b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/model/DetailPaneInformation.kt
index 93064d13..cb1e5d10 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/core/presentation/model/DetailPaneInformation.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/model/DetailPaneInformation.kt
@@ -1,8 +1,20 @@
package de.davis.keygo.item.core.presentation.model
-import de.davis.keygo.core.domain.model.VaultItem
+import kotlinx.serialization.Serializable
sealed interface DetailPaneInformation {
data class InitByDetailType(val detailType: DetailType) : DetailPaneInformation
- data class CreateRaw(val vaultItem: VaultItem) : DetailPaneInformation
+
+ @Serializable
+ sealed interface CreateRaw : DetailPaneInformation {
+ val name: String
+
+ @Serializable
+ data class Password(
+ override val name: String,
+ val password: String,
+ val username: String,
+ val url: String?,
+ ) : CreateRaw
+ }
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/core/presentation/model/DetailType.kt b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/model/DetailType.kt
index 9f642142..5d465769 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/core/presentation/model/DetailType.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/model/DetailType.kt
@@ -1,8 +1,8 @@
package de.davis.keygo.item.core.presentation.model
import android.os.Parcelable
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.generated.item.VaultItemType
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.generated.domain.model.VaultItemType
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
diff --git a/app/src/main/kotlin/de/davis/keygo/item/core/presentation/password/model/FieldType.kt b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/password/model/FieldType.kt
index d9e1774a..61f8cade 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/core/presentation/password/model/FieldType.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/core/presentation/password/model/FieldType.kt
@@ -5,6 +5,6 @@ enum class FieldType(val isSensitive: Boolean = false) {
Password(isSensitive = true),
Totp,
Username,
- Website,
+ Domain,
Note
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/EditVaulltItemScreen.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/EditVaulltItemScreen.kt
index 715ad8e3..ea732060 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/EditVaulltItemScreen.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/EditVaulltItemScreen.kt
@@ -1,14 +1,11 @@
package de.davis.keygo.item.create.presentation
import androidx.compose.runtime.Composable
-import de.davis.keygo.core.domain.model.Password
-import de.davis.keygo.core.domain.model.VaultItem
-import de.davis.keygo.core.domain.model.VaultSearchResult
+import de.davis.keygo.core.item.generated.domain.model.VaultItemType
+import de.davis.keygo.core.presentation.model.NavigationEvent
import de.davis.keygo.item.core.presentation.model.DetailPaneInformation
import de.davis.keygo.item.core.presentation.model.DetailPaneInformation.CreateRaw
import de.davis.keygo.item.core.presentation.model.DetailPaneInformation.InitByDetailType
-import de.davis.keygo.core.presentation.model.NavigationEvent
-import de.davis.keygo.generated.item.VaultItemType
import de.davis.keygo.item.core.presentation.model.DetailType
import de.davis.keygo.item.create.presentation.password.PasswordScreen
@@ -23,7 +20,7 @@ fun EditVaultItemScreen(
navigate
)
- is CreateRaw -> ForVaultItemInstance(
+ is CreateRaw -> ForRawItem(
detailPaneInformation,
navigate
)
@@ -45,14 +42,11 @@ private fun ForType(info: InitByDetailType, navigate: (NavigationEvent) -> Unit)
}
@Composable
-private fun ForVaultItemInstance(item: CreateRaw, navigate: (NavigationEvent) -> Unit) {
- when (item.vaultItem) {
- is Password -> PasswordScreen(
+private fun ForRawItem(item: CreateRaw, navigate: (NavigationEvent) -> Unit) {
+ when (item) {
+ is CreateRaw.Password -> PasswordScreen(
detailPaneInformation = item,
navigate = navigate
)
-
- is VaultItem.Basic,
- is VaultSearchResult -> throw IllegalArgumentException("Not supported")
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/component/OverrideTotpDialog.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/component/OverrideTotpDialog.kt
index 35bf7795..1f2e4169 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/component/OverrideTotpDialog.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/component/OverrideTotpDialog.kt
@@ -147,7 +147,7 @@ private fun SelectItemForTotpModificationDialogPreview() {
selected = false
),
OverrideTotpField(
- fieldType = FieldType.Website,
+ fieldType = FieldType.Domain,
before = "oldWebsite",
after = "newWebsite",
selected = true
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/component/SelectItemForTotpModificationDialog.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/component/SelectItemForTotpModificationDialog.kt
index 75047e33..b1beffc2 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/component/SelectItemForTotpModificationDialog.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/component/SelectItemForTotpModificationDialog.kt
@@ -20,12 +20,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.davis.keygo.R
-import de.davis.keygo.core.domain.model.Password
-import de.davis.keygo.core.domain.model.Score
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.lite.LitePassword
import de.davis.keygo.core.presentation.theme.KeyGoTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -33,8 +36,8 @@ import kotlinx.collections.immutable.persistentListOf
@Composable
fun SelectItemForTotpModificationDialog(
onDismissRequest: () -> Unit,
- items: ImmutableList,
- onItemClicked: (Password) -> Unit,
+ items: ImmutableList,
+ onItemClicked: (LitePassword) -> Unit,
onCreateNew: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -84,9 +87,26 @@ fun SelectItemForTotpModificationDialog(
Text(text = stringResource(R.string.list_entry, it))
}
- item.website?.let {
- Text(text = stringResource(R.string.list_entry, it))
- }
+ Text(
+ text = buildAnnotatedString {
+ item.domains.take(3).joinToString { it.value }.also {
+ append(stringResource(R.string.list_entry, it))
+ }
+
+ if (item.domains.size <= 3)
+ return@buildAnnotatedString
+
+ append(", ")
+ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
+ append(
+ stringResource(
+ R.string.n_more,
+ item.domains.size - 3
+ )
+ )
+ }
+ }
+ )
}
)
}
@@ -108,26 +128,19 @@ private fun SelectItemForTotpModificationDialogPreview() {
onItemClicked = {},
onCreateNew = {},
items = persistentListOf(
- Password(
- passwordId = 1,
- username = "User 1",
- website = "Website",
- score = Score.Weak,
+ LitePassword(
vaultItemId = 1,
+ passwordId = 1,
name = "${if (1 >= 5) 'A' else 'B'} Item 1",
- encryptedData = CryptographicData.EMPTY,
- note = "This is a note for item 1",
- totpSecret = null
- ), Password(
- passwordId = 2,
- username = "User 2",
- website = "Website",
- score = Score.Weak,
+ username = "User 1",
+ domains = listOf(DomainInfo(1, "Website", "website.com")),
+ ),
+ LitePassword(
vaultItemId = 2,
+ passwordId = 2,
name = "${if (2 >= 5) 'A' else 'B'} Item 2",
- encryptedData = CryptographicData.EMPTY,
- note = "This is a note for item 2",
- totpSecret = null
+ username = "User 2",
+ domains = listOf(DomainInfo(12, "Website", "website.com")),
)
)
)
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/dialog/SelectItemContent.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/dialog/SelectItemContent.kt
index f8026bf4..dec746b9 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/dialog/SelectItemContent.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/dialog/SelectItemContent.kt
@@ -37,8 +37,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.davis.keygo.R
-import de.davis.keygo.generated.item.VaultItemType
-import de.davis.keygo.generated.item.getString
+import de.davis.keygo.core.item.generated.domain.model.VaultItemType
+import de.davis.keygo.core.item.generated.presentation.presentation
@Composable
fun SelectItemContent(onSelect: (VaultItemType) -> Unit) {
@@ -66,11 +66,12 @@ fun SelectItemContent(onSelect: (VaultItemType) -> Unit) {
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
+ val (text, icon) = it.presentation
Icon(
- imageVector = it.icon,
+ imageVector = icon,
contentDescription = null
)
- Text(text = it.getString())
+ Text(text = text)
}
}
}
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/GeneratePasswordContent.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/GeneratePasswordContent.kt
index 58918d3a..fd85c4fa 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/GeneratePasswordContent.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/GeneratePasswordContent.kt
@@ -36,7 +36,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.davis.keygo.R
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.Password
import de.davis.keygo.core.presentation.component.KeyGoCard
import de.davis.keygo.core.presentation.component.KeyGoCardProp
import de.davis.keygo.core.presentation.component.StrengthIndicator
@@ -264,7 +264,7 @@ private fun GeneratePasswordBottomSheetPreview() {
GeneratePasswordContent(
state = GeneratePasswordUiState(
generatedPassword = "p@ssw0rd".asUiPassword(),
- passwordStrength = Score.Ridiculous,
+ passwordStrength = Password.Score.Ridiculous,
showCaution = true,
)
)
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordContent.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordContent.kt
index 6f970b24..32b657ce 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordContent.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordContent.kt
@@ -6,7 +6,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AutoAwesome
@@ -17,6 +19,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.InputChip
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
@@ -31,14 +34,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.davis.keygo.R
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.Password
import de.davis.keygo.core.presentation.LocalIsInSinglePaneMode
import de.davis.keygo.core.presentation.component.KeyGoFormField
import de.davis.keygo.core.presentation.component.StrengthIndicator
import de.davis.keygo.core.presentation.theme.KeyGoTheme
+import de.davis.keygo.item.core.presentation.component.ChipFormGroup
import de.davis.keygo.item.create.presentation.component.FormGroup
import de.davis.keygo.item.create.presentation.component.KeyGoItemForm
import de.davis.keygo.item.create.presentation.component.OverrideTotpDialog
@@ -48,6 +55,7 @@ import de.davis.keygo.item.create.presentation.password.model.DialogState
import de.davis.keygo.item.create.presentation.password.model.PasswordUiEvent
import de.davis.keygo.item.create.presentation.password.model.PasswordUiState
import de.davis.keygo.totp.presentation.component.QRScanner
+import kotlinx.collections.immutable.persistentSetOf
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -99,6 +107,7 @@ fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) -> Unit)
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.padding(8.dp)
+ .imePadding()
.nestedScroll(scrollBehavior.nestedScrollConnection),
nameError = state.nameError,
nameExists = state.nameExists,
@@ -160,13 +169,40 @@ fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) -> Unit)
KeyGoFormField(
state = state.usernameTextFieldState,
- label = { Text(text = stringResource(R.string.username)) },
- placeholder = { Text(text = stringResource(R.string.username)) },
+ label = { Text(text = stringResource(R.string.login_identifier)) },
+ placeholder = { Text(text = stringResource(R.string.login_identifier)) },
)
- KeyGoFormField(
- state = state.websiteTextFieldState,
- label = { Text(text = stringResource(R.string.website)) },
- placeholder = { Text(text = stringResource(R.string.website)) },
+ }
+ }
+
+ item(key = "domain_information") {
+ ChipFormGroup(
+ title = stringResource(R.string.domain_information),
+ items = state.domains,
+ containsForInput = {
+ state.domains.any { domain -> domain.value == it }
+ },
+ onSubmit = {
+ onEvent(PasswordUiEvent.OnAddDomains(it))
+ },
+ onDelete = {
+ onEvent(PasswordUiEvent.OnDeleteDomain(it.value))
+ },
+ label = {
+ Text(text = stringResource(R.string.add_domains))
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Next
+ ),
+ prefix = {
+ Text(text = "https://")
+ }
+ ) { item, selected ->
+ InputChip(
+ selected = selected,
+ onClick = { /* TODO: maybe allow editing? but definitely removing */ },
+ label = { Text(text = item.value) }
)
}
}
@@ -249,7 +285,14 @@ private fun PasswordContentPreview() {
KeyGoTheme {
PasswordContent(
state = PasswordUiState(
- strengthScore = Score.Weak,
+ strengthScore = Password.Score.Weak,
+ domains = persistentSetOf(
+ DomainInfo(
+ passwordId = 0,
+ value = "example.com",
+ eTLD1 = "example.com"
+ )
+ ),
nameExists = true
),
onEvent = {}
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordViewModel.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordViewModel.kt
index 9aa845f5..105268f7 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordViewModel.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordViewModel.kt
@@ -6,26 +6,30 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.viewModelScope
import de.davis.keygo.R
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.alias.ItemIdNone
import de.davis.keygo.core.domain.crypto.CryptographicScopeProvider
+import de.davis.keygo.core.domain.crypto.decryptSecretData
import de.davis.keygo.core.domain.estimator.PasswordStrengthEstimator
-import de.davis.keygo.core.domain.getOrNull
-import de.davis.keygo.core.domain.model.Password
-import de.davis.keygo.item.core.presentation.model.DetailPaneInformation
import de.davis.keygo.core.domain.model.snackbar.SnackbarMessage
-import de.davis.keygo.core.domain.onFailure
-import de.davis.keygo.core.domain.onSuccess
-import de.davis.keygo.core.domain.repository.PasswordRepository
-import de.davis.keygo.core.domain.repository.VaultItemRepository
import de.davis.keygo.core.domain.snackbar.SnackbarManager
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemIdNone
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.repository.PasswordRepository
+import de.davis.keygo.core.item.domain.repository.VaultItemRepository
import de.davis.keygo.core.presentation.UIText
+import de.davis.keygo.core.presentation.UIText.ResourceString
import de.davis.keygo.core.presentation.model.InputFieldError
import de.davis.keygo.core.presentation.model.NavigationEvent
+import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver
+import de.davis.keygo.core.util.getOrNull
+import de.davis.keygo.core.util.onFailure
+import de.davis.keygo.core.util.onSuccess
import de.davis.keygo.item.core.domain.model.PasswordError
-import de.davis.keygo.item.core.domain.model.Upsert
+import de.davis.keygo.item.core.domain.model.UpsertPassword
import de.davis.keygo.item.core.domain.model.fieldUpdate
+import de.davis.keygo.item.core.domain.model.set
import de.davis.keygo.item.core.domain.usecase.CreateNewOrUpdatePasswordUseCase
+import de.davis.keygo.item.core.presentation.model.DetailPaneInformation
import de.davis.keygo.item.core.presentation.model.DetailType
import de.davis.keygo.item.core.presentation.password.model.FieldType
import de.davis.keygo.item.create.domain.PasswordGenerator
@@ -37,6 +41,7 @@ import de.davis.keygo.item.create.presentation.password.model.PasswordUiState
import de.davis.keygo.totp.domain.model.TotpSecretInformation
import de.davis.keygo.totp.domain.usecase.GetTotpSecretFromUrlUseCase
import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@@ -69,6 +74,7 @@ class PasswordViewModel(
private val createNewOrUpdatePassword: CreateNewOrUpdatePasswordUseCase,
private val snackbarManager: SnackbarManager,
private val getTotpSecret: GetTotpSecretFromUrlUseCase,
+ private val registrableDomainResolver: RegistrableDomainResolver
) : GeneratePasswordViewModel(passwordGenerator, passwordStrengthEstimator) {
private val nameTextFieldState = TextFieldState()
@@ -103,8 +109,7 @@ class PasswordViewModel(
snapshotFlow { nameTextFieldState.text }
.debounce(150.milliseconds)
.mapLatest { input ->
- vaultItemRepository.searchVaultItem(input.toString())
- .any { it.vaultItemId != itemId && it.name == input.toString() }
+ vaultItemRepository.doesNameExist(input.toString(), excludeId = itemId)
}
.distinctUntilChanged()
.onEach { exists ->
@@ -161,11 +166,30 @@ class PasswordViewModel(
is DetailType.View -> throw IllegalArgumentException("This VM cannot be used to view an item")
}
- is DetailPaneInformation.CreateRaw -> when (information.vaultItem) {
- is Password -> initWithId(ItemIdNone)
- else -> {
- Log.e(TAG, "Unsupported vault item type: ${information.vaultItem}")
- navigateUp()
+ is DetailPaneInformation.CreateRaw -> initWithRawItem(information)
+ }
+ }
+
+ private fun initWithRawItem(createRaw: DetailPaneInformation.CreateRaw) {
+ nameTextFieldState.setTextAndPlaceCursorAtEnd(createRaw.name)
+
+ when (createRaw) {
+ is DetailPaneInformation.CreateRaw.Password -> {
+ val domainInfo = createRaw.url?.let {
+ val eTLD1 = registrableDomainResolver.resolve(it)
+ DomainInfo(
+ value = it,
+ eTLD1 = eTLD1
+ )
+ }
+
+ passwordTextFieldState.setTextAndPlaceCursorAtEnd(createRaw.password)
+ _uiState.update {
+ it.copy(
+ usernameTextFieldState = TextFieldState(createRaw.username),
+ domains = setOfNotNull(domainInfo).toImmutableSet(),
+ updating = false
+ )
}
}
}
@@ -175,19 +199,19 @@ class PasswordViewModel(
this.itemId = itemId
if (itemId == ItemIdNone) return
- passwordRepository.observeVaultPasswordById(itemId)
+ passwordRepository.observePasswordById(itemId)
.onEach { password ->
coroutineScope {
val pwdDeferred = async {
cryptographicScopeProvider.scope {
- password.encryptedData.decrypt().decodeToString()
+ password.encryptedData.decryptSecretData()
}
}
val totpSecret = password.totpSecret?.let { totpSecret ->
async {
cryptographicScopeProvider.scope {
- totpSecret.encodedSecret.decrypt().decodeToString()
+ totpSecret.decryptSecretData()
}
}
}
@@ -199,7 +223,7 @@ class PasswordViewModel(
it.copy(
totpTextFieldState = TextFieldState(totpSecret?.await() ?: ""),
usernameTextFieldState = TextFieldState(password.username ?: ""),
- websiteTextFieldState = TextFieldState(password.website ?: ""),
+ domains = password.domainInfos.toImmutableSet(),
notesTextFieldState = TextFieldState(password.note ?: ""),
dialogState = DialogState.None,
updating = true
@@ -223,12 +247,13 @@ class PasswordViewModel(
}.onSuccess { secret ->
totpSecretInformation = secret
viewModelScope.launch {
- val matchedItems = passwordRepository.searchVaultPasswords(
- username = secret.accountName,
- website = secret.issuer
- )
+ val matchedItems = secret.issuer?.let {
+ registrableDomainResolver.resolve(it)
+ }?.let {
+ passwordRepository.getVaultPasswordsByTLD(etld1 = it)
+ }
- if (matchedItems.isEmpty()) {
+ if (matchedItems.isNullOrEmpty()) {
updateUiWithTotpSecretInfo(secret)
return@launch
}
@@ -251,20 +276,20 @@ class PasswordViewModel(
val state = _uiState.value
createNewOrUpdatePassword(
upsert = when (itemId == ItemIdNone) {
- true -> Upsert.Create(
- name = fieldUpdate(state.nameTextFieldState.text.toString()),
- username = fieldUpdate(state.usernameTextFieldState.text.toString()),
- website = fieldUpdate(state.websiteTextFieldState.text.toString()),
- password = fieldUpdate(state.passwordTextFieldState.text.toString()),
- totpSecret = fieldUpdate(state.totpTextFieldState.text.toString()),
- note = fieldUpdate(state.notesTextFieldState.text.toString())
+ true -> UpsertPassword.create(
+ name = state.nameTextFieldState.text.toString(),
+ username = state.usernameTextFieldState.text.toString(),
+ domains = state.domains,
+ password = state.passwordTextFieldState.text.toString(),
+ totpSecret = state.totpTextFieldState.text.toString(),
+ note = state.notesTextFieldState.text.toString()
)
- false -> Upsert.Update(
+ false -> UpsertPassword.update(
vaultId = itemId,
name = fieldUpdate(state.nameTextFieldState.text.toString()),
username = fieldUpdate(state.usernameTextFieldState.text.toString()),
- website = fieldUpdate(state.websiteTextFieldState.text.toString()),
+ domains = set(state.domains),
password = fieldUpdate(state.passwordTextFieldState.text.toString()),
totpSecret = fieldUpdate(state.totpTextFieldState.text.toString()),
note = fieldUpdate(state.notesTextFieldState.text.toString())
@@ -283,7 +308,7 @@ class PasswordViewModel(
if (failure.any { it is PasswordError.InvalidVaultId }) {
snackbarManager.sendMessage(
message = SnackbarMessage(
- message = UIText.ResourceString(R.string.invalid_vault_id)
+ message = ResourceString(R.string.invalid_vault_id)
)
)
}
@@ -403,18 +428,40 @@ class PasswordViewModel(
)
}
}
+
+ is PasswordUiEvent.OnAddDomains -> {
+ event.domains.forEach { domain ->
+ val registrableDomain = registrableDomainResolver.resolve(domain)
+ val info = DomainInfo(
+ passwordId = itemId,
+ value = domain,
+ eTLD1 = registrableDomain
+ )
+ _uiState.update {
+ val newList = it.domains + info
+ it.copy(domains = newList.toImmutableSet())
+ }
+ }
+ }
+
+ is PasswordUiEvent.OnDeleteDomain -> {
+ _uiState.update {
+ val newList = it.domains.filterNot { info -> info.value == event.value }
+ it.copy(domains = newList.toImmutableSet())
+ }
+ }
}
}
private fun List.applyToUi(overrideWith: OverrideTotpField.() -> String) {
val secretField = find { field -> field.fieldType == FieldType.Totp }
val usernameField = find { field -> field.fieldType == FieldType.Username }
- val websiteField = find { field -> field.fieldType == FieldType.Website }
+ val domainField = find { field -> field.fieldType == FieldType.Domain }
updateUiWithSpecificTotpSecretInfo(
secret = secretField?.overrideWith(),
- issuer = websiteField?.overrideWith(),
+ issuer = domainField?.overrideWith(),
accountName = usernameField?.overrideWith()
)
}
@@ -422,7 +469,7 @@ class PasswordViewModel(
private fun requestTotpSecretUpdate(secretInformation: TotpSecretInformation) {
val currentState = _uiState.value
val currentTotpSecret = currentState.totpTextFieldState.text.toString()
- val currentIssuer = currentState.websiteTextFieldState.text.toString()
+ val currentIssuers = currentState.domains
val currentAccountName = currentState.usernameTextFieldState.text.toString()
val newTotpSecret = secretInformation.secret
@@ -430,17 +477,16 @@ class PasswordViewModel(
val newAccountName = secretInformation.accountName
val isCurrentSecretSet = currentTotpSecret.isNotBlank()
- val isCurrentIssuerSet = currentIssuer.isNotBlank()
val isCurrentAccountNameSet = currentAccountName.isNotBlank()
val isCurrentTotpSecretSame = currentTotpSecret == newTotpSecret
- val isCurrentIssuerSame = newIssuer.isBlank() || currentIssuer == newIssuer
val isCurrentAccountNameSame = currentAccountName == newAccountName
val overridingFields = mutableSetOf()
val isOverridingTotpSecret = isCurrentSecretSet && !isCurrentTotpSecretSame
- val isOverridingIssuer = isCurrentIssuerSet && !isCurrentIssuerSame
+ val isAddingNewIssuer = currentIssuers
+ .none { it.value.contains(newIssuer, ignoreCase = true) }
val isOverridingAccountName = isCurrentAccountNameSet && !isCurrentAccountNameSame
if (isOverridingTotpSecret) {
@@ -453,16 +499,6 @@ class PasswordViewModel(
)
}
- if (isOverridingIssuer) {
- overridingFields.add(
- OverrideTotpField(
- fieldType = FieldType.Website,
- before = currentIssuer,
- after = newIssuer
- )
- )
- }
-
if (isOverridingAccountName) {
overridingFields.add(
OverrideTotpField(
@@ -475,7 +511,7 @@ class PasswordViewModel(
updateUiWithSpecificTotpSecretInfo(
secret = if (!isOverridingTotpSecret) newTotpSecret else null,
- issuer = if (!isOverridingIssuer) newIssuer else null,
+ issuer = if (isAddingNewIssuer) newIssuer else null,
accountName = if (!isOverridingAccountName) newAccountName else null,
closeDialog = false
)
@@ -509,7 +545,7 @@ class PasswordViewModel(
}
issuer?.let {
- currentState.websiteTextFieldState.setTextAndPlaceCursorAtEnd(it)
+ onEvent(PasswordUiEvent.OnAddDomains(setOf(it)))
}
accountName?.let {
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/DialogState.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/DialogState.kt
index 0b11fe49..6e0b130c 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/DialogState.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/DialogState.kt
@@ -1,11 +1,11 @@
package de.davis.keygo.item.create.presentation.password.model
-import de.davis.keygo.core.domain.model.Password
+import de.davis.keygo.core.item.domain.model.lite.LitePassword
import kotlinx.collections.immutable.ImmutableList
sealed interface DialogState {
data object None : DialogState
data object TotpParseError : DialogState
- data class SelectItemForModification(val items: ImmutableList) : DialogState
+ data class SelectItemForModification(val items: ImmutableList) : DialogState
data class OverrideTotp(val fields: ImmutableList) : DialogState
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/GeneratePasswordUiState.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/GeneratePasswordUiState.kt
index 408c7c71..cf058137 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/GeneratePasswordUiState.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/GeneratePasswordUiState.kt
@@ -2,11 +2,11 @@ package de.davis.keygo.item.create.presentation.password.model
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SliderState
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.Password
data class GeneratePasswordUiState(
val generatedPassword: UiPassword = UiPassword(""),
- val passwordStrength: Score = Score.None,
+ val passwordStrength: Password.Score = Password.Score.None,
@OptIn(ExperimentalMaterial3Api::class)
val sliderState: SliderState = SliderState(value = 10f, valueRange = 8f..100f),
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/PasswordUiEvent.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/PasswordUiEvent.kt
index c5d2db64..1529f895 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/PasswordUiEvent.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/PasswordUiEvent.kt
@@ -1,6 +1,6 @@
package de.davis.keygo.item.create.presentation.password.model
-import de.davis.keygo.core.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.item.core.presentation.password.model.FieldType
sealed interface PasswordUiEvent {
@@ -10,6 +10,9 @@ sealed interface PasswordUiEvent {
data object OnCloseBottomSheet : PasswordUiEvent
data object OnScanCodeRequest : PasswordUiEvent
+ data class OnDeleteDomain(val value: String) : PasswordUiEvent
+ data class OnAddDomains(val domains: Set) : PasswordUiEvent
+
data object OnTotpParseErrorDismiss : PasswordUiEvent
data class OnCodesScanned(val codes: List) : PasswordUiEvent
diff --git a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/PasswordUiState.kt b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/PasswordUiState.kt
index 4b3e936a..1eb9c05e 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/PasswordUiState.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/model/PasswordUiState.kt
@@ -2,8 +2,11 @@ package de.davis.keygo.item.create.presentation.password.model
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.Password
import de.davis.keygo.core.presentation.model.InputFieldError
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentSetOf
data class PasswordUiState(
val nameTextFieldState: TextFieldState = TextFieldState(),
@@ -11,9 +14,9 @@ data class PasswordUiState(
val passwordTextFieldState: TextFieldState = TextFieldState(),
val totpTextFieldState: TextFieldState = TextFieldState(),
val usernameTextFieldState: TextFieldState = TextFieldState(),
- val websiteTextFieldState: TextFieldState = TextFieldState(),
+ val domains: ImmutableSet = persistentSetOf(),
val nameExists: Boolean = false,
- val strengthScore: Score = Score.None,
+ val strengthScore: Password.Score = Password.Score.None,
val generatePasswordBottomSheetVisible: Boolean = false,
@OptIn(ExperimentalMaterial3Api::class)
val generatePasswordState: GeneratePasswordUiState = GeneratePasswordUiState(),
diff --git a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordContent.kt b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordContent.kt
index 5ea418f8..96dbcf9e 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordContent.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordContent.kt
@@ -20,7 +20,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.NoteAdd
import androidx.compose.material.icons.automirrored.filled.Notes
-import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AddLink
@@ -48,6 +47,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -61,7 +61,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.davis.keygo.R
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.Password
import de.davis.keygo.core.presentation.LocalIsInSinglePaneMode
import de.davis.keygo.core.presentation.component.KeyGoCard
import de.davis.keygo.core.presentation.component.KeyGoFormField
@@ -115,8 +116,8 @@ fun ViewPasswordContent(state: ViewPasswordState, onEvent: (ViewPasswordUiEvent)
val name = stringResource(R.string.name)
val password = stringResource(R.string.password)
val totp = stringResource(R.string.totp)
- val username = stringResource(R.string.username)
- val website = stringResource(R.string.website)
+ val username = stringResource(R.string.login_identifier)
+ val domains = stringResource(R.string.domains)
val note = stringResource(R.string.note)
var isPasswordHidden by rememberSaveable { mutableStateOf(true) }
@@ -214,22 +215,26 @@ fun ViewPasswordContent(state: ViewPasswordState, onEvent: (ViewPasswordUiEvent)
}
}
- if (state.website.isNotBlank()) {
+ if (state.domains.isNotEmpty()) {
entry(
- title = website,
+ title = domains,
leadingIcon = Icons.Default.Link,
- trailingContent = if (state.canOpenWebsite) {
- {
- IconButton(onClick = { onEvent(ViewPasswordUiEvent.OpenWebsite) }) {
- Icon(
- imageVector = Icons.AutoMirrored.Default.OpenInNew,
- contentDescription = stringResource(R.string.open_website_content_description)
+ ) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ state.domains.forEach {
+ key(it.value) {
+ AssistChip(
+ onClick = {
+ onEvent(ViewPasswordUiEvent.OpenWebsite(it.value))
+ },
+ label = { Text(text = it.value) }
)
}
}
- } else null
- ) {
- Text(text = state.website)
+ }
}
}
@@ -260,12 +265,11 @@ fun ViewPasswordContent(state: ViewPasswordState, onEvent: (ViewPasswordUiEvent)
onClick = { onEvent(ViewPasswordUiEvent.OnModifyFieldRequest(it)) }
)
}
- if (state.website.isBlank()) {
- AddChip(
- fieldType = FieldType.Website,
- onClick = { onEvent(ViewPasswordUiEvent.OnModifyFieldRequest(it)) }
- )
- }
+
+ AddChip(
+ fieldType = FieldType.Domain,
+ onClick = { onEvent(ViewPasswordUiEvent.OnModifyFieldRequest(it)) }
+ )
if (state.note.isBlank()) {
AddChip(
@@ -338,7 +342,7 @@ private fun FieldType.addLabel(): String {
FieldType.Password -> stringResource(R.string.password)
FieldType.Totp -> stringResource(R.string.add_totp)
FieldType.Username -> stringResource(R.string.add_username)
- FieldType.Website -> stringResource(R.string.add_website)
+ FieldType.Domain -> stringResource(R.string.add_domain)
FieldType.Note -> stringResource(R.string.add_note)
}
}
@@ -350,7 +354,7 @@ private fun FieldType.addIcon(): ImageVector {
FieldType.Password -> Icons.Default.Password
FieldType.Totp -> Icons.Default.MoreTime
FieldType.Username -> Icons.Default.PersonAdd
- FieldType.Website -> Icons.Default.AddLink
+ FieldType.Domain -> Icons.Default.AddLink
FieldType.Note -> Icons.AutoMirrored.Default.NoteAdd
}
}
@@ -392,16 +396,21 @@ private fun ViewPasswordContentPreview() {
state = ViewPasswordState(
name = "Password 1",
password = ObfuscatedString("Password"),
- passwordStrengthScore = Score.Ridiculous,
+ passwordStrengthScore = Password.Score.Ridiculous,
totpInformation = TotpInformation(
code = "123456",
validUntil = System.currentTimeMillis() + 30_000L,
maxLifetime = 30_000L
),
username = "Username 1",
- website = "example.com",
+ domains = setOf(
+ DomainInfo(
+ passwordId = 1,
+ value = "login.example.com",
+ eTLD1 = "example.com"
+ )
+ ),
note = "Note about the password or any additional information that might be useful.",
- canOpenWebsite = true,
),
onEvent = {}
)
@@ -420,10 +429,15 @@ private fun ViewPasswordContentModificationDialogPreview() {
state = ViewPasswordState(
name = "Password 1",
password = ObfuscatedString("Password"),
- passwordStrengthScore = Score.Ridiculous,
+ passwordStrengthScore = Password.Score.Ridiculous,
username = "Username 1",
- website = "example.com",
- canOpenWebsite = true,
+ domains = setOf(
+ DomainInfo(
+ passwordId = 1,
+ value = "login.example.com",
+ eTLD1 = "example.com"
+ )
+ ),
modificationDialog = ModificationDialog(
fieldType = FieldType.Name,
textFieldState = rememberTextFieldState()
diff --git a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordScreen.kt b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordScreen.kt
index c2ec2ac3..695007f1 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordScreen.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordScreen.kt
@@ -5,7 +5,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import de.davis.keygo.core.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.presentation.ObserveAsEvents
import de.davis.keygo.core.presentation.model.NavigationEvent
import org.koin.androidx.compose.koinViewModel
diff --git a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordViewModel.kt b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordViewModel.kt
index d98a0f9f..a265ea1f 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordViewModel.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/ViewPasswordViewModel.kt
@@ -3,18 +3,23 @@ package de.davis.keygo.item.viewing.presentation.password
import androidx.compose.foundation.text.input.TextFieldState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import de.davis.keygo.core.domain.alias.ItemId
-import de.davis.keygo.core.domain.alias.ItemIdNone
import de.davis.keygo.core.domain.crypto.CryptographicScopeProvider
-import de.davis.keygo.core.domain.onFailure
-import de.davis.keygo.core.domain.onSuccess
-import de.davis.keygo.core.domain.repository.PasswordRepository
+import de.davis.keygo.core.domain.crypto.decryptSecretData
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.alias.ItemIdNone
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.repository.PasswordRepository
+import de.davis.keygo.core.item.generated.domain.model.VaultItemType
import de.davis.keygo.core.presentation.model.InputFieldError
import de.davis.keygo.core.presentation.model.NavigationEvent
-import de.davis.keygo.generated.item.VaultItemType
+import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver
+import de.davis.keygo.core.util.onFailure
+import de.davis.keygo.core.util.onSuccess
import de.davis.keygo.item.core.domain.model.PasswordError
-import de.davis.keygo.item.core.domain.model.Upsert
+import de.davis.keygo.item.core.domain.model.UpsertPassword
import de.davis.keygo.item.core.domain.model.fieldUpdate
+import de.davis.keygo.item.core.domain.model.onSet
+import de.davis.keygo.item.core.domain.model.set
import de.davis.keygo.item.core.domain.usecase.CreateNewOrUpdatePasswordUseCase
import de.davis.keygo.item.core.presentation.password.model.FieldType
import de.davis.keygo.item.viewing.domain.WebsiteHandler
@@ -52,7 +57,8 @@ class ViewPasswordViewModel(
private val updatePassword: CreateNewOrUpdatePasswordUseCase,
private val isValidUrl: IsValidUrlUseCase,
private val websiteHandler: WebsiteHandler,
- private val totpGenerator: TotpGenerator
+ private val totpGenerator: TotpGenerator,
+ private val registrableDomainResolver: RegistrableDomainResolver
) : ViewModel() {
private val _modificationDialogState = MutableStateFlow(null)
@@ -63,18 +69,18 @@ class ViewPasswordViewModel(
.filter { it != ItemIdNone }
.distinctUntilChanged()
.flatMapLatest { id ->
- passwordRepository.observeVaultPasswordById(id).flatMapLatest { password ->
+ passwordRepository.observePasswordById(id).flatMapLatest { password ->
coroutineScope {
val obfuscatedString = async {
cryptographicScopeProvider.scope {
- password.encryptedData.decrypt().decodeToString()
+ password.encryptedData.decryptSecretData()
}.asObfuscatedString()
}
val totpSecret = password.totpSecret?.let { totpSecret ->
async {
cryptographicScopeProvider.scope {
- totpSecret.encodedSecret.decrypt()
+ totpSecret.decryptSecretData().encodeToByteArray()
}
}
}
@@ -85,9 +91,8 @@ class ViewPasswordViewModel(
password = obfuscatedString.await(),
passwordStrengthScore = password.score,
username = password.username.orEmpty(),
- website = password.website.orEmpty(),
+ domains = password.domainInfos,
note = password.note.orEmpty(),
- canOpenWebsite = isValidUrl(password.website.orEmpty()),
totpInformation = TotpInformation("", 0, 0),
)
@@ -126,8 +131,8 @@ class ViewPasswordViewModel(
navigationEventChannel.send(NavigationEvent.NavigateBack)
}
- ViewPasswordUiEvent.OpenWebsite -> {
- val url = state.value.website
+ is ViewPasswordUiEvent.OpenWebsite -> {
+ val url = event.domain
if (!isValidUrl(url))
return
@@ -157,7 +162,7 @@ class ViewPasswordViewModel(
FieldType.Password -> state.password.raw
FieldType.Totp -> "" // TOTP is not editable in this context
FieldType.Username -> state.username
- FieldType.Website -> state.website
+ FieldType.Domain -> "" // Only allow adding new domains, not editing existing ones
FieldType.Note -> state.note
}
@@ -177,32 +182,41 @@ class ViewPasswordViewModel(
viewModelScope.launch {
updatePassword(
when (dialog.fieldType) {
- FieldType.Name -> Upsert.Update(
+ FieldType.Name -> UpsertPassword.update(
vaultId = itemId,
name = newText
)
- FieldType.Password -> Upsert.Update(
+ FieldType.Password -> UpsertPassword.update(
vaultId = itemId,
password = newText
)
- FieldType.Totp -> Upsert.Update(
+ FieldType.Totp -> UpsertPassword.update(
vaultId = itemId,
totpSecret = newText
)
- FieldType.Username -> Upsert.Update(
+ FieldType.Username -> UpsertPassword.update(
vaultId = itemId,
username = newText
)
- FieldType.Website -> Upsert.Update(
- vaultId = itemId,
- website = newText
- )
-
- FieldType.Note -> Upsert.Update(
+ FieldType.Domain -> newText.onSet {
+ val eTLD1 = registrableDomainResolver.resolve(it)
+ val updatedDomains = state.value.domains + DomainInfo(
+ itemId,
+ it,
+ eTLD1
+ )
+
+ UpsertPassword.update(
+ vaultId = itemId,
+ domains = set(updatedDomains)
+ )
+ } ?: return@launch
+
+ FieldType.Note -> UpsertPassword.update(
vaultId = itemId,
note = newText
)
diff --git a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/model/ViewPasswordState.kt b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/model/ViewPasswordState.kt
index 336e5501..28da16a4 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/model/ViewPasswordState.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/model/ViewPasswordState.kt
@@ -1,17 +1,17 @@
package de.davis.keygo.item.viewing.presentation.password.model
-import de.davis.keygo.core.domain.model.Score
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.Password
import de.davis.keygo.totp.domain.model.TotpInformation
data class ViewPasswordState(
val name: String = "",
val password: ObfuscatedString = ObfuscatedString(""),
- val passwordStrengthScore: Score = Score.None,
+ val passwordStrengthScore: Password.Score = Password.Score.None,
val totpInformation: TotpInformation = TotpInformation("", 0, 0),
val username: String = "",
- val website: String = "",
+ val domains: Set = emptySet(),
val note: String = "",
- val canOpenWebsite: Boolean = false,
val modificationDialog: ModificationDialog? = null,
)
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/model/ViewPasswordUiEvent.kt b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/model/ViewPasswordUiEvent.kt
index 07160924..c20f182f 100644
--- a/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/model/ViewPasswordUiEvent.kt
+++ b/app/src/main/kotlin/de/davis/keygo/item/viewing/presentation/password/model/ViewPasswordUiEvent.kt
@@ -4,7 +4,7 @@ import de.davis.keygo.item.core.presentation.password.model.FieldType
sealed interface ViewPasswordUiEvent {
- data object OpenWebsite : ViewPasswordUiEvent
+ data class OpenWebsite(val domain: String) : ViewPasswordUiEvent
data object OnBackClick : ViewPasswordUiEvent
data object OnEditRequest : ViewPasswordUiEvent
diff --git a/app/src/main/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCase.kt b/app/src/main/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCase.kt
index 21c3975f..bc900aed 100644
--- a/app/src/main/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCase.kt
+++ b/app/src/main/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCase.kt
@@ -1,7 +1,7 @@
package de.davis.keygo.totp.domain.usecase
-import de.davis.keygo.core.domain.Result
-import de.davis.keygo.core.domain.getOrNull
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.getOrNull
import de.davis.keygo.totp.domain.model.Algorithm
import de.davis.keygo.totp.domain.model.TotpSecretInformation
import de.davis.keygo.totp.domain.model.TotpSecretUrlParseError
diff --git a/app/src/main/res/layout/autofill_menu.xml b/app/src/main/res/layout/autofill_menu.xml
new file mode 100644
index 00000000..7f62d9ad
--- /dev/null
+++ b/app/src/main/res/layout/autofill_menu.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 145fb314..61240555 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -20,10 +20,20 @@
Open website
Copy to clipboard
+ Suggest
+ Suggest this item
+
+
+ - “%1$s” will be suggested next time you sign in. This choice is stored on your device and can be changed on the item\'s detail screen.
+ - “%1$s” will be suggested next time you sign in on %2$s. This choice is stored on your device and can be changed on the item\'s detail screen.
+
+
+ Not now
+
Add
Add TOTP Secret
Add Username
- Add Website
+ Add Domain
Add Note
Add new Element
@@ -41,6 +51,7 @@
Password is incorrect!
Password does not match
Authenticate
+ Unlock %s
Create your access
Use biometrics
@@ -75,14 +86,16 @@
General Information
Additional Information
Password Information
+ Domain Information
Name
Enter Name
Note
Write your Note
- Username
- Website
+ Email, phone, or username
+ Domains
+ Add domains
Ridiculous
Weak
@@ -115,6 +128,7 @@
An unexpected database error has occurred: %s
Vault item ID can not be found
+ %d more
\u2022 %s
\u2022 Before: %s
\u2022 After: %s
@@ -127,4 +141,10 @@
Warning
Some items will have identical names.
+
+ Autofill Service
+ Fill anyway
+
+ Suspicious Activity
+ This app (%1$s) isn\'t a recognized browser. It is showing a website (%2$s). Autofilling here may give your credentials to this app. Only continue if you trust this app and the site.
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 7a8715de..6b0c4feb 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,5 +1,14 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/keygo_autofill_service.xml b/app/src/main/res/xml/keygo_autofill_service.xml
new file mode 100644
index 00000000..59a918de
--- /dev/null
+++ b/app/src/main/res/xml/keygo_autofill_service.xml
@@ -0,0 +1,278 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/kotlin/de/davis/keygo/auth/data/repository/BiometricKekRepositoryImplTest.kt b/app/src/test/kotlin/de/davis/keygo/auth/data/repository/BiometricKekRepositoryImplTest.kt
index 73fd7b5b..20343453 100644
--- a/app/src/test/kotlin/de/davis/keygo/auth/data/repository/BiometricKekRepositoryImplTest.kt
+++ b/app/src/test/kotlin/de/davis/keygo/auth/data/repository/BiometricKekRepositoryImplTest.kt
@@ -1,9 +1,10 @@
package de.davis.keygo.auth.data.repository
-import de.davis.keygo.auth.domain.model.KeyStoreError
-import de.davis.keygo.core.domain.getOrNull
-import de.davis.keygo.core.domain.isFailure
-import de.davis.keygo.core.domain.isSuccess
+import de.davis.keygo.core.identity.biometric.data.repository.BiometricKekRepositoryImpl
+import de.davis.keygo.core.identity.biometric.domain.model.KeyStoreError
+import de.davis.keygo.core.util.getOrNull
+import de.davis.keygo.core.util.isFailure
+import de.davis.keygo.core.util.isSuccess
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
diff --git a/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithPasswordUseCaseTest.kt b/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithPasswordUseCaseTest.kt
index ea4ddb2f..161025c0 100644
--- a/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithPasswordUseCaseTest.kt
+++ b/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithPasswordUseCaseTest.kt
@@ -1,16 +1,16 @@
package de.davis.keygo.auth.domain.usecase
-import de.davis.keygo.auth.domain.factory.CipherFactory
-import de.davis.keygo.auth.domain.model.CryptographyError
import de.davis.keygo.auth.domain.model.PasswordWrappedKeyData
import de.davis.keygo.auth.domain.repository.DeviceInfoRepository
import de.davis.keygo.auth.domain.repository.KeyDerivationRepository
import de.davis.keygo.auth.domain.repository.PasswordWrappedKeyRepository
-import de.davis.keygo.core.domain.Result
import de.davis.keygo.core.domain.Session
-import de.davis.keygo.core.domain.isFailure
-import de.davis.keygo.core.domain.isSuccess
import de.davis.keygo.core.domain.model.crypto.asAesKey
+import de.davis.keygo.core.identity.common.domain.CipherFactory
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.isFailure
+import de.davis.keygo.core.util.isSuccess
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
diff --git a/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/GetBiometricAvailabilityUseCaseTest.kt b/app/src/test/kotlin/de/davis/keygo/core/identity/biometric/usecase/GetBiometricAvailabilityUseCaseTest.kt
similarity index 81%
rename from app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/GetBiometricAvailabilityUseCaseTest.kt
rename to app/src/test/kotlin/de/davis/keygo/core/identity/biometric/usecase/GetBiometricAvailabilityUseCaseTest.kt
index 95de7680..627f71a8 100644
--- a/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/GetBiometricAvailabilityUseCaseTest.kt
+++ b/app/src/test/kotlin/de/davis/keygo/core/identity/biometric/usecase/GetBiometricAvailabilityUseCaseTest.kt
@@ -1,8 +1,9 @@
-package de.davis.keygo.auth.domain.usecase
+package de.davis.keygo.core.identity.biometric.usecase
-import de.davis.keygo.auth.data.repository.BiometricWrappedKeyRepository
-import de.davis.keygo.auth.domain.model.BiometricAvailability
-import de.davis.keygo.auth.domain.repository.BiometricKekRepository
+import de.davis.keygo.core.identity.biometric.domain.model.BiometricAvailability
+import de.davis.keygo.core.identity.biometric.domain.repository.BiometricKekRepository
+import de.davis.keygo.core.identity.biometric.domain.usecase.GetBiometricCryptoSetupAvailabilityUseCase
+import de.davis.keygo.core.identity.common.domain.repository.BiometricWrappedKeyRepository
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
diff --git a/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/PrepareBiometricCipherUseCaseTest.kt b/app/src/test/kotlin/de/davis/keygo/core/identity/biometric/usecase/PrepareBiometricCipherUseCaseTest.kt
similarity index 84%
rename from app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/PrepareBiometricCipherUseCaseTest.kt
rename to app/src/test/kotlin/de/davis/keygo/core/identity/biometric/usecase/PrepareBiometricCipherUseCaseTest.kt
index 6a422dbb..a9fefec4 100644
--- a/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/PrepareBiometricCipherUseCaseTest.kt
+++ b/app/src/test/kotlin/de/davis/keygo/core/identity/biometric/usecase/PrepareBiometricCipherUseCaseTest.kt
@@ -1,16 +1,17 @@
-package de.davis.keygo.auth.domain.usecase
-
-import de.davis.keygo.auth.data.repository.BiometricWrappedKeyRepository
-import de.davis.keygo.auth.domain.factory.CipherFactory
-import de.davis.keygo.auth.domain.model.BiometricWrappedKeyData
-import de.davis.keygo.auth.domain.model.CryptographicMode
-import de.davis.keygo.auth.domain.model.CryptographyError
-import de.davis.keygo.auth.domain.model.KeyStoreError
-import de.davis.keygo.auth.domain.repository.BiometricKekRepository
-import de.davis.keygo.core.domain.Result
-import de.davis.keygo.core.domain.isFailure
-import de.davis.keygo.core.domain.isSuccess
+package de.davis.keygo.core.identity.biometric.usecase
+
import de.davis.keygo.core.domain.model.crypto.asAesKey
+import de.davis.keygo.core.identity.biometric.domain.model.KeyStoreError
+import de.davis.keygo.core.identity.biometric.domain.repository.BiometricKekRepository
+import de.davis.keygo.core.identity.biometric.domain.usecase.PrepareBiometricCipherUseCase
+import de.davis.keygo.core.identity.common.domain.CipherFactory
+import de.davis.keygo.core.identity.common.domain.model.BiometricWrappedKeyData
+import de.davis.keygo.core.identity.common.domain.model.CryptographicMode
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.identity.common.domain.repository.BiometricWrappedKeyRepository
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.isFailure
+import de.davis.keygo.core.util.isSuccess
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
diff --git a/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithBiometricsUseCaseTest.kt b/app/src/test/kotlin/de/davis/keygo/core/identity/biometric/usecase/UnlockWithBiometricsUseCaseTest.kt
similarity index 81%
rename from app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithBiometricsUseCaseTest.kt
rename to app/src/test/kotlin/de/davis/keygo/core/identity/biometric/usecase/UnlockWithBiometricsUseCaseTest.kt
index f0b2007f..39c33782 100644
--- a/app/src/test/kotlin/de/davis/keygo/auth/domain/usecase/UnlockWithBiometricsUseCaseTest.kt
+++ b/app/src/test/kotlin/de/davis/keygo/core/identity/biometric/usecase/UnlockWithBiometricsUseCaseTest.kt
@@ -1,14 +1,15 @@
-package de.davis.keygo.auth.domain.usecase
+package de.davis.keygo.core.identity.biometric.usecase
-import de.davis.keygo.auth.data.repository.BiometricWrappedKeyRepository
-import de.davis.keygo.auth.domain.factory.CipherFactory
-import de.davis.keygo.auth.domain.model.BiometricWrappedKeyData
-import de.davis.keygo.auth.domain.model.CryptographyError
-import de.davis.keygo.core.domain.Result
import de.davis.keygo.core.domain.Session
-import de.davis.keygo.core.domain.isFailure
-import de.davis.keygo.core.domain.isSuccess
import de.davis.keygo.core.domain.model.crypto.asAesKey
+import de.davis.keygo.core.identity.biometric.domain.usecase.UnlockWithBiometricsUseCase
+import de.davis.keygo.core.identity.common.domain.CipherFactory
+import de.davis.keygo.core.identity.common.domain.model.BiometricWrappedKeyData
+import de.davis.keygo.core.identity.common.domain.model.CryptographyError
+import de.davis.keygo.core.identity.common.domain.repository.BiometricWrappedKeyRepository
+import de.davis.keygo.core.util.Result
+import de.davis.keygo.core.util.isFailure
+import de.davis.keygo.core.util.isSuccess
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.verify
diff --git a/app/src/test/kotlin/de/davis/keygo/dashboard/domain/usecase/FilterUseCaseTest.kt b/app/src/test/kotlin/de/davis/keygo/dashboard/domain/usecase/FilterUseCaseTest.kt
index 64995a7b..3c44f9d5 100644
--- a/app/src/test/kotlin/de/davis/keygo/dashboard/domain/usecase/FilterUseCaseTest.kt
+++ b/app/src/test/kotlin/de/davis/keygo/dashboard/domain/usecase/FilterUseCaseTest.kt
@@ -1,7 +1,7 @@
package de.davis.keygo.dashboard.domain.usecase
-import de.davis.keygo.core.domain.model.VaultItem
-import de.davis.keygo.core.domain.model.crypto.CryptographicData
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.lite.LiteItem
import de.davis.keygo.dashboard.domain.model.Filter
import de.davis.keygo.dashboard.domain.model.Filter.Direction
import kotlin.test.Test
@@ -13,17 +13,22 @@ class FilterUseCaseTest {
private var filterAsc: Filter = Filter.Alphanumerical(direction = Direction.Ascending)
private var filterDesc: Filter = Filter.Alphanumerical(direction = Direction.Descending)
+ data class TestLiteItem(
+ override val vaultItemId: ItemId = 0,
+ override val name: String,
+ ) : LiteItem
+
@Test
fun `sorts items with numeric suffixes alphanumerically`() {
val input = listOf(
- VaultItem.Basic(name = "AAA 10", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "AAA 8", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "AAA 9", encryptedData = CryptographicData.EMPTY, note = null),
+ TestLiteItem(name = "AAA 10"),
+ TestLiteItem(name = "AAA 8"),
+ TestLiteItem(name = "AAA 9"),
)
val expected = listOf(
- VaultItem.Basic(name = "AAA 8", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "AAA 9", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "AAA 10", encryptedData = CryptographicData.EMPTY, note = null),
+ TestLiteItem(name = "AAA 8"),
+ TestLiteItem(name = "AAA 9"),
+ TestLiteItem(name = "AAA 10"),
)
val actual = useCase(filterAsc, input)
@@ -33,14 +38,14 @@ class FilterUseCaseTest {
@Test
fun `sorts items with numeric suffixes alphanumerically desc`() {
val input = listOf(
- VaultItem.Basic(name = "AAA 10", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "AAA 8", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "AAA 9", encryptedData = CryptographicData.EMPTY, note = null),
+ TestLiteItem(name = "AAA 10"),
+ TestLiteItem(name = "AAA 8"),
+ TestLiteItem(name = "AAA 9"),
)
val expected = listOf(
- VaultItem.Basic(name = "AAA 10", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "AAA 9", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "AAA 8", encryptedData = CryptographicData.EMPTY, note = null),
+ TestLiteItem(name = "AAA 10"),
+ TestLiteItem(name = "AAA 9"),
+ TestLiteItem(name = "AAA 8"),
)
val actual = useCase(filterDesc, input)
@@ -50,16 +55,16 @@ class FilterUseCaseTest {
@Test
fun `sorts items with leading zeros and multi-digit numbers`() {
val input = listOf(
- VaultItem.Basic(name = "file2", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "file10", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "file1", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "file02", encryptedData = CryptographicData.EMPTY, note = null),
+ TestLiteItem(name = "file2"),
+ TestLiteItem(name = "file10"),
+ TestLiteItem(name = "file1"),
+ TestLiteItem(name = "file02"),
)
val expected = listOf(
- VaultItem.Basic(name = "file1", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "file2", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "file02", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "file10", encryptedData = CryptographicData.EMPTY, note = null)
+ TestLiteItem(name = "file1"),
+ TestLiteItem(name = "file2"),
+ TestLiteItem(name = "file02"),
+ TestLiteItem(name = "file10")
)
val actual = useCase(filterAsc, input)
@@ -69,16 +74,16 @@ class FilterUseCaseTest {
@Test
fun `sorts items with leading numbers`() {
val input = listOf(
- VaultItem.Basic(name = "2file", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "10file", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "1file", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "02file", encryptedData = CryptographicData.EMPTY, note = null),
+ TestLiteItem(name = "2file"),
+ TestLiteItem(name = "10file"),
+ TestLiteItem(name = "1file"),
+ TestLiteItem(name = "02file"),
)
val expected = listOf(
- VaultItem.Basic(name = "1file", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "2file", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "02file", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "10file", encryptedData = CryptographicData.EMPTY, note = null)
+ TestLiteItem(name = "1file"),
+ TestLiteItem(name = "2file"),
+ TestLiteItem(name = "02file"),
+ TestLiteItem(name = "10file")
)
val actual = useCase(filterAsc, input)
@@ -88,24 +93,24 @@ class FilterUseCaseTest {
@Test
fun `empty list returns empty list`() {
val actual = useCase(filterAsc, emptyList())
- assertEquals(emptyList(), actual)
+ assertEquals(emptyList(), actual)
}
@Test
fun `items without numeric parts are sorted lexically`() {
val input = listOf(
- VaultItem.Basic(name = "banana", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "Apple", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "apple", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "Banana", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "cherry", encryptedData = CryptographicData.EMPTY, note = null)
+ TestLiteItem(name = "banana"),
+ TestLiteItem(name = "Apple"),
+ TestLiteItem(name = "apple"),
+ TestLiteItem(name = "Banana"),
+ TestLiteItem(name = "cherry")
)
val expected = listOf(
- VaultItem.Basic(name = "Apple", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "apple", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "banana", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "Banana", encryptedData = CryptographicData.EMPTY, note = null),
- VaultItem.Basic(name = "cherry", encryptedData = CryptographicData.EMPTY, note = null)
+ TestLiteItem(name = "Apple"),
+ TestLiteItem(name = "apple"),
+ TestLiteItem(name = "banana"),
+ TestLiteItem(name = "Banana"),
+ TestLiteItem(name = "cherry")
)
val actual = useCase(filterAsc, input)
diff --git a/app/src/test/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCaseTest.kt b/app/src/test/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCaseTest.kt
index c47facdc..4abcc327 100644
--- a/app/src/test/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCaseTest.kt
+++ b/app/src/test/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCaseTest.kt
@@ -1,6 +1,6 @@
package de.davis.keygo.totp.domain.usecase
-import de.davis.keygo.core.domain.Result
+import de.davis.keygo.core.util.Result
import de.davis.keygo.totp.domain.model.Algorithm
import de.davis.keygo.totp.domain.model.TotpSecretUrlParseError
import org.junit.Assert.assertEquals
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/di/Koin.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/di/Koin.kt
index c2740e65..807f599e 100644
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/di/Koin.kt
+++ b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/di/Koin.kt
@@ -4,6 +4,7 @@ import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import de.davis.keygo.automation.processor.model.Options
import de.davis.keygo.automation.processor.util.GetClassName
+import de.davis.keygo.automation.processor.util.GetStringRes
import org.koin.core.context.startKoin
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
@@ -16,6 +17,7 @@ fun initiateKoin(logger: KSPLogger, codeGenerator: CodeGenerator, options: Optio
single { options }
singleOf(::GetClassName)
+ singleOf(::GetStringRes)
}.also(::modules)
}
}
\ No newline at end of file
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/handler/ItemHandler.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/handler/ItemHandler.kt
index 219eec6a..aa2da235 100644
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/handler/ItemHandler.kt
+++ b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/handler/ItemHandler.kt
@@ -1,53 +1,38 @@
package de.davis.keygo.automation.processor.handler
import com.google.devtools.ksp.KspExperimental
-import com.google.devtools.ksp.isAnnotationPresent
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.symbol.ClassKind
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.Modifier
-import com.squareup.kotlinpoet.AnnotationSpec
+import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FunSpec
-import com.squareup.kotlinpoet.KModifier
-import com.squareup.kotlinpoet.TypeName
-import com.squareup.kotlinpoet.ksp.toClassName
+import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
+import com.squareup.kotlinpoet.PropertySpec
+import com.squareup.kotlinpoet.STRING
+import com.squareup.kotlinpoet.asTypeName
+import com.squareup.kotlinpoet.buildCodeBlock
import de.davis.keygo.automation.processor.exception.NotFoundException
import de.davis.keygo.automation.processor.ext.getAnnotation
-import de.davis.keygo.automation.processor.kotlinpoet.FileBuilder
-import de.davis.keygo.automation.processor.kotlinpoet.dataClass
import de.davis.keygo.automation.processor.kotlinpoet.enum
import de.davis.keygo.automation.processor.kotlinpoet.file
import de.davis.keygo.automation.processor.model.Entry
-import de.davis.keygo.automation.processor.model.ForeignKey
-import de.davis.keygo.automation.processor.model.Index
-import de.davis.keygo.automation.processor.model.getOwnProperties
import de.davis.keygo.automation.processor.util.COMPOSABLE_CLASS_NAME
-import de.davis.keygo.automation.processor.util.Constants
-import de.davis.keygo.automation.processor.util.EMBEDDED_CLASS_NAME
-import de.davis.keygo.automation.processor.util.GetClassName
+import de.davis.keygo.automation.processor.util.GetStringRes
import de.davis.keygo.automation.processor.util.ICONS_DEFAULT_CLASS_NAME
import de.davis.keygo.automation.processor.util.IMAGE_VECTOR_CLASS_NAME
import de.davis.keygo.automation.processor.util.STRING_RESOURCE_MEMBER_NAME
-import de.davis.keygo.automation.processor.util.StringUtils.camelToSnakeCase
-import de.davis.keygo.automation.processor.util.StringUtils.isCamelCase
import de.davis.keygo.automation.processor.util.defaultIconMemberName
-import de.davis.keygo.automation.processor.util.primaryRoomKey
-import de.davis.keygo.automation.processor.util.roomColumnInfo
-import de.davis.keygo.automation.processor.util.roomEntity
-import de.davis.keygo.automation.processor.util.roomRelation
-import de.davis.keygo.automation.processor.util.stringRes
-import de.davis.keygo.processor.annotation.BasicModel
-import de.davis.keygo.processor.annotation.Ignore
import de.davis.keygo.processor.annotation.RootVaultEntity
import de.davis.keygo.processor.annotation.VaultEntity
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
-import kotlin.reflect.KClass
class ItemHandler : Handler, KoinComponent {
- private val className by inject()
+ private val stringRes by inject()
+
private val codeGenerator by inject()
private val roots = mutableListOf()
@@ -59,316 +44,96 @@ class ItemHandler : Handler, KoinComponent
@OptIn(KspExperimental::class)
override fun handleSymbols(symbols: List): List {
symbols.map { root ->
- val rootProperties = root.getOwnProperties()
- val rootId = rootProperties.first { it.isId }
-
- val subclasses = root.getSealedSubclasses()
- .filterNot { it.areAnyAnnotationsPresent(Ignore::class, BasicModel::class) }
- .map {
- Entry.ChildEntry(
- simpleName = it.simpleName.asString(),
- packageName = it.packageName.asString(),
- vaultEntity = it.getAnnotation() ?: throw NotFoundException(
- "Annotation @RootVaultEntity is missing on ${it.qualifiedName?.asString()}"
- ),
- properties = it.getOwnProperties(),
- rootId = rootId
- )
- }
- .toList()
+ val subclasses = root.getSealedSubclasses().map {
+ Entry.ChildEntry(
+ simpleName = it.simpleName.asString(),
+ packageName = it.packageName.asString(),
+ vaultEntity = it.getAnnotation() ?: throw NotFoundException(
+ "Annotation @RootVaultEntity is missing on ${it.qualifiedName?.asString()}"
+ ),
+ )
+ }.toList()
Entry.RootEntry(
simpleName = root.simpleName.asString(),
packageName = root.packageName.asString(),
- properties = rootProperties,
- basicClassName = root.getSealedSubclasses()
- .firstOrNull { it.isAnnotationPresent(BasicModel::class) }?.toClassName(),
children = subclasses
)
}.also(roots::addAll)
writeEnumType(roots)
- writeEntities(roots)
- writeRelations(roots)
- writeMappers(roots)
-
return emptyList()
}
- @OptIn(KspExperimental::class)
- fun KSClassDeclaration.areAnyAnnotationsPresent(vararg annotations: KClass) =
- annotations.any { isAnnotationPresent(it) }
-
fun writeEnumType(roots: List) {
roots.forEach { root ->
- val rootClassName = root.enumTypeClassName(getClassName = className)
+ val rootClassName = root.enumTypeClassName()
file(
codeGenerator = codeGenerator,
className = rootClassName
) { className ->
enum(className) {
- constructor {
+ /*constructor {
parameter("resString", Int::class, KModifier.INTERNAL)
parameter("icon", IMAGE_VECTOR_CLASS_NAME, emptyList(), KModifier.INTERNAL)
- }
+ }*/
root.children.forEach {
- entry(it.simpleName) {
+ entry(it.simpleName)/* {
parameter(stringRes(it.vaultEntity.resString))
parameter(
"%T.Default.%M",
ICONS_DEFAULT_CLASS_NAME,
defaultIconMemberName(it.vaultEntity.defaultIconType)
)
- }
+ }*/
}
}
-
- val funSpec = FunSpec.builder("getString")
- .addAnnotation(COMPOSABLE_CLASS_NAME)
- .receiver(rootClassName)
- .returns(String::class)
- .addStatement("return %M(resString)", STRING_RESOURCE_MEMBER_NAME)
-
- fileSpecBuilder.addFunction(funSpec.build())
}
- }
- }
- fun writeEntities(roots: List) {
- roots.forEach { root ->
file(
codeGenerator = codeGenerator,
- className = root.entityClassName(getClassName = className)
- ) {
- dataClass(root.entityClassName(getClassName = className)) {
- annotation(roomEntity())
-
- constructor {
- root.properties.forEach {
- val annotations = mutableListOf()
- if (it.isId) annotations.add(primaryRoomKey())
- if (it.name.isCamelCase()) annotations.add(roomColumnInfo(it.name.camelToSnakeCase()))
-
- parameter(it.name, it.type, annotations)
- }
- }
- }
- }
-
- root.children.forEach { subclass ->
- file(
- codeGenerator = codeGenerator,
- className = subclass.entityClassName(getClassName = className)
- ) {
- dataClass(subclass.entityClassName(getClassName = className)) {
- annotation(
- annotationSpec = roomEntity(
- foreignKey = ForeignKey(
- entity = root.entityClassName(getClassName = className),
- parentColumns = listOf(root.idProperty.name.camelToSnakeCase()),
- childColumns = listOf(subclass.rootId.name.camelToSnakeCase()),
- ),
- index = Index(
- value = listOf(subclass.rootId.name.camelToSnakeCase()),
- unique = true,
+ className = ClassName(
+ rootClassName.packageName.substringBefore("domain") + "presentation",
+ rootClassName.simpleName
+ )
+ ) { className ->
+ val funSpec = FunSpec.getterBuilder()
+ .addAnnotation(COMPOSABLE_CLASS_NAME)
+ .addCode(
+ buildCodeBlock {
+ beginControlFlow("return when(this)")
+ root.children.forEach {
+ addStatement(
+ "%T.%L -> %M(%L) to %T.Default.%M",
+ rootClassName,
+ it.simpleName,
+ STRING_RESOURCE_MEMBER_NAME,
+ stringRes(it.vaultEntity.resString),
+ ICONS_DEFAULT_CLASS_NAME,
+ defaultIconMemberName(it.vaultEntity.defaultIconType)
)
- )
- )
-
- constructor {
- subclass.properties.forEach {
- val annotations = mutableListOf()
- if (it.isId) annotations.add(primaryRoomKey())
- if (it.name.isCamelCase()) annotations.add(roomColumnInfo(it.name.camelToSnakeCase()))
-
- parameter(it.name, it.type, annotations)
}
-
- parameter(
- subclass.rootId.name,
- subclass.rootId.type,
- listOf(roomColumnInfo(name = subclass.rootId.name.camelToSnakeCase()))
- )
- }
- }
- }
- }
- }
- }
-
- fun writeRelations(roots: List) {
- roots.forEach { root ->
- val rootClassName = root.entityClassName(getClassName = className)
-
- root.children.forEach { subclass ->
- file(
- codeGenerator = codeGenerator,
- className = subclass.relationClassName(getClassName = className)
- ) {
- dataClass(subclass.relationClassName(getClassName = className)) {
- constructor {
- parameter(
- name = root.embeddedName(getClassName = className),
- type = rootClassName,
- annotations = listOf(
- AnnotationSpec.builder(EMBEDDED_CLASS_NAME).build()
- )
- )
-
- parameter(
- name = subclass.relationPropertyName,
- type = subclass.entityClassName(getClassName = className),
- annotations = listOf(
- roomRelation(
- parentColumn = root.idProperty.name.camelToSnakeCase(),
- entityColumn = subclass.rootId.name.camelToSnakeCase()
- )
- )
- )
+ endControlFlow()
}
- }
- }
- }
- }
- }
+ ).build()
- fun writeMappers(roots: List) {
- roots.forEach { root ->
- file(
- codeGenerator = codeGenerator,
- className = root.mapperClassName(getClassName = className)
- ) {
- writeMapperFunction(
- type = MapperType.TO_DATA,
- root = root
- )
- writeMapperFunction(
- type = MapperType.TO_DOMAIN,
- root = root
- )
- }
- root.children.forEach { subclass ->
- file(
- codeGenerator = codeGenerator,
- className = subclass.mapperClassName(getClassName = className)
- ) {
- writeMapperFunction(
- type = MapperType.TO_DATA,
- root = root,
- child = subclass
- )
- writeMapperFunction(
- type = MapperType.TO_DOMAIN,
- root = root,
- child = subclass
+ val returnType = Pair::class
+ .asTypeName()
+ .parameterizedBy(
+ STRING,
+ IMAGE_VECTOR_CLASS_NAME
)
- mapperFunction(
- name = MapperType.TO_DOMAIN.funName,
- receiver = subclass.relationClassName(getClassName = className),
- returnType = subclass.className
- ) {
- `return`(
- "%L.%L(%L)",
- subclass.relationPropertyName,
- MapperType.TO_DOMAIN.funName,
- root.embeddedName(getClassName = className)
- )
- }
- }
- }
- }
- }
-
-
- private fun FileBuilder.writeMapperFunction(
- type: MapperType,
- root: Entry.RootEntry,
- ) {
- val (receiver, returnType) = when (type) {
- MapperType.TO_DATA -> root.className to root.entityClassName(getClassName = className)
- MapperType.TO_DOMAIN -> root.entityClassName(getClassName = className) to root.basicClassName
- }
-
- returnType ?: return
-
- val fields = root.properties.associate {
- it.name to it.name
- }
-
- writeMapperFunction(
- funName = type.funName,
- receiver = receiver,
- returnType = returnType,
- fields = fields,
- )
- }
-
- private fun FileBuilder.writeMapperFunction(
- type: MapperType,
- root: Entry.RootEntry,
- child: Entry.ChildEntry,
- ) {
- val (receiver, returnType) = when (type) {
- MapperType.TO_DATA -> child.className to child.entityClassName(getClassName = className)
- MapperType.TO_DOMAIN -> child.entityClassName(getClassName = className) to child.className
- }
-
- val fields = child.properties.associate {
- it.name to it.name
- }.toMutableMap()
-
- val parameter = when (type) {
- MapperType.TO_DATA -> {
- fields.put(child.rootId.name, child.rootId.name)
- null
- }
-
- MapperType.TO_DOMAIN -> {
- val rootItem = root.entityClassName(getClassName = className)
- .simpleName
- .replaceFirstChar { it.lowercase() }
- root.properties.associate {
- it.name to "$rootItem.${it.name}"
- }.also(fields::putAll)
- rootItem to root.entityClassName(getClassName = className)
- }
- }
-
- writeMapperFunction(
- funName = type.funName,
- receiver = receiver,
- returnType = returnType,
- fields = fields,
- parameter = parameter
- )
- }
+ val propertySpec = PropertySpec.builder("presentation", returnType)
+ .getter(funSpec)
+ .receiver(rootClassName)
+ .build()
- private fun FileBuilder.writeMapperFunction(
- funName: String,
- receiver: TypeName,
- returnType: TypeName,
- fields: Map,
- parameter: Pair? = null,
- ) {
- mapperFunction(
- name = funName,
- receiver = receiver,
- returnType = returnType
- ) {
- parameter?.let { (paramName, paramType) ->
- parameter(paramName, paramType)
+ fileSpecBuilder.addProperty(propertySpec)
}
-
- `return`("%T(${fields.entries.joinToString(", ")})", returnType)
}
}
-
- enum class MapperType(val funName: String) {
- TO_DATA(Constants.MapperNames.TO_DATA),
- TO_DOMAIN(Constants.MapperNames.TO_DOMAIN)
- }
}
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Entry.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Entry.kt
index 6fc53b1f..ee13e061 100644
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Entry.kt
+++ b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Entry.kt
@@ -1,66 +1,33 @@
package de.davis.keygo.automation.processor.model
-import com.squareup.kotlinpoet.ClassName
import de.davis.keygo.automation.processor.util.Constants
import de.davis.keygo.automation.processor.util.GetClassName
import de.davis.keygo.processor.annotation.VaultEntity
import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
sealed class Entry : KoinComponent {
+ protected val getClassName by inject()
+
abstract val simpleName: String
abstract val packageName: String
- abstract val properties: Sequence
-
- val className get() = ClassName(packageName, simpleName)
data class RootEntry(
override val simpleName: String,
override val packageName: String,
- override val properties: Sequence,
- val basicClassName: ClassName?,
val children: List
) : Entry() {
- val idProperty by lazy {
- properties.firstOrNull { it.isId }
- ?: throw IllegalStateException("No ID property found in $simpleName")
- }
-
- fun embeddedName(getClassName: GetClassName) = entityClassName(getClassName)
- .simpleName
- .replaceFirstChar { it.lowercase() }
-
- fun enumTypeClassName(getClassName: GetClassName) = getClassName(
- "$simpleName${Constants.Suffixes.TYPE_SUFFIX}",
- packageNameSuffix = Constants.Packages.ENUM_PACKAGE_SUFFIX
+ fun enumTypeClassName() = getClassName(
+ packageName = packageName,
+ simpleName = "$simpleName${Constants.Suffixes.TYPE_SUFFIX}"
)
}
data class ChildEntry(
override val simpleName: String,
override val packageName: String,
- override val properties: Sequence,
- val rootId: Property,
val vaultEntity: VaultEntity,
- ) : Entry() {
- val relationPropertyName by lazy {
- simpleName.replaceFirstChar { it.lowercase() }
- }
-
- fun relationClassName(getClassName: GetClassName) = getClassName(
- "${Constants.Prefixes.VAULT_PREFIX}$simpleName",
- packageNameSuffix = Constants.Packages.RELATION_PACKAGE_SUFFIX
- )
- }
-
- fun entityClassName(getClassName: GetClassName) = getClassName(
- "$simpleName${Constants.Suffixes.ENTITY_SUFFIX}",
- packageNameSuffix = Constants.Packages.ENTITY_PACKAGE_SUFFIX
- )
-
- fun mapperClassName(getClassName: GetClassName) = getClassName(
- "$simpleName${Constants.Suffixes.MAPPER_SUFFIX}",
- packageNameSuffix = Constants.Packages.MAPPER_PACKAGE_SUFFIX
- )
+ ) : Entry()
}
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/ForeignKey.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/ForeignKey.kt
deleted file mode 100644
index 6bb62808..00000000
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/ForeignKey.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package de.davis.keygo.automation.processor.model
-
-import com.squareup.kotlinpoet.AnnotationSpec
-import com.squareup.kotlinpoet.ClassName
-import de.davis.keygo.automation.processor.util.FOREIGN_KEY_CLASS_NAME
-
-data class ForeignKey(
- val entity: ClassName,
- val parentColumns: List,
- val childColumns: List,
- val deferred: Boolean = false,
-) {
- fun foreignKey() =
- AnnotationSpec.Companion.builder(FOREIGN_KEY_CLASS_NAME)
- .addMember("entity = %T::class", entity)
- .addMember("parentColumns = [%S]", parentColumns.joinToString(", "))
- .addMember("childColumns = [%S]", childColumns.joinToString(", "))
- .addMember("onDelete = %T.%L", FOREIGN_KEY_CLASS_NAME, "CASCADE")
- .apply { if (deferred) addMember("deferred = true") }
- .build()
-}
\ No newline at end of file
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Index.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Index.kt
deleted file mode 100644
index da29fdae..00000000
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Index.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package de.davis.keygo.automation.processor.model
-
-import com.squareup.kotlinpoet.AnnotationSpec
-import de.davis.keygo.automation.processor.util.INDEX_CLASS_NAME
-
-data class Index(
- val value: List,
- val unique: Boolean = false,
-) {
- fun index() =
- AnnotationSpec.Companion.builder(INDEX_CLASS_NAME)
- .addMember("value = [%S]", value.joinToString(", "))
- .addMember("unique = %L", unique)
- .build()
-}
\ No newline at end of file
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Options.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Options.kt
index 0dd869c1..7e77aaa8 100644
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Options.kt
+++ b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Options.kt
@@ -12,6 +12,7 @@ value class Options(private val options: Map) {
fun require(key: String) = options[key] ?: throw NotFoundException("Option $key not found")
companion object {
- const val KEY_GENERATED_AUTOMATION_PACKAGE = "automation.packageName"
+ const val KEY_AUTOMATION_ANDROID_NAMESPACE = "automation.android_namespace"
+ const val KEY_AUTOMATION_ANDROID_NAMESPACE_SUFFIX = "automation.android_namespace_suffix"
}
}
\ No newline at end of file
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Property.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Property.kt
deleted file mode 100644
index eebfe23b..00000000
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/Property.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package de.davis.keygo.automation.processor.model
-
-import com.google.devtools.ksp.symbol.KSClassDeclaration
-import com.squareup.kotlinpoet.TypeName
-import com.squareup.kotlinpoet.ksp.toTypeName
-import de.davis.keygo.automation.processor.ext.getAnnotation
-import de.davis.keygo.processor.annotation.Id
-
-data class Property(
- val name: String,
- val type: TypeName,
- val isId: Boolean
-)
-
-fun KSClassDeclaration.getOwnProperties(): Sequence {
- return getAllProperties()
- .filter { it.findOverridee() == null }
- .map {
- Property(
- name = it.simpleName.asString(),
- type = it.type.resolve().toTypeName(),
- isId = it.getAnnotation() != null
- )
- }
-}
\ No newline at end of file
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/RouteTree.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/RouteTree.kt
deleted file mode 100644
index f611df07..00000000
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/model/RouteTree.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package de.davis.keygo.automation.processor.model
-
-
-sealed interface RouteTree {
-
- data class Root(
- val name: String,
- val routes: Set
- ) : RouteTree
-
- data class Endpoint(
- val name: String
- ) : RouteTree
-}
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/Constants.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/Constants.kt
index 21051015..84aa707b 100644
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/Constants.kt
+++ b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/Constants.kt
@@ -1,25 +1,12 @@
package de.davis.keygo.automation.processor.util
object Constants {
- object Prefixes {
- const val VAULT_PREFIX = "Vault"
- }
object Suffixes {
- const val MAPPER_SUFFIX = "Mapper"
- const val ENTITY_SUFFIX = "Entity"
const val TYPE_SUFFIX = "Type"
}
object Packages {
- const val ENUM_PACKAGE_SUFFIX = "item"
- const val MAPPER_PACKAGE_SUFFIX = "$ENUM_PACKAGE_SUFFIX.data.mapper"
- const val ENTITY_PACKAGE_SUFFIX = "$ENUM_PACKAGE_SUFFIX.data.local.entity"
- const val RELATION_PACKAGE_SUFFIX = "$ENUM_PACKAGE_SUFFIX.data.local.relation"
- }
-
- object MapperNames {
- const val TO_DATA = "toData"
- const val TO_DOMAIN = "toDomain"
+ const val DEFAULT_GENERATED_PACKAGE_SUFFIX = "item"
}
}
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/KotlinPoetUtils.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/KotlinPoetUtils.kt
index d3df4156..004df4cd 100644
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/KotlinPoetUtils.kt
+++ b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/KotlinPoetUtils.kt
@@ -1,64 +1,41 @@
package de.davis.keygo.automation.processor.util
-import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.MemberName
-import de.davis.keygo.automation.processor.model.ForeignKey
-import de.davis.keygo.automation.processor.model.Index
import de.davis.keygo.automation.processor.model.Options
class GetClassName(options: Options) {
- private val packageName =
- options.getOrDefault(Options.KEY_GENERATED_AUTOMATION_PACKAGE, "generated")
- operator fun invoke(
- simpleName: String,
- packageNameSuffix: String = "",
- ) = packageNameSuffix.let {
- if (it.isNotEmpty()) {
- ClassName(
- "$packageName.$it", simpleName
- )
- } else {
- ClassName(packageName, simpleName)
- }
+ private val androidNamespace = options.require(Options.KEY_AUTOMATION_ANDROID_NAMESPACE)
+
+ private val packageNamePrefix =
+ options.getOrDefault(Options.KEY_AUTOMATION_ANDROID_NAMESPACE_SUFFIX, "generated")
+
+ operator fun invoke(packageName: String, simpleName: String): ClassName {
+ val packageNameWithoutNamespace = packageName.removePrefix(androidNamespace)
+ val finalPackageName = listOf(
+ androidNamespace,
+ packageNamePrefix,
+ packageNameWithoutNamespace
+ ).joinToString(".").trimEnd('.')
+
+ return ClassName(finalPackageName, simpleName)
}
}
-fun stringRes(name: String) = MemberName("de.davis.keygo.R.string", name)
+class GetStringRes(options: Options) {
-fun primaryRoomKey(autoGenerate: Boolean = true) =
- AnnotationSpec.builder(ClassName("androidx.room", "PrimaryKey")).apply {
- if (autoGenerate) addMember("autoGenerate = true")
- }.build()
+ private val androidNamespace = options.require(Options.KEY_AUTOMATION_ANDROID_NAMESPACE)
-fun roomColumnInfo(name: String) =
- AnnotationSpec.builder(ClassName("androidx.room", "ColumnInfo"))
- .addMember("name = %S", name)
- .build()
-
-fun roomEntity(foreignKey: ForeignKey? = null, index: Index? = null) =
- AnnotationSpec.builder(ENTITY_CLASS_NAME)
- .apply {
- foreignKey?.let { addMember("foreignKeys = [%L]", it.foreignKey()) }
- index?.let { addMember("indices = [%L]", it.index()) }
- }
- .build()
+ operator fun invoke(name: String): MemberName {
+ return MemberName("$androidNamespace.R.string", name)
+ }
+}
-fun roomRelation(parentColumn: String, entityColumn: String) =
- AnnotationSpec.builder(RELATION_CLASS_NAME)
- .addMember("parentColumn = %S", parentColumn)
- .addMember("entityColumn = %S", entityColumn)
- .build()
fun defaultIconMemberName(iconName: String) =
MemberName("${ICONS_DEFAULT_CLASS_NAME.packageName}.filled", iconName)
-val FOREIGN_KEY_CLASS_NAME = ClassName("androidx.room", "ForeignKey")
-val INDEX_CLASS_NAME = ClassName("androidx.room", "Index")
-val ENTITY_CLASS_NAME = ClassName("androidx.room", "Entity")
-val EMBEDDED_CLASS_NAME = ClassName("androidx.room", "Embedded")
-val RELATION_CLASS_NAME = ClassName("androidx.room", "Relation")
val COMPOSABLE_CLASS_NAME = ClassName("androidx.compose.runtime", "Composable")
val STRING_RESOURCE_MEMBER_NAME = MemberName("androidx.compose.ui.res", "stringResource")
diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/StringUtils.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/StringUtils.kt
deleted file mode 100644
index 31b2f34a..00000000
--- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/util/StringUtils.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package de.davis.keygo.automation.processor.util
-
-object StringUtils {
-
- fun String.camelToSnakeCase(): String =
- replace(Regex("([a-z0-9])([A-Z])"), "$1_$2")
- .replace(Regex("([A-Z])([A-Z][a-z])"), "$1_$2")
- .lowercase()
-
- fun String.isCamelCase(): Boolean =
- matches(Regex("^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)+$"))
-}
diff --git a/automation/src/main/kotlin/de/davis/keygo/processor/annotation/VaultItem.kt b/automation/src/main/kotlin/de/davis/keygo/processor/annotation/VaultItem.kt
index dee23a60..0fcdc4a5 100644
--- a/automation/src/main/kotlin/de/davis/keygo/processor/annotation/VaultItem.kt
+++ b/automation/src/main/kotlin/de/davis/keygo/processor/annotation/VaultItem.kt
@@ -6,16 +6,4 @@ annotation class RootVaultEntity(val name: String = "")
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
-annotation class VaultEntity(val resString: String, val defaultIconType: String)
-
-@Target(AnnotationTarget.CLASS)
-@Retention(AnnotationRetention.SOURCE)
-annotation class Ignore
-
-@Target(AnnotationTarget.CLASS)
-@Retention(AnnotationRetention.SOURCE)
-annotation class BasicModel
-
-@Target(AnnotationTarget.PROPERTY)
-@Retention(AnnotationRetention.SOURCE)
-annotation class Id
\ No newline at end of file
+annotation class VaultEntity(val resString: String, val defaultIconType: String)
\ No newline at end of file
diff --git a/core/item/.gitignore b/core/item/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/item/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/item/build.gradle.kts b/core/item/build.gradle.kts
new file mode 100644
index 00000000..e3ec8813
--- /dev/null
+++ b/core/item/build.gradle.kts
@@ -0,0 +1,70 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.androidx.room)
+ alias(libs.plugins.google.ksp)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "de.davis.keygo.core.item"
+ compileSdk = libs.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ // Required for the generated composable functions
+ buildFeatures {
+ compose = true
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_17
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+
+ // Room
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
+
+ implementation(projects.automation)
+ ksp(projects.automationProcessor)
+
+ api(projects.core.util)
+
+ // Jetpack Compose
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.material.icons.extended)
+
+ // Koin DI
+ implementation(project.dependencies.platform(libs.koin.bom))
+ implementation(project.dependencies.platform(libs.koin.annotations.bom))
+ implementation(libs.koin.androidx.compose)
+ implementation(libs.koin.annotations)
+ ksp(libs.koin.ksp.compiler)
+}
+
+room {
+ schemaDirectory("$projectDir/schemas")
+}
+
+ksp {
+ arg("automation.android_namespace", "de.davis.keygo.core.item")
+}
\ No newline at end of file
diff --git a/core/item/consumer-rules.pro b/core/item/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/core/item/proguard-rules.pro b/core/item/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/core/item/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json
new file mode 100644
index 00000000..5341a5d5
--- /dev/null
+++ b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json
@@ -0,0 +1,166 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "68bfdbbb3e9972ed90593269d8aa4622",
+ "entities": [
+ {
+ "tableName": "VaultItemEntity",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `note` TEXT, `encrypted_data` BLOB NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "note",
+ "columnName": "note",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "encryptedData",
+ "columnName": "encrypted_data",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "PasswordEntity",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT, `score` TEXT NOT NULL, `totp_secret` BLOB, `vault_item_id` INTEGER NOT NULL, FOREIGN KEY(`vault_item_id`) REFERENCES `VaultItemEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "score",
+ "columnName": "score",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totpSecret",
+ "columnName": "totp_secret",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "vaultItemId",
+ "columnName": "vault_item_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_PasswordEntity_vault_item_id",
+ "unique": true,
+ "columnNames": [
+ "vault_item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PasswordEntity_vault_item_id` ON `${TABLE_NAME}` (`vault_item_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "VaultItemEntity",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "vault_item_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "DomainInfoEntity",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`password_id` INTEGER NOT NULL, `value` TEXT NOT NULL, `eTLD1` TEXT, PRIMARY KEY(`password_id`, `value`), FOREIGN KEY(`password_id`) REFERENCES `PasswordEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "passwordId",
+ "columnName": "password_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "value",
+ "columnName": "value",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "eTLD1",
+ "columnName": "eTLD1",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "password_id",
+ "value"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_DomainInfoEntity_eTLD1",
+ "unique": false,
+ "columnNames": [
+ "eTLD1"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_DomainInfoEntity_eTLD1` ON `${TABLE_NAME}` (`eTLD1`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "PasswordEntity",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "password_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '68bfdbbb3e9972ed90593269d8aa4622')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/item/src/main/AndroidManifest.xml b/core/item/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/core/item/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/converter/SecretDataConverter.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/converter/SecretDataConverter.kt
new file mode 100644
index 00000000..0280ddb5
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/converter/SecretDataConverter.kt
@@ -0,0 +1,30 @@
+package de.davis.keygo.core.item.data.local.converter
+
+import androidx.room.TypeConverter
+import de.davis.keygo.core.item.domain.model.SecretData
+
+internal object SecretDataConverter {
+
+ @TypeConverter
+ fun fromSecretDataString(value: SecretData?): ByteArray? = fromSecretData(value)
+
+ @TypeConverter
+ fun fromByteArrayToSecretDataString(value: ByteArray?): SecretData? =
+ fromByteArray(value)
+
+ private fun fromSecretData(value: SecretData?): ByteArray? = value?.let {
+ byteArrayOf(it.decryptedDataType.uniqueId) + it.data
+ }
+
+ private fun fromByteArray(value: ByteArray?): SecretData? = value?.let {
+ if (value.isEmpty()) return null
+
+ @Suppress("UNCHECKED_CAST")
+ val type = SecretData.DecryptedDataType
+ .getById(value[0]) as? SecretData.DecryptedDataType
+ ?: return null
+
+ val data = value.sliceArray(1 until value.size)
+ SecretData(data = data, decryptedDataType = type)
+ }
+}
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/DomainInfoDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/DomainInfoDao.kt
new file mode 100644
index 00000000..7c9f1141
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/DomainInfoDao.kt
@@ -0,0 +1,32 @@
+package de.davis.keygo.core.item.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Upsert
+import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity
+
+@Dao
+internal abstract class DomainInfoDao {
+
+ @Query("DELETE FROM DomainInfoEntity WHERE password_id = :passwordId AND (value IS NULL OR value NOT IN (:except))")
+ protected abstract suspend fun deleteAllDomainsForPassword(
+ passwordId: Long,
+ except: Set = emptySet()
+ )
+
+ @Upsert
+ abstract suspend fun upsertAll(domains: Set): List
+
+ @Transaction
+ open suspend fun syncForPassword(passwordId: Long, domains: Set) {
+ if (domains.isEmpty()) {
+ deleteAllDomainsForPassword(passwordId)
+ return
+ }
+
+ val adjusted = domains.map { it.copy(passwordId = passwordId) }.toSet()
+ upsertAll(adjusted)
+ deleteAllDomainsForPassword(passwordId, adjusted.map { it.value }.toSet())
+ }
+}
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasswordDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasswordDao.kt
new file mode 100644
index 00000000..7b2ec560
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasswordDao.kt
@@ -0,0 +1,52 @@
+package de.davis.keygo.core.item.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Upsert
+import de.davis.keygo.core.item.data.local.entity.PasswordEntity
+import de.davis.keygo.core.item.data.local.pojo.LightweightPassword
+import de.davis.keygo.core.item.data.local.pojo.VaultPassword
+import de.davis.keygo.core.item.domain.alias.ItemId
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+internal interface PasswordDao {
+
+ @Transaction
+ @Query("SELECT * FROM PasswordEntity")
+ fun getAllPasswords(): Flow>
+
+
+ @Transaction
+ @Query("SELECT * FROM PasswordEntity WHERE vault_item_id = :vaultId")
+ fun observeVaultPassword(vaultId: ItemId): Flow
+
+ @Transaction
+ @Query(
+ """
+ SELECT vault.id vault_item_id, vault.name name, password.id password_id, password.username username
+ FROM VaultItemEntity vault
+ JOIN PasswordEntity password ON vault.id = password.vault_item_id
+ WHERE (NOT :requireTotp OR password.totp_secret IS NOT NULL)
+ AND EXISTS (
+ SELECT 1 FROM DomainInfoEntity domain
+ WHERE domain.password_id = password.id
+ AND domain.eTLD1 in (:etld1s) COLLATE NOCASE
+ )
+ LIMIT :limit
+ """
+ )
+ suspend fun getByTLDs(
+ etld1s: Set,
+ requireTotp: Boolean,
+ limit: Int
+ ): List
+
+ @Transaction
+ @Query("SELECT * FROM PasswordEntity WHERE vault_item_id = :vaultId")
+ suspend fun getVaultPassword(vaultId: ItemId): VaultPassword?
+
+ @Upsert
+ suspend fun upsert(password: PasswordEntity): ItemId
+}
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/VaultDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/VaultDao.kt
new file mode 100644
index 00000000..578f0e55
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/VaultDao.kt
@@ -0,0 +1,42 @@
+package de.davis.keygo.core.item.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Upsert
+import de.davis.keygo.core.item.data.local.entity.VaultItemEntity
+import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItem
+import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItemSearchResult
+import de.davis.keygo.core.item.domain.alias.ItemId
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+internal interface VaultDao {
+
+ @Upsert
+ suspend fun upsert(vaultItem: VaultItemEntity): ItemId
+
+ @Query("DELETE FROM VaultItemEntity WHERE id = :id")
+ suspend fun delete(id: ItemId)
+
+ @Query("SELECT name FROM VaultItemEntity WHERE id = :itemId")
+ suspend fun getNameById(itemId: ItemId): String?
+
+ @Query("SELECT EXISTS(SELECT 1 FROM VaultItemEntity WHERE name = :name AND (:excludeId IS NULL OR id != :excludeId))")
+ suspend fun existsName(name: String, excludeId: ItemId? = null): Boolean
+
+ @Query("SELECT * FROM VaultItemEntity WHERE id = :id")
+ suspend fun getVaultItemById(id: ItemId): VaultItemEntity?
+
+ @Query(
+ """
+ SELECT v.id, v.name, (name LIKE '%' || :query || '%') AS matchedName, (note LIKE '%' || :query || '%') AS matchedNote
+ FROM VaultItemEntity v
+ WHERE name LIKE '%' || :query || '%' OR COALESCE(note, '') LIKE '%' || :query || '%'
+ """
+ )
+ suspend fun searchVaultItem(query: String): List
+
+
+ @Query("SELECT v.id, v.name FROM VaultItemEntity v")
+ fun observeLiteVaultItems(): Flow>
+}
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt
new file mode 100644
index 00000000..0bb6473a
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt
@@ -0,0 +1,48 @@
+package de.davis.keygo.core.item.data.local.datasource
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import de.davis.keygo.core.item.data.local.converter.SecretDataConverter
+import de.davis.keygo.core.item.data.local.dao.DomainInfoDao
+import de.davis.keygo.core.item.data.local.dao.PasswordDao
+import de.davis.keygo.core.item.data.local.dao.VaultDao
+import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity
+import de.davis.keygo.core.item.data.local.entity.PasswordEntity
+import de.davis.keygo.core.item.data.local.entity.VaultItemEntity
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+@Database(
+ entities = [
+ VaultItemEntity::class,
+ PasswordEntity::class,
+ DomainInfoEntity::class
+ ],
+ version = 1
+)
+@TypeConverters(SecretDataConverter::class)
+abstract class ItemDatabase : RoomDatabase() {
+
+ internal abstract fun vaultDao(): VaultDao
+ internal abstract fun passwordDao(): PasswordDao
+ internal abstract fun domainInfoDao(): DomainInfoDao
+
+ companion object {
+ val koinModule = module {
+ single { create(get()) }
+
+ singleOf(ItemDatabase::vaultDao)
+ singleOf(ItemDatabase::passwordDao)
+ singleOf(ItemDatabase::domainInfoDao)
+ }
+
+ private fun create(applicationContext: Context) = Room.databaseBuilder(
+ applicationContext,
+ ItemDatabase::class.java,
+ name = "secure_element_database"
+ ).fallbackToDestructiveMigration(false).build()
+ }
+}
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/DomainInfoEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/DomainInfoEntity.kt
new file mode 100644
index 00000000..eef64849
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/DomainInfoEntity.kt
@@ -0,0 +1,27 @@
+package de.davis.keygo.core.item.data.local.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+
+@Entity(
+ primaryKeys = ["password_id", "value"],
+ foreignKeys = [
+ ForeignKey(
+ entity = PasswordEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["password_id"],
+ onDelete = ForeignKey.CASCADE,
+ )
+ ],
+ indices = [
+ Index("eTLD1"),
+ ]
+)
+internal data class DomainInfoEntity(
+ @ColumnInfo("password_id")
+ val passwordId: Long,
+ val value: String,
+ val eTLD1: String?,
+)
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasswordEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasswordEntity.kt
new file mode 100644
index 00000000..da40f028
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasswordEntity.kt
@@ -0,0 +1,34 @@
+package de.davis.keygo.core.item.data.local.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.Password
+import de.davis.keygo.core.item.domain.model.SecretData
+
+@Entity(
+ foreignKeys = [
+ ForeignKey(
+ entity = VaultItemEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["vault_item_id"],
+ onDelete = ForeignKey.CASCADE
+ )
+ ],
+ indices = [
+ Index(value = ["vault_item_id"], unique = true)
+ ],
+)
+internal data class PasswordEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: ItemId,
+ val username: String?,
+ val score: Password.Score,
+ @ColumnInfo(name = "totp_secret")
+ val totpSecret: SecretData?,
+ @ColumnInfo(name = "vault_item_id")
+ val vaultItemId: ItemId,
+)
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/VaultItemEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/VaultItemEntity.kt
new file mode 100644
index 00000000..d47ba890
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/VaultItemEntity.kt
@@ -0,0 +1,17 @@
+package de.davis.keygo.core.item.data.local.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.SecretData
+
+@Entity
+internal data class VaultItemEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: ItemId,
+ val name: String,
+ val note: String?,
+ @ColumnInfo(name = "encrypted_data")
+ val encryptedData: SecretData,
+)
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightPassword.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightPassword.kt
new file mode 100644
index 00000000..c6446f52
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightPassword.kt
@@ -0,0 +1,21 @@
+package de.davis.keygo.core.item.data.local.pojo
+
+import androidx.room.ColumnInfo
+import androidx.room.Relation
+import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity
+import de.davis.keygo.core.item.domain.alias.ItemId
+
+internal data class LightweightPassword(
+ @ColumnInfo("vault_item_id")
+ val vaultItemId: ItemId,
+ @ColumnInfo("password_id")
+ val passwordId: ItemId,
+ val username: String?,
+ val name: String,
+
+ @Relation(
+ parentColumn = "password_id",
+ entityColumn = "password_id"
+ )
+ val domains: List
+)
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItem.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItem.kt
new file mode 100644
index 00000000..6b08c804
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItem.kt
@@ -0,0 +1,6 @@
+package de.davis.keygo.core.item.data.local.pojo
+
+internal data class LightweightVaultItem(
+ val id: Long,
+ val name: String,
+)
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItemSearchResult.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItemSearchResult.kt
new file mode 100644
index 00000000..59baf37f
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItemSearchResult.kt
@@ -0,0 +1,8 @@
+package de.davis.keygo.core.item.data.local.pojo
+
+internal data class LightweightVaultItemSearchResult(
+ val id: Long,
+ val name: String,
+ val matchedName: Boolean,
+ val matchedNote: Boolean
+)
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultPassword.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultPassword.kt
new file mode 100644
index 00000000..399125b7
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultPassword.kt
@@ -0,0 +1,24 @@
+package de.davis.keygo.core.item.data.local.pojo
+
+import androidx.room.Embedded
+import androidx.room.Relation
+import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity
+import de.davis.keygo.core.item.data.local.entity.PasswordEntity
+import de.davis.keygo.core.item.data.local.entity.VaultItemEntity
+
+internal data class VaultPassword(
+ @Embedded
+ val passwordEntity: PasswordEntity,
+
+ @Relation(
+ parentColumn = "vault_item_id",
+ entityColumn = "id",
+ )
+ val vaultItemEntity: VaultItemEntity,
+
+ @Relation(
+ parentColumn = "id",
+ entityColumn = "password_id",
+ )
+ val domains: List
+)
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainInfoMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainInfoMapper.kt
new file mode 100644
index 00000000..b7019fa5
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainInfoMapper.kt
@@ -0,0 +1,17 @@
+package de.davis.keygo.core.item.data.maper
+
+import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.Password
+
+internal fun Password.toDataDomainInfos(passwordId: ItemId = id): Set =
+ domainInfos.map {
+ it.toData(passwordId = passwordId)
+ }.toSet()
+
+internal fun DomainInfo.toData(passwordId: Long): DomainInfoEntity = DomainInfoEntity(
+ passwordId = passwordId,
+ value = value,
+ eTLD1 = eTLD1,
+)
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainMapper.kt
new file mode 100644
index 00000000..da2a09bc
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainMapper.kt
@@ -0,0 +1,16 @@
+package de.davis.keygo.core.item.data.maper
+
+import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity
+import de.davis.keygo.core.item.domain.model.DomainInfo
+
+internal fun DomainInfo.toData() = DomainInfoEntity(
+ passwordId = passwordId,
+ value = value,
+ eTLD1 = eTLD1,
+)
+
+internal fun DomainInfoEntity.toDomain() = DomainInfo(
+ passwordId = passwordId,
+ value = value,
+ eTLD1 = eTLD1,
+)
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapper.kt
new file mode 100644
index 00000000..a8a76c45
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapper.kt
@@ -0,0 +1,40 @@
+package de.davis.keygo.core.item.data.maper
+
+import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity
+import de.davis.keygo.core.item.data.local.entity.PasswordEntity
+import de.davis.keygo.core.item.data.local.pojo.LightweightPassword
+import de.davis.keygo.core.item.data.local.pojo.VaultPassword
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.Password
+import de.davis.keygo.core.item.domain.model.lite.LitePassword
+
+internal fun Password.toData(vaultItemId: ItemId = this.vaultItemId): PasswordEntity =
+ PasswordEntity(
+ id = id,
+ username = username,
+ score = score,
+ totpSecret = totpSecret,
+ vaultItemId = vaultItemId
+ )
+
+internal fun VaultPassword.toDomain(): Password = Password(
+ id = passwordEntity.id,
+ username = passwordEntity.username,
+ score = passwordEntity.score,
+ totpSecret = passwordEntity.totpSecret,
+
+ domainInfos = domains.map(DomainInfoEntity::toDomain).toSet(),
+
+ vaultItemId = vaultItemEntity.id,
+ name = vaultItemEntity.name,
+ note = vaultItemEntity.note,
+ encryptedData = vaultItemEntity.encryptedData
+)
+
+internal fun LightweightPassword.toDomain(): LitePassword = LitePassword(
+ vaultItemId = vaultItemId,
+ passwordId = passwordId,
+ name = name,
+ username = username,
+ domains = domains.map(DomainInfoEntity::toDomain)
+)
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultItemMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultItemMapper.kt
new file mode 100644
index 00000000..6fea1db2
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultItemMapper.kt
@@ -0,0 +1,27 @@
+package de.davis.keygo.core.item.data.maper
+
+import de.davis.keygo.core.item.data.local.entity.VaultItemEntity
+import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItem
+import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItemSearchResult
+import de.davis.keygo.core.item.domain.model.VaultItem
+import de.davis.keygo.core.item.domain.model.lite.LiteItem
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItemSearchResult
+
+internal fun VaultItem.toData() = VaultItemEntity(
+ id = vaultItemId,
+ name = name,
+ note = note,
+ encryptedData = encryptedData
+)
+
+internal fun LightweightVaultItem.toDomain() = LiteItem.Concrete(
+ vaultItemId = id,
+ name = name,
+)
+
+internal fun LightweightVaultItemSearchResult.toDomain() = LiteVaultItemSearchResult(
+ vaultItemId = id,
+ name = name,
+ matchedName = matchedName,
+ matchedNote = matchedNote
+)
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasswordRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasswordRepositoryImpl.kt
new file mode 100644
index 00000000..2ad2a6af
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasswordRepositoryImpl.kt
@@ -0,0 +1,93 @@
+package de.davis.keygo.core.item.data.repository
+
+import androidx.room.withTransaction
+import de.davis.keygo.core.item.data.local.dao.DomainInfoDao
+import de.davis.keygo.core.item.data.local.dao.PasswordDao
+import de.davis.keygo.core.item.data.local.dao.VaultDao
+import de.davis.keygo.core.item.data.local.datasource.ItemDatabase
+import de.davis.keygo.core.item.data.local.pojo.LightweightPassword
+import de.davis.keygo.core.item.data.local.pojo.VaultPassword
+import de.davis.keygo.core.item.data.maper.toData
+import de.davis.keygo.core.item.data.maper.toDataDomainInfos
+import de.davis.keygo.core.item.data.maper.toDomain
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.Password
+import de.davis.keygo.core.item.domain.model.VaultItem
+import de.davis.keygo.core.item.domain.model.lite.LitePassword
+import de.davis.keygo.core.item.domain.repository.PasswordRepository
+import de.davis.keygo.core.util.Result
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.koin.core.annotation.Single
+
+@Single
+internal class PasswordRepositoryImpl(
+ private val database: ItemDatabase,
+ private val vaultDao: VaultDao,
+ private val passwordDao: PasswordDao,
+ private val domainInfoDao: DomainInfoDao
+) : PasswordRepository {
+
+ override suspend fun createOrUpdatePassword(password: Password): Result =
+ runCatching {
+ database.withTransaction {
+ val vaultItemId = vaultDao.upsert((password as VaultItem).toData())
+ .takeIf { it != -1L }
+ ?: password.vaultItemId // room returned -1 meaning the item was updated
+
+ val passwordId =
+ passwordDao.upsert(password.toData(vaultItemId))
+ .takeIf { it != -1L }
+ ?: password.id
+
+ domainInfoDao.syncForPassword(passwordId, password.toDataDomainInfos(passwordId))
+
+ passwordId
+ }
+ }.fold(
+ onSuccess = { Result.Success(it) },
+ onFailure = { Result.Failure(it) }
+ )
+
+ override suspend fun updatePasswordWithDomainInfo(
+ vaultItemId: ItemId,
+ domainInfos: Set
+ ): Result = runCatching {
+ database.withTransaction {
+ val password = passwordDao.getVaultPassword(vaultItemId)
+ ?: throw IllegalArgumentException("No password found with vault id $vaultItemId")
+
+ val dataDomains = domainInfos.map { it.toData(password.passwordEntity.id) }.toSet()
+ domainInfoDao.upsertAll(dataDomains)
+ }
+ }.fold(
+ onSuccess = { Result.Success(Unit) },
+ onFailure = { Result.Failure(it) }
+ )
+
+ override suspend fun getVaultPasswordsByTLD(
+ etld1: String,
+ requireTotp: Boolean,
+ limit: Int
+ ): List =
+ getVaultPasswordsByTLDs(setOf(etld1), requireTotp, limit)
+
+ override suspend fun getVaultPasswordsByTLDs(
+ etld1s: Set,
+ requireTotp: Boolean,
+ limit: Int
+ ): List =
+ passwordDao.getByTLDs(etld1s, requireTotp, limit).map(LightweightPassword::toDomain)
+
+ override suspend fun getPasswordById(vaultId: ItemId): Password? =
+ passwordDao.getVaultPassword(vaultId)?.toDomain()
+
+ override fun observePasswordById(vaultId: ItemId): Flow =
+ passwordDao.observeVaultPassword(vaultId)
+ .map(VaultPassword::toDomain)
+
+ override fun observePasswords(): Flow> = passwordDao.getAllPasswords().map {
+ it.map(VaultPassword::toDomain)
+ }
+}
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultItemRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultItemRepositoryImpl.kt
new file mode 100644
index 00000000..44c13751
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultItemRepositoryImpl.kt
@@ -0,0 +1,41 @@
+package de.davis.keygo.core.item.data.repository
+
+import de.davis.keygo.core.item.data.local.dao.VaultDao
+import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItem
+import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItemSearchResult
+import de.davis.keygo.core.item.data.maper.toData
+import de.davis.keygo.core.item.data.maper.toDomain
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.VaultItem
+import de.davis.keygo.core.item.domain.model.lite.LiteItem
+import de.davis.keygo.core.item.domain.model.lite.LiteVaultItemSearchResult
+import de.davis.keygo.core.item.domain.repository.VaultItemRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.koin.core.annotation.Single
+
+@Single
+internal class VaultItemRepositoryImpl(
+ private val vaultDao: VaultDao
+) : VaultItemRepository {
+
+ override suspend fun deleteItem(itemId: ItemId) = vaultDao.delete(itemId)
+
+ override suspend fun createOrUpdateVaultItem(item: VaultItem): ItemId =
+ vaultDao.upsert(item.toData())
+
+ override suspend fun getItemName(itemId: ItemId): String? = vaultDao.getNameById(itemId)
+
+ override suspend fun doesNameExist(
+ name: String,
+ excludeId: ItemId?
+ ): Boolean = vaultDao.existsName(name, excludeId)
+
+ override suspend fun searchVaultItem(query: String): List =
+ vaultDao.searchVaultItem(query).map(LightweightVaultItemSearchResult::toDomain)
+
+ override fun observeLiteVaultItems(): Flow> =
+ vaultDao.observeLiteVaultItems().map {
+ it.map(LightweightVaultItem::toDomain)
+ }
+}
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/di/CoreItemModule.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/di/CoreItemModule.kt
new file mode 100644
index 00000000..8c381dbc
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/di/CoreItemModule.kt
@@ -0,0 +1,8 @@
+package de.davis.keygo.core.item.di
+
+import org.koin.core.annotation.ComponentScan
+import org.koin.core.annotation.Module
+
+@Module
+@ComponentScan("de.davis.keygo.core.item.**")
+object CoreItemModule
\ No newline at end of file
diff --git a/app/src/main/kotlin/de/davis/keygo/core/domain/alias/ItemId.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/ItemId.kt
similarity index 56%
rename from app/src/main/kotlin/de/davis/keygo/core/domain/alias/ItemId.kt
rename to core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/ItemId.kt
index cc810487..7dae2636 100644
--- a/app/src/main/kotlin/de/davis/keygo/core/domain/alias/ItemId.kt
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/ItemId.kt
@@ -1,4 +1,4 @@
-package de.davis.keygo.core.domain.alias
+package de.davis.keygo.core.item.domain.alias
typealias ItemId = Long
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/DomainInfo.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/DomainInfo.kt
new file mode 100644
index 00000000..a0f2c59c
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/DomainInfo.kt
@@ -0,0 +1,10 @@
+package de.davis.keygo.core.item.domain.model
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+
+// TODO: extract additional schemes (like https) from "value" and store them separately
+data class DomainInfo(
+ val passwordId: ItemId = 0,
+ val value: String,
+ val eTLD1: String?,
+)
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Password.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Password.kt
new file mode 100644
index 00000000..ef95ca41
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Password.kt
@@ -0,0 +1,43 @@
+package de.davis.keygo.core.item.domain.model
+
+import androidx.annotation.IntRange
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.processor.annotation.VaultEntity
+
+@VaultEntity(resString = "password", defaultIconType = "Password")
+data class Password(
+ val id: ItemId = 0,
+ val username: String?,
+ val domainInfos: Set,
+ val score: Score,
+ val totpSecret: SecretData?,
+ override val vaultItemId: ItemId = 0,
+ override val name: String,
+ override val encryptedData: SecretData,
+ override val note: String?
+) : VaultItem {
+
+ enum class Score {
+ None,
+ Ridiculous,
+ Weak,
+ Moderate,
+ Strong,
+ Excellent;
+
+ val isNone: Boolean
+ get() = this == None
+
+ companion object {
+ operator fun invoke(@IntRange(from = 1, to = 5) value: Int): Score =
+ when (value) {
+ 1 -> Ridiculous
+ 2 -> Weak
+ 3 -> Moderate
+ 4 -> Strong
+ 5 -> Excellent
+ else -> None
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/SecretData.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/SecretData.kt
new file mode 100644
index 00000000..e0833de1
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/SecretData.kt
@@ -0,0 +1,58 @@
+package de.davis.keygo.core.item.domain.model
+
+data class SecretData(
+ val data: ByteArray,
+ val decryptedDataType: DecryptedDataType
+) {
+
+ sealed interface DecryptedDataType {
+ val uniqueId: Byte
+
+ fun decode(data: ByteArray): T
+ fun encode(value: T): ByteArray
+
+ object StringType : DecryptedDataType {
+ override val uniqueId: Byte = 0x1
+ override fun decode(data: ByteArray): String = data.decodeToString()
+ override fun encode(value: String): ByteArray = value.encodeToByteArray()
+ }
+
+ companion object {
+ fun getById(id: Byte): DecryptedDataType<*>? = when (id) {
+ StringType.uniqueId -> StringType
+ else -> null
+ }
+
+ inline fun getDecryptedDataType(): DecryptedDataType = when (T::class) {
+ String::class -> StringType
+ else -> throw IllegalArgumentException("Unsupported type: ${T::class}")
+ } as DecryptedDataType
+ }
+ }
+
+ companion object {
+ val EMPTY_STRING: SecretData
+ get() = SecretData(
+ byteArrayOf(),
+ DecryptedDataType.StringType
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as SecretData<*>
+
+ if (!data.contentEquals(other.data)) return false
+ if (decryptedDataType != other.decryptedDataType) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = data.contentHashCode()
+ result = 31 * result + decryptedDataType.hashCode()
+ return result
+ }
+}
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultItem.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultItem.kt
new file mode 100644
index 00000000..dce5a21f
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultItem.kt
@@ -0,0 +1,12 @@
+package de.davis.keygo.core.item.domain.model
+
+import de.davis.keygo.core.item.domain.model.lite.LiteItem
+import de.davis.keygo.processor.annotation.RootVaultEntity
+
+@RootVaultEntity
+sealed interface VaultItem : LiteItem {
+ override val vaultItemId: Long
+ override val name: String
+ val encryptedData: SecretData
+ val note: String?
+}
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LitePassword.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LitePassword.kt
new file mode 100644
index 00000000..b197fa6b
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LitePassword.kt
@@ -0,0 +1,12 @@
+package de.davis.keygo.core.item.domain.model.lite
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.DomainInfo
+
+data class LitePassword(
+ override val vaultItemId: ItemId,
+ val passwordId: ItemId,
+ override val name: String,
+ val username: String?,
+ val domains: List,
+) : LiteVaultItem
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItem.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItem.kt
new file mode 100644
index 00000000..512226fb
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItem.kt
@@ -0,0 +1,26 @@
+package de.davis.keygo.core.item.domain.model.lite
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+
+/**
+ * A lightweight representation of a item stored in the vault.
+ */
+interface LiteItem {
+ val vaultItemId: ItemId
+ val name: String
+
+ @ConsistentCopyVisibility
+ data class Concrete internal constructor(
+ override val vaultItemId: ItemId,
+ override val name: String,
+ ) : LiteItem
+}
+
+/**
+ * A lightweight representation of a vault item. This interface was introduced to allow exhausted
+ * when expressions for LiteItem without including search results that might be represented by a
+ * [LiteItem].
+ *
+ * @see LiteItem
+ */
+sealed interface LiteVaultItem : LiteItem
\ No newline at end of file
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItemSearchResult.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItemSearchResult.kt
new file mode 100644
index 00000000..c2c20ce0
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItemSearchResult.kt
@@ -0,0 +1,10 @@
+package de.davis.keygo.core.item.domain.model.lite
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+
+data class LiteVaultItemSearchResult(
+ override val vaultItemId: ItemId,
+ override val name: String,
+ val matchedName: Boolean,
+ val matchedNote: Boolean
+) : LiteItem
diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasswordRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasswordRepository.kt
new file mode 100644
index 00000000..2cb9dd0e
--- /dev/null
+++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasswordRepository.kt
@@ -0,0 +1,34 @@
+package de.davis.keygo.core.item.domain.repository
+
+import de.davis.keygo.core.item.domain.alias.ItemId
+import de.davis.keygo.core.item.domain.model.DomainInfo
+import de.davis.keygo.core.item.domain.model.Password
+import de.davis.keygo.core.item.domain.model.lite.LitePassword
+import de.davis.keygo.core.util.Result
+import kotlinx.coroutines.flow.Flow
+
+interface PasswordRepository {
+
+ suspend fun createOrUpdatePassword(password: Password): Result
+ suspend fun updatePasswordWithDomainInfo(
+ vaultItemId: ItemId,
+ domainInfos: Set
+ ): Result
+
+ suspend fun getVaultPasswordsByTLD(
+ etld1: String,
+ requireTotp: Boolean = false,
+ limit: Int = -1
+ ): List