Skip to content

Commit 40a42da

Browse files
committed
[Autofill] Add password generation support
- Add `GeneratePassword` request type to autofill flows - Integrate `GeneratePasswordModalBottomSheet` into `AutofillActivity` - Implement password generation logic in `AutofillViewModel` to fill fields and optionally copy to clipboard - Update `MenuDatasetBuilder` and `InlineDatasetBuilder` to include password generation options - Add "Use KeyGo Autofill" string and sparkle icon for the new generation action - Update unit tests and dependencies in `feature/autofill`
1 parent 5daf04e commit 40a42da

11 files changed

Lines changed: 172 additions & 24 deletions

File tree

feature/autofill/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,8 @@ dependencies {
7979
implementation(project.dependencies.platform(libs.koin.bom))
8080
implementation(libs.koin.androidx.compose)
8181
implementation(libs.koin.annotations)
82+
83+
testImplementation(libs.kotlin.test)
84+
testImplementation(libs.kotlinx.coroutines.test)
85+
testImplementation(libs.io.mockk)
8286
}

feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
package de.davis.keygo.feature.autofill.presentation.activity
22

3+
import android.content.ClipData
4+
import android.content.ClipDescription
35
import android.content.Context
46
import android.content.Intent
7+
import android.os.Build
58
import android.os.Bundle
9+
import android.os.PersistableBundle
610
import android.service.autofill.Dataset
711
import android.view.autofill.AutofillManager
812
import androidx.activity.compose.setContent
13+
import androidx.compose.material3.ExperimentalMaterial3Api
914
import androidx.compose.runtime.LaunchedEffect
1015
import androidx.compose.runtime.getValue
16+
import androidx.compose.ui.platform.LocalClipboard
17+
import androidx.compose.ui.platform.toClipEntry
1118
import androidx.fragment.app.FragmentActivity
1219
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1320
import androidx.navigation.compose.rememberNavController
@@ -28,6 +35,7 @@ import de.davis.keygo.feature.autofill.presentation.activity.model.AutofillUiEve
2835
import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionDialogVisibility
2936
import de.davis.keygo.feature.autofill.presentation.model.Request
3037
import de.davis.keygo.feature.autofill.presentation.model.RequestData
38+
import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet
3139
import org.koin.androidx.compose.koinViewModel
3240

3341

@@ -41,6 +49,7 @@ import org.koin.androidx.compose.koinViewModel
4149
*/
4250
internal class AutofillActivity : FragmentActivity() {
4351

52+
@OptIn(ExperimentalMaterial3Api::class)
4453
override fun onCreate(savedInstanceState: Bundle?) {
4554
super.onCreate(savedInstanceState)
4655

@@ -57,10 +66,25 @@ internal class AutofillActivity : FragmentActivity() {
5766
val biometricCryptoController = rememberBiometricCryptoController()
5867
val biometricUnlockAdapter = rememberBiometricUnlockAdapter()
5968

60-
ObserveAsEvents(viewModel.events) {
61-
when (it) {
69+
val clipboard = LocalClipboard.current
70+
71+
ObserveAsEvents(viewModel.events) { event ->
72+
when (event) {
6273
AutofillEvent.Abort -> cancel()
63-
is AutofillEvent.Fill -> finishWithResult(it.dataset)
74+
is AutofillEvent.Fill -> {
75+
event.copyToClipboard?.let {
76+
val clipData = ClipData.newPlainText(it, it).apply {
77+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
78+
description.extras = PersistableBundle().apply {
79+
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
80+
}
81+
}
82+
83+
clipboard.setClipEntry(clipData.toClipEntry())
84+
}
85+
86+
finishWithResult(event.dataset)
87+
}
6488
}
6589
}
6690

@@ -123,6 +147,13 @@ internal class AutofillActivity : FragmentActivity() {
123147
appPackageName = suspicionDialogVisibility.appPackageName,
124148
website = suspicionDialogVisibility.website
125149
)
150+
151+
152+
if (uiState.showGeneratePassword)
153+
GeneratePasswordModalBottomSheet(
154+
onGenerated = { viewModel.onEvent(AutofillUiEvent.OnGeneratedPassword(it)) },
155+
onDismiss = { viewModel.onEvent(AutofillUiEvent.OnDismissGeneratePassword) }
156+
)
126157
}
127158
}
128159
}

feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ internal class AutofillViewModel(
125125
is FillRequestData.Pinned,
126126
is FillRequestData.App -> _uiState.update { it.copy(request = Request.SelectItem) }
127127

128+
is FillRequestData.GeneratePassword -> _uiState.update {
129+
it.copy(showGeneratePassword = true)
130+
}
131+
128132
is FillRequestData.Suggestion -> handleSuggestionRequest(requestData)
129133
}
130134
}
@@ -206,6 +210,14 @@ internal class AutofillViewModel(
206210
AutofillUiEvent.OnAbortInSuspicion -> viewModelScope.launch {
207211
eventChannel.send(AutofillEvent.Abort)
208212
}
213+
214+
AutofillUiEvent.OnDismissGeneratePassword -> viewModelScope.launch {
215+
eventChannel.send(AutofillEvent.Abort)
216+
}
217+
218+
is AutofillUiEvent.OnGeneratedPassword -> viewModelScope.launch {
219+
sendGeneratedPasswordFillEvent(event.password)
220+
}
209221
}
210222
}
211223

@@ -332,6 +344,29 @@ internal class AutofillViewModel(
332344
eventChannel.send(AutofillEvent.Fill(autofillDatasetProvider.getFillingDataset(values)))
333345
}
334346

347+
private suspend fun sendGeneratedPasswordFillEvent(password: String) {
348+
val values = requestData.form.fields
349+
.filter { it.type is FieldType.Credentials.Password }
350+
.map {
351+
AutofillValue(
352+
autofillId = it.autofillId,
353+
value = password
354+
)
355+
}
356+
357+
if (values.isEmpty()) {
358+
eventChannel.send(AutofillEvent.Abort)
359+
return
360+
}
361+
362+
eventChannel.send(
363+
AutofillEvent.Fill(
364+
autofillDatasetProvider.getFillingDataset(values),
365+
copyToClipboard = password
366+
)
367+
)
368+
}
369+
335370
companion object {
336371
const val KEY_AUTOFILL_INFORMATION = "extraction"
337372
}

feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/AutofillEvent.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ internal sealed interface AutofillEvent {
66

77
data object Abort : AutofillEvent
88

9-
data class Fill(val dataset: Dataset) : AutofillEvent
9+
data class Fill(val dataset: Dataset, val copyToClipboard: String? = null) : AutofillEvent
1010
}

feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/model/AutofillUiEvent.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@ internal sealed interface AutofillUiEvent {
1010

1111
data object OnContinueInSuspicion : AutofillUiEvent
1212
data object OnAbortInSuspicion : AutofillUiEvent
13+
14+
data object OnDismissGeneratePassword : AutofillUiEvent
15+
data class OnGeneratedPassword(val password: String) : AutofillUiEvent
1316
}

feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,23 @@ import android.service.autofill.InlinePresentation
99
import android.widget.inline.InlinePresentationSpec
1010
import androidx.annotation.RequiresApi
1111
import de.davis.keygo.core.item.domain.model.lite.LiteVaultItem
12+
import de.davis.keygo.feature.autofill.R
1213
import de.davis.keygo.feature.autofill.presentation.dataset.DatasetBuilder
1314
import de.davis.keygo.feature.autofill.presentation.dataset.SuggestionFinder
1415
import de.davis.keygo.feature.autofill.presentation.getOnLongClickPendingIntent
1516
import de.davis.keygo.feature.autofill.presentation.getSelectionPendingIntent
17+
import de.davis.keygo.feature.autofill.presentation.model.FieldType
1618
import de.davis.keygo.feature.autofill.presentation.model.FillRequestData
1719
import de.davis.keygo.feature.autofill.presentation.model.Form
1820
import de.davis.keygo.feature.autofill.presentation.model.FormType
1921
import de.davis.keygo.feature.autofill.presentation.model.appRequestData
22+
import de.davis.keygo.feature.autofill.presentation.model.generatePasswordRequestData
2023
import de.davis.keygo.feature.autofill.presentation.model.pinnedRequestData
2124
import de.davis.keygo.feature.autofill.presentation.model.suggestionRequestData
2225
import de.davis.keygo.feature.autofill.presentation.subtitle
2326
import org.koin.core.annotation.Single
2427
import de.davis.keygo.core.ui.R as CoreUiR
28+
import de.davis.keygo.feature.item.create.R as FeatureItemCreateR
2529

2630
@Single
2731
internal class InlineDatasetBuilder(
@@ -31,8 +35,6 @@ internal class InlineDatasetBuilder(
3135
private val context: Context,
3236
) {
3337

34-
// TODO: include a option to generate a secure password -> make the generate UI a standalone
35-
// composable like a TextField
3638
@RequiresApi(Build.VERSION_CODES.R)
3739
suspend fun buildInlineDatasets(
3840
specs: List<InlinePresentationSpec>,
@@ -71,8 +73,20 @@ internal class InlineDatasetBuilder(
7173
0 -> emptyList()
7274
1 -> listOf(buildPinnedInlineSuggestionDataset(specs.first(), form))
7375
else -> {
74-
val suggestions =
75-
suggestionFinder.findVaultSuggestions(form, count = specs.size - 2)
76+
// Reserve 1 spec for "Pinned" (always last). Suggestions get remaining specs.
77+
val suggestions = suggestionFinder.findVaultSuggestions(
78+
form = form,
79+
count = specs.size - 1
80+
)
81+
82+
val hasNoSuggestions = suggestions.isEmpty()
83+
var remaining = specs.size - 1 - suggestions.size
84+
85+
val showOpenApp = hasNoSuggestions && remaining > 0
86+
if (showOpenApp) remaining--
87+
88+
val showGeneratePassword = remaining > 0 &&
89+
form.fields.any { it.type is FieldType.Credentials.Password }
7690

7791
buildList {
7892
addAll(
@@ -86,19 +100,19 @@ internal class InlineDatasetBuilder(
86100
}
87101
)
88102

89-
add(
90-
buildAppInlineSuggestionDataset(
91-
spec = specs[specs.size - 2], // second to last spec
92-
form = form,
103+
var specIndex = suggestions.size
104+
if (showGeneratePassword)
105+
add(
106+
buildGeneratePasswordInlineSuggestionDataset(
107+
spec = specs[specIndex++],
108+
form = form
109+
)
93110
)
94-
)
95111

96-
add(
97-
buildPinnedInlineSuggestionDataset(
98-
spec = specs.last(),
99-
form = form
100-
)
101-
)
112+
if (showOpenApp)
113+
add(buildAppInlineSuggestionDataset(spec = specs[specIndex], form = form))
114+
115+
add(buildPinnedInlineSuggestionDataset(spec = specs.last(), form = form))
102116
}
103117
}
104118
}
@@ -126,12 +140,27 @@ internal class InlineDatasetBuilder(
126140
spec = spec,
127141
pendingIntent = context.getOnLongClickPendingIntent(),
128142
icon = appIcon(),
129-
title = context.getString(CoreUiR.string.app_name)
143+
title = context.getString(R.string.open_app)
130144
)
131145

132146
return presentation.buildDataset(appRequestData(form))
133147
}
134148

149+
@RequiresApi(Build.VERSION_CODES.R)
150+
private fun buildGeneratePasswordInlineSuggestionDataset(
151+
spec: InlinePresentationSpec,
152+
form: Form
153+
): Dataset {
154+
val presentation = inlineSuggestionFactory.buildPresentation(
155+
spec = spec,
156+
pendingIntent = context.getOnLongClickPendingIntent(),
157+
icon = Icon.createWithResource(context, R.drawable.baseline_auto_awesome_24),
158+
title = context.getString(FeatureItemCreateR.string.generate_password)
159+
)
160+
161+
return presentation.buildDataset(generatePasswordRequestData(form))
162+
}
163+
135164
@RequiresApi(Build.VERSION_CODES.R)
136165
private fun buildInlineSuggestionDataset(
137166
spec: InlinePresentationSpec,
@@ -168,4 +197,4 @@ internal class InlineDatasetBuilder(
168197
setTintBlendMode(BlendMode.DST)
169198
}
170199
else null
171-
}
200+
}

feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import de.davis.keygo.feature.autofill.R
99
import de.davis.keygo.feature.autofill.presentation.dataset.DatasetBuilder
1010
import de.davis.keygo.feature.autofill.presentation.dataset.SuggestionFinder
1111
import de.davis.keygo.feature.autofill.presentation.getSelectionPendingIntent
12+
import de.davis.keygo.feature.autofill.presentation.model.FieldType
1213
import de.davis.keygo.feature.autofill.presentation.model.Form
1314
import de.davis.keygo.feature.autofill.presentation.model.FormType
1415
import de.davis.keygo.feature.autofill.presentation.model.appRequestData
16+
import de.davis.keygo.feature.autofill.presentation.model.generatePasswordRequestData
1517
import de.davis.keygo.feature.autofill.presentation.model.suggestionRequestData
1618
import de.davis.keygo.feature.autofill.presentation.subtitle
1719
import org.koin.core.annotation.Single
@@ -43,13 +45,29 @@ internal class MenuDatasetBuilder(
4345

4446
suggestions.mapIndexed { index, suggestion ->
4547
buildSuggestionDataset(index, form, suggestion)
46-
} + listOf(buildAppDataset(form))
48+
} + listOfNotNull(buildGeneratePasswordDataset(form), buildAppDataset(form))
4749
}
4850
}
4951

52+
private fun buildGeneratePasswordDataset(form: Form): Dataset? {
53+
if (form.fields.none { it.type is FieldType.Credentials.Password }) return null
54+
55+
val remoteViews = menuDatasetBuilder.buildMenuSuggestion(
56+
title = context.getString(de.davis.keygo.feature.item.create.R.string.generate_password),
57+
icon = R.drawable.baseline_auto_awesome_24
58+
)
59+
60+
val requestData = generatePasswordRequestData(form)
61+
return datasetBuilder.buildDataset(
62+
remoteViews = remoteViews,
63+
intentSender = context.getSelectionPendingIntent(requestData).intentSender,
64+
form = requestData.form,
65+
)
66+
}
67+
5068
private fun buildAppDataset(form: Form): Dataset {
5169
val remoteViews = menuDatasetBuilder.buildMenuSuggestion(
52-
title = context.getString(CoreUiR.string.app_name),
70+
title = context.getString(R.string.open_app),
5371
subtitle = context.getString(R.string.autofill_service),
5472
icon = CoreUiR.mipmap.ic_launcher_round
5573
)

feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/AutofillUiState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ internal data class AutofillUiState(
99
val request: Request<*> = Request.None,
1010
val associationDialogVisibility: AssociationDialogVisibility = AssociationDialogVisibility.Hidden,
1111
val suspicionDialogVisibility: SuspicionDialogVisibility = SuspicionDialogVisibility.Hidden,
12+
val showGeneratePassword: Boolean = false,
1213
val vaultId: ItemId = ItemIdNone
1314
)

feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/RequestData.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,18 @@ internal sealed interface FillRequestData : RequestData {
3232
// the pending intent would be overridden by the last one created, causing only that one to
3333
// be received.
3434
@IgnoredOnParcel
35-
override val requestId: Int = 1003 + index
35+
override val requestId: Int = 1010 + index
3636
}
3737

3838
data class App(
3939
override val form: Form,
40+
) : FillRequestData {
41+
@IgnoredOnParcel
42+
override val requestId: Int = 1003
43+
}
44+
45+
data class GeneratePassword(
46+
override val form: Form,
4047
) : FillRequestData {
4148
@IgnoredOnParcel
4249
override val requestId: Int = 1002
@@ -54,6 +61,12 @@ internal fun pinnedRequestData(formInformation: Form) =
5461
FillRequestData.Pinned(form = formInformation)
5562

5663
internal fun appRequestData(formInformation: Form) = FillRequestData.App(form = formInformation)
64+
internal fun generatePasswordRequestData(formInformation: Form) =
65+
FillRequestData.GeneratePassword(
66+
form = formInformation.copy(
67+
fields = formInformation.fields.filter { it.type is FieldType.Credentials.Password }
68+
)
69+
)
5770

5871
internal fun suggestionRequestData(formInformation: Form, vaultId: ItemId, index: Int) =
5972
FillRequestData.Suggestion(
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:tint="#000000"
5+
android:viewportWidth="24"
6+
android:viewportHeight="24">
7+
8+
<path
9+
android:fillColor="@android:color/white"
10+
android:pathData="M19,9l1.25,-2.75L23,5l-2.75,-1.25L19,1l-1.25,2.75L15,5l2.75,1.25L19,9zM11.5,9.5L9,4 6.5,9.5 1,12l5.5,2.5L9,20l2.5,-5.5L17,12l-5.5,-2.5zM19,15l-1.25,2.75L15,19l2.75,1.25L19,23l1.25,-2.75L23,19l-2.75,-1.25L19,15z" />
11+
12+
</vector>

0 commit comments

Comments
 (0)