diff options
5 files changed, 100 insertions, 3 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt new file mode 100644 index 00000000..8dbc9eb9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.entry + +import androidx.compose.runtime.Immutable +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.passfile.Totp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * What the entry view shows. [password] and [username] open the first group (the one-time code + * joins them from the entry's TOTP flow); [extras] are the remaining `key: value` lines in file + * order, plus lines that are not `key: value` under the parser's "Extra Content" key. Blank values + * are null, so their rows are not shown. + */ +@Immutable +data class EntryFields( + val password: String?, + val username: String?, + val extras: ImmutableList<Pair<String, String>>, +) + +fun entryFields(entry: PasswordEntry): EntryFields = + EntryFields( + password = entry.password?.takeIf { it.isNotBlank() }, + username = entry.username?.takeIf { it.isNotBlank() }, + extras = entry.extraContent.toList().toImmutableList(), + ) + +/** Share of the code's lifetime left: 1 right after it was generated, toward 0 at expiry. */ +fun Totp.fractionLeft(): Float = (remainingTime / period).toFloat() diff --git a/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt new file mode 100644 index 00000000..290c51a9 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.entry + +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.passfile.Totp +import app.passwordstore.util.time.UserClock +import app.passwordstore.util.totp.UriTotpFinder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class EntryModelTest { + + private fun entry(content: String) = + PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray()) + + @Test + fun firstGroupAndExtrasInFileOrder() { + val fields = + entryFields(entry("hunter2\nusername: alice\nurl: https://example.org\npin: 1234\n")) + assertEquals("hunter2", fields.password) + assertEquals("alice", fields.username) + assertEquals(listOf("url" to "https://example.org", "pin" to "1234"), fields.extras) + } + + @Test + fun blankPasswordAndMissingUsernameAreLeftOut() { + val fields = entryFields(entry("\nnote: none\n")) + assertNull(fields.password) + assertNull(fields.username) + assertEquals(listOf("note" to "none"), fields.extras) + } + + @Test + fun otpUriIsNotAnExtraField() { + val fields = + entryFields( + entry( + "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n" + ) + ) + assertEquals("s3cret", fields.password) + assertTrue(fields.extras.isEmpty()) + } + + @Test + fun fractionLeftOfTheOtpPeriod() { + assertEquals(1f, Totp("123456", 30.seconds, 30.seconds).fractionLeft()) + assertEquals(0.5f, Totp("123456", 15.seconds, 30.seconds).fractionLeft()) + } +} diff --git a/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt b/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt index f0009dcf..1995342e 100644 --- a/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt +++ b/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt @@ -224,7 +224,7 @@ constructor( digits, issuer, ) - .mapBoth({ code -> Ok(Totp(code, remainingTime)) }, ::Err) + .mapBoth({ code -> Ok(Totp(code, remainingTime, totpPeriod.seconds)) }, ::Err) } @AssistedFactory diff --git a/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt b/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt index 5e98ecd4..a26f03d9 100644 --- a/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt +++ b/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt @@ -7,5 +7,9 @@ package app.passwordstore.data.passfile import kotlin.time.Duration -/** Holder for a TOTP secret and the duration for which it is valid. */ -public data class Totp(public val value: String, public val remainingTime: Duration) +/** A TOTP code, how long it stays valid, and the full period it was generated for. */ +public data class Totp( + public val value: String, + public val remainingTime: Duration, + public val period: Duration, +) diff --git a/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt b/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt index 4efb140b..b7d41fd1 100644 --- a/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt +++ b/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt @@ -134,6 +134,7 @@ class PasswordEntryTest { val otp = expectMostRecentItem() assertEquals("818800", otp.value) assertEquals(30.seconds, otp.remainingTime) + assertEquals(30.seconds, otp.period) cancelAndIgnoreRemainingEvents() } } |
