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