NexoPass 1.3.0: Linux and Windows app, project split into shared/android/desktop

The PC app (Compose Multiplatform) uses the nexopass script's files, so the
window and the terminal share accounts, site lists and the app password.
Clipboard copies carry KDE's password hint. GitHub Actions builds the APK,
.deb, .rpm, .msi and a portable Windows zip, and checks that passwords
still match the script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-08 14:59:04 +02:00
commit 987cea431f
42 changed files with 1475 additions and 557 deletions

202
shared/resources/LICENSE Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,229 @@
package com.nexoniarz.nexopass.app
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.nexoniarz.nexopass.core.Export
import com.nexoniarz.nexopass.core.NexoPass
import com.nexoniarz.nexopass.core.Rec
import com.nexoniarz.nexopass.core.Vault
import java.io.File
import java.util.concurrent.ConcurrentHashMap
/**
* The whole app: accounts on disk, the app unlock, and the keyring (every
* account's vault key and master) while unlocked. Nothing secret is written
* anywhere except encrypted.
*/
class AppState(val platform: Platform, val words: List<String>) {
val prefs = platform.prefs
val lock = platform.lock
private val accountsDir = platform.accountsDir
var accounts by mutableStateOf(listOf<String>())
private set
var appUnlocked by mutableStateOf(false)
private set
/** Unlocked with a master (recovery, first run, update): an app unlock must be set now. */
var needsAppSetup by mutableStateOf(false)
private set
var session by mutableStateOf<Session?>(null)
private set
/** Set when the account's master isn't in the keyring yet. */
var needsMaster by mutableStateOf<String?>(null)
private set
var migrated by mutableStateOf(platform.migrated)
private set
var firstRunCheck by mutableStateOf<String?>(null)
private var keyring: MutableMap<String, Unlocked> = LinkedHashMap()
private var innerKey: ByteArray? = null
init {
refreshAccounts()
}
val current get() = prefs.currentAccount
fun refreshAccounts() {
accounts = accountsDir.listFiles()
?.filter { File(it, platform.codec.fileName).exists() }
?.map { it.name }?.sorted() ?: emptyList()
if (accounts.isNotEmpty() && current !in accounts) prefs.currentAccount = accounts.first()
}
private fun vault(name: String, key: ByteArray) = Vault(File(accountsDir, "$name/${platform.codec.fileName}"), key, platform.codec)
/** After the PIN/password/pattern or biometrics. */
fun unlockApp(r: UnlockResult.Ok) {
innerKey = r.innerKey
keyring = r.keyring
needsAppSetup = false
openAccount(current)
appUnlocked = true
}
/**
* Opens an account with its master (recovery, update, or an account not in
* the keyring yet). Slow. False when the master doesn't open it.
*/
fun unlockWithMaster(name: String, master: ByteArray): Boolean {
val u = Unlocked(NexoPass.vaultKey(master), master)
if (!vault(name, u.vaultKey).load()) return false
keyring[name] = u
if (innerKey == null) needsAppSetup = true else saveKeyring()
needsMaster = null
open(name, u)
appUnlocked = true
return true
}
/** New account: empty vault, master into the keyring. Slow. */
fun createAccount(name: String, master: ByteArray) {
val u = Unlocked(NexoPass.vaultKey(master), master)
vault(name, u.vaultKey).save()
keyring[name] = u
refreshAccounts()
firstRunCheck = NexoPass.checkWords(master, words)
if (innerKey == null) needsAppSetup = true else saveKeyring()
needsMaster = null
open(name, u)
appUnlocked = true
}
fun openAccount(name: String) {
prefs.currentAccount = name
session?.close()
session = null
val u = keyring[name]
needsMaster = if (u != null && open(name, u)) null else name
}
private fun open(name: String, u: Unlocked): Boolean {
val s = Session(name, vault(name, u.vaultKey.copyOf()), words, prefs, u.master)
if (!s.load()) return false
session?.close()
session = s
prefs.currentAccount = name
return true
}
/** Slow (Argon2). */
fun setupApp(method: String, secret: String) {
innerKey = lock.setup(method, secret, keyring)
}
fun finishAppSetup() {
needsAppSetup = false
}
fun innerKey() = innerKey!!
fun masterOf(name: String) = keyring[name]?.master?.decodeToString()
fun renameAccount(old: String, new: String) {
session?.close()
session = null
File(accountsDir, old).renameTo(File(accountsDir, new))
keyring.remove(old)?.let { keyring[new] = it }
saveKeyring()
refreshAccounts()
openAccount(new)
}
/** True when [master] opens [name]. Slow. */
fun checkMaster(name: String, master: ByteArray) = vault(name, NexoPass.vaultKey(master)).load()
fun detachAccount(name: String) {
if (session?.name == name) {
session?.close()
session = null
}
File(accountsDir, name).deleteRecursively()
keyring.remove(name)?.let { it.master.fill(0); it.vaultKey.fill(0) }
saveKeyring()
refreshAccounts()
if (accounts.isEmpty()) {
// Nothing left: start over.
lock.wipe()
lock()
} else {
openAccount(accounts.first())
}
}
private fun saveKeyring() {
innerKey?.let { lock.save(it, keyring) }
}
fun lock() {
session?.close()
session = null
keyring.values.forEach { it.master.fill(0); it.vaultKey.fill(0) }
keyring = LinkedHashMap()
innerKey?.fill(0)
innerKey = null
needsMaster = null
needsAppSetup = false
appUnlocked = false
migrated = false
}
}
/** One open account. Nothing here is written to disk except its vault. */
class Session(val name: String, private val vault: Vault, val words: List<String>, val prefs: Prefs, master: ByteArray) {
private val master = master.copyOf()
private val cache = ConcurrentHashMap<Rec, String>()
var records by mutableStateOf(listOf<Rec>())
private set
fun load(): Boolean {
if (!vault.load()) return false
records = vault.records.toList()
return true
}
fun close() {
master.fill(0)
cache.clear()
}
val current get() = records.filter { it.current }.sortedWith(compareBy({ it.site }, { it.user }))
fun password(r: Rec) = cache.getOrPut(r) {
NexoPass.password(master, words, r.site, r.user, r.version, r.mode)
}
fun checkWords() = NexoPass.checkWords(master, words)
fun add(site: String, user: String, version: Int, mode: String) = edit { add(site, user, version, mode) }
fun rotate(r: Rec, mode: String) = edit { rotate(r, mode) }
fun remove(r: Rec) = edit { remove(r); r }
fun removeVersion(r: Rec) = edit { removeVersion(r); r }
/** Encrypted copy of the site list; slow (Argon2). */
fun exportBytes(): ByteArray =
Export.encrypt(NexoPass.exportPassphrase(master), Vault.text(vault.records).toByteArray())
/** Merges an export into the list. Number of new entries, or null if it doesn't open. Slow. */
fun importBytes(data: ByteArray): Int? {
val plain = Export.decrypt(NexoPass.exportPassphrase(master), data) ?: return null
val n = vault.mergeIn(Vault.parse(plain.decodeToString()))
records = vault.records.toList()
return n
}
private fun edit(block: Vault.() -> Rec): Rec {
val r = vault.block()
records = vault.records.toList()
return r
}
}
sealed interface Screen {
data object List : Screen
data object Add : Screen
data object Settings : Screen
data object NewAccount : Screen
data class Detail(val site: String, val user: String) : Screen
}

View file

@ -0,0 +1,75 @@
package com.nexoniarz.nexopass.app
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import com.nexoniarz.nexopass.core.VaultCodec
import java.io.File
/** What opens one account: its vault key and its master. */
class Unlocked(val vaultKey: ByteArray, val master: ByteArray)
sealed interface UnlockResult {
class Ok(val innerKey: ByteArray, val keyring: MutableMap<String, Unlocked>) : UnlockResult
class Wrong(val attemptsLeft: Int) : UnlockResult
/** Too many wrong attempts (or the stored data is gone): recover with a master. */
object Wiped : UnlockResult
}
/**
* The app unlock: a secret (PIN, password, pattern) that opens the keyring
* holding every account's master. Phone and PC store it differently.
*/
interface AppLock {
val isSet: Boolean
/** pin, password, pattern; "none" when not set. */
val method: String
/** Methods this platform offers, best first. */
val methods: List<String>
/** Why [secret] is not acceptable for [method], or null. */
fun problem(method: String, secret: String): String?
/** New secret; returns the key that encrypts the keyring. Slow. */
fun setup(method: String, secret: String, keyring: Map<String, Unlocked>): ByteArray
/** Re-encrypts the keyring after accounts changed. */
fun save(innerKey: ByteArray, keyring: Map<String, Unlocked>)
/** Slow. Wrong guesses are counted; too many wipe the unlock. */
fun unlock(secret: String): UnlockResult
fun wipe()
/** off, strong, device. */
val bioMode: String get() = "off"
fun bioOptions(): List<String> = emptyList()
fun enableBio(mode: String, innerKey: ByteArray, done: (String?) -> Unit) = done("Not available")
fun disableBio() {}
/** Errors are "" when the user just cancelled. */
fun unlockBio(cancelText: String, onOk: (UnlockResult.Ok) -> Unit, onError: (String) -> Unit) = onError("")
}
/** Everything that differs between Android and the PC. */
interface Platform {
val lock: AppLock
val codec: VaultCodec
/** Folder with one sub-folder per account. */
val accountsDir: File
val prefs: Prefs
/** True once after an older single-vault layout was moved into the account "main". */
val migrated: Boolean
/** Shown in About. */
val aboutText: String
/** PC: auto-lock never goes below this, so file dialogs don't lock the app. */
val minAutoLockMinutes: Int get() = 0
fun resource(name: String): ByteArray
fun copySecret(text: String, seconds: Int)
val animationsEnabled: Boolean
@Composable fun BackHandler(enabled: Boolean, onBack: () -> Unit)
/** Locks the app after the configured time away. */
@Composable fun AutoLock(app: AppState)
/** Returns a function that asks where to save, then writes [content]. */
@Composable fun rememberExport(content: () -> ByteArray, onDone: (Boolean) -> Unit): (suggestedName: String) -> Unit
/** Returns a function that asks for a file and passes its bytes (null if none/unreadable). */
@Composable fun rememberImport(onBytes: (ByteArray?) -> Unit): () -> Unit
}
val LocalPlatform = staticCompositionLocalOf<Platform> { error("No platform") }

View file

@ -0,0 +1,62 @@
package com.nexoniarz.nexopass.app
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/** Where settings live: SharedPreferences on Android, the script's config files on the PC. */
interface KeyValue {
fun getInt(key: String, def: Int): Int
fun putInt(key: String, value: Int)
fun getString(key: String, def: String): String
fun putString(key: String, value: String)
fun getBool(key: String, def: Boolean): Boolean
fun putBool(key: String, value: Boolean)
}
/** App settings. Nothing secret is stored here. Backed by Compose state so the UI follows changes. */
class Prefs(private val kv: KeyValue) {
/** system, dark, light */
var theme by StringPref("theme", "system")
var particles by BoolPref("particles", true)
/** Minutes in the background before the app locks; 0 = as soon as you leave. */
var autoLockMinutes by IntPref("auto_lock", 5)
var clipSeconds by IntPref("clip_seconds", 30)
/** words, words<N> or chars<N> */
var defaultMode by StringPref("default_mode", "words")
var maxAttempts by IntPref("max_attempts", 5)
var failedAttempts by IntPref("failed", 0)
/** pin, password, pattern (Android; only meaningful while the unlock is set) */
var quickMethod by StringPref("quick_method", "none")
var currentAccount by StringPref("current_account", "main")
/** off, strong (fingerprint/face), device (biometrics or phone screen lock) */
var bioMode by StringPref("bio_mode", "off")
private inner class IntPref(val key: String, def: Int) : ReadWriteProperty<Any?, Int> {
private val state = mutableIntStateOf(kv.getInt(key, def))
override fun getValue(thisRef: Any?, property: KProperty<*>) = state.intValue
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
state.intValue = value
kv.putInt(key, value)
}
}
private inner class StringPref(val key: String, def: String) : ReadWriteProperty<Any?, String> {
private val state = mutableStateOf(kv.getString(key, def))
override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
state.value = value
kv.putString(key, value)
}
}
private inner class BoolPref(val key: String, def: Boolean) : ReadWriteProperty<Any?, Boolean> {
private val state = mutableStateOf(kv.getBool(key, def))
override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) {
state.value = value
kv.putBool(key, value)
}
}
}

View file

@ -0,0 +1,379 @@
package com.nexoniarz.nexopass.core
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.params.Argon2Parameters
import java.io.File
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.LocalDate
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
// Must stay byte-for-byte compatible with the nexopass bash script.
object NexoPass {
const val WORDLIST_SHA256 = "addd35536511597a02fa0a9ff1e5284677b8883b83e986e43f15a3db996b903e"
// Changing any of these changes every password.
private const val SALT_PREFIX = "nexopass:v1"
private const val ARGON_T = 3
private const val ARGON_M_POW = 16 // 2^16 KiB = 64 MiB
private const val ARGON_P = 1
private const val SITE_BYTES = 1024
private const val WORD_COUNT = 5 // "words" means 5; other counts are "words<N>"
private const val UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ"
private const val LOWER = "abcdefghijkmnopqrstuvwxyz"
private const val DIGITS = "23456789"
private const val SYMBOLS = "!@#$%&*?-_+="
private val TWO_PART_TLDS = setOf(
"com.pl", "net.pl", "org.pl", "edu.pl", "gov.pl", "info.pl", "biz.pl",
"co.uk", "org.uk", "ac.uk", "com.au", "co.jp", "com.br",
)
const val MAX_MASTER_BYTES = 127
const val MIN_LENGTH = 12
const val MAX_LENGTH = 64
const val MIN_WORDS = 5
const val MAX_WORDS = 15
/** Mode string for a number of words. 5 stays "words" so older entries keep their passwords. */
fun wordsMode(n: Int) = if (n == WORD_COUNT) "words" else "words$n"
/** Number of words for a words mode, or null for a characters mode. */
fun wordCount(mode: String) = when {
mode == "words" -> WORD_COUNT
mode.startsWith("words") -> mode.removePrefix("words").toInt()
else -> null
}
fun loadWords(bytes: ByteArray): List<String> {
val sum = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
require(sum == WORDLIST_SHA256) { "Word list differs from the EFF original" }
return bytes.decodeToString().lines().filter { it.isNotEmpty() }.map { it.substringAfter('\t') }
}
private fun isAsciiSpace(c: Char) = c == ' ' || c in '\t'..'\r'
private fun asciiLower(s: String) = String(CharArray(s.length) { s[it].let { c -> if (c in 'A'..'Z') c + 32 else c } })
/** 'https://www.Allegro.pl/konto' -> 'allegro', 'Discord' -> 'discord', invalid -> null. */
fun normalizeSite(raw: String): String? {
var s = asciiLower(raw).trim(::isAsciiSpace)
if ("://" in s) s = s.substringAfter("://")
val cut = s.indexOfFirst { it == '/' || it == '?' || it == '#' }
if (cut >= 0) s = s.substring(0, cut)
s = s.substringAfterLast('@').substringBefore(':').removePrefix("www.").removeSuffix(".")
if (s.isEmpty() || s.startsWith(".") || s.endsWith(".") || ".." in s) return null
val l = s.split('.')
val n = l.size
s = when {
n >= 3 && "${l[n - 2]}.${l[n - 1]}" in TWO_PART_TLDS -> l[n - 3]
n >= 2 -> l[n - 2]
else -> l[0]
}
return s.takeIf { it.isNotEmpty() && it.all { c -> c in 'a'..'z' || c in '0'..'9' || c == '-' } }
}
/** Lowercase per code point, like the script's towlower(). */
private fun lower(s: String): String {
val sb = StringBuilder(s.length)
s.codePoints().forEach { sb.appendCodePoint(Character.toLowerCase(it)) }
return sb.toString()
}
fun normalizeUser(raw: String): String? {
val u = lower(raw).trim(::isAsciiSpace)
return u.takeIf { it.none { c -> c < ' ' || c == '\u007f' } }
}
/** Lowercase, and any run of spaces/tabs/newlines becomes a single space. */
fun normalizeMaster(raw: String): String =
lower(raw).split(' ', '\t', '\n').filter { it.isNotEmpty() }.joinToString(" ")
const val MIN_MASTER_WORDS = 3
const val MIN_MASTER_BYTES = 12
enum class Strength(val label: String) {
TOO_SHORT("Too short: at least 3 words, 12 characters"),
MINIMUM("3 words: minimum"),
RECOMMENDED("4 words: recommended"),
STRONG("5 words: strong"),
SAFEST("6+ words: safest"),
}
/** Same rule as the script. Only used when a vault is created. */
fun masterStrength(master: String): Strength {
val n = master.split(' ').count { it.isNotEmpty() }
return when {
n < MIN_MASTER_WORDS || master.toByteArray().size < MIN_MASTER_BYTES -> Strength.TOO_SHORT
n == 3 -> Strength.MINIMUM
n == 4 -> Strength.RECOMMENDED
n == 5 -> Strength.STRONG
else -> Strength.SAFEST
}
}
/** Edit distance, a swap of two neighbouring letters counts as one. */
fun distance(a: String, b: String): Int {
val w = b.length + 1
val d = IntArray((a.length + 1) * w)
for (i in 0..a.length) d[i * w] = i
for (j in 0..b.length) d[j] = j
for (i in 1..a.length) for (j in 1..b.length) {
val cost = if (a[i - 1] == b[j - 1]) 0 else 1
var v = minOf(d[(i - 1) * w + j] + 1, d[i * w + j - 1] + 1, d[(i - 1) * w + j - 1] + cost)
if (i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1]) v = minOf(v, d[(i - 2) * w + j - 2] + 1)
d[i * w + j] = v
}
return d[a.length * w + b.length]
}
/** Sites close to [site]; empty when [site] itself is on the list. */
fun suggestions(site: String, user: String, current: List<Rec>): List<Rec> {
if (current.any { it.site == site && it.user == user }) return emptyList()
val limit = if (site.length <= 4) 1 else 2
return current.asSequence()
.filter { kotlin.math.abs(it.site.length - site.length) <= limit }
.map { it to distance(site, it.site) }
.filter { it.second <= limit }
.sortedBy { it.second }
.map { it.first }
.take(5)
.toList()
}
fun derive(master: ByteArray, salt: String, bytes: Int) = argon2(master, salt, ARGON_T, ARGON_M_POW, bytes)
/** Argon2id like the `argon2` command: salt as text, memory 2^[memPow] KiB, one lane. */
fun argon2(password: ByteArray, salt: String, iterations: Int, memPow: Int, bytes: Int): ByteArray {
val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
.withIterations(iterations)
.withMemoryPowOfTwo(memPow)
.withParallelism(ARGON_P)
.withSalt(salt.toByteArray())
.build()
val out = ByteArray(bytes)
Argon2BytesGenerator().apply { init(params) }.generateBytes(password, out)
return out
}
/** Consumes 32-bit numbers from derived bytes, the same way the script does. */
private class Picker(private val data: ByteArray) {
private var pos = 0
fun pick(n: Int): Int {
val limit = (1L shl 32) - (1L shl 32) % n
while (pos + 4 <= data.size) {
var x = 0L
for (k in 0 until 4) x = (x shl 8) or (data[pos + k].toLong() and 0xff)
pos += 4
if (x < limit) return (x % n).toInt()
}
error("Ran out of random data")
}
}
fun password(master: ByteArray, words: List<String>, site: String, user: String, version: Int, mode: String): String {
val p = Picker(derive(master, "$SALT_PREFIX:site:$site:$version:$mode:$user", SITE_BYTES))
val count = wordCount(mode)
if (count != null) {
val chosen = List(count) { words[p.pick(words.size)].replaceFirstChar { it.uppercaseChar() } }
return chosen.joinToString("-") + DIGITS[p.pick(DIGITS.length)]
}
val len = mode.removePrefix("chars").toInt()
val all = UPPER + LOWER + DIGITS + SYMBOLS
val c = ArrayList<Char>(len)
for (group in listOf(UPPER, LOWER, DIGITS, SYMBOLS)) c += group[p.pick(group.length)]
repeat(len - 4) { c += all[p.pick(all.length)] }
for (i in len - 1 downTo 1) {
val j = p.pick(i + 1)
val t = c[i]; c[i] = c[j]; c[j] = t
}
return c.joinToString("")
}
/** Two words that are always the same for the same master, on every device. */
fun checkWords(master: ByteArray, words: List<String>): String {
val p = Picker(derive(master, "$SALT_PREFIX:check", 32))
return words[p.pick(words.size)] + " " + words[p.pick(words.size)]
}
fun vaultKey(master: ByteArray) = derive(master, "$SALT_PREFIX:vault", 32)
/** OpenPGP passphrase for export files, the same one the script uses. */
fun exportPassphrase(master: ByteArray) = hex(derive(master, "$SALT_PREFIX:export", 32)).toCharArray()
fun newMaster(words: List<String>, count: Int = 6): String {
val rnd = SecureRandom()
return List(count) { words[rnd.nextInt(words.size)] }.joinToString(" ")
}
}
/** One version of one site. until == "" means it is the current version. */
data class Rec(
val site: String,
val user: String,
val version: Int,
val mode: String,
val since: String,
val until: String = "",
) {
val current get() = until.isEmpty()
val label get() = if (user.isEmpty()) "$site v$version" else "$site ($user) v$version"
fun same(o: Rec) = site == o.site && user == o.user
}
/** How one account's site list is turned into bytes on disk. */
interface VaultCodec {
/** File name inside the account folder. */
val fileName: String
fun encode(key: ByteArray, text: String): ByteArray
/** null when the key is wrong or the data is damaged. */
fun decode(key: ByteArray, data: ByteArray): String?
}
/** Phone format: AES-256-GCM, "NXP1" + IV + ciphertext. */
object AesVaultCodec : VaultCodec {
private val MAGIC = "NXP1".toByteArray()
override val fileName = "vault.bin"
override fun encode(key: ByteArray, text: String): ByteArray {
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
return MAGIC + iv + cipher(Cipher.ENCRYPT_MODE, key, iv).doFinal(text.toByteArray())
}
override fun decode(key: ByteArray, data: ByteArray): String? {
if (data.size < MAGIC.size + 12 || !data.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) return null
return try {
cipher(Cipher.DECRYPT_MODE, key, data.copyOfRange(MAGIC.size, MAGIC.size + 12))
.doFinal(data, MAGIC.size + 12, data.size - MAGIC.size - 12).decodeToString()
} catch (e: javax.crypto.AEADBadTagException) {
null
}
}
private fun cipher(mode: Int, key: ByteArray, iv: ByteArray) = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
updateAAD(MAGIC)
}
}
/** Script format: OpenPGP (AES-256), passphrase = the vault key as hex, like `gpg` in nexopass. */
object PgpVaultCodec : VaultCodec {
override val fileName = "vault.gpg"
override fun encode(key: ByteArray, text: String) = Export.encrypt(hex(key).toCharArray(), text.toByteArray())
override fun decode(key: ByteArray, data: ByteArray) = Export.decrypt(hex(key).toCharArray(), data)?.decodeToString()
}
fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }
/**
* Site list with version archive, encrypted with a key derived from the
* master. Holds no passwords, only what is needed to derive them again.
*/
class Vault(private val file: File, private val key: ByteArray, private val codec: VaultCodec) {
val records = mutableListOf<Rec>()
fun exists() = file.exists()
/** Returns false when the key is wrong (or the file is damaged). */
fun load(): Boolean {
records.clear()
if (!file.exists()) return true
val text = codec.decode(key, file.readBytes()) ?: return false
records += parse(text)
return true
}
fun save() {
val out = codec.encode(key, text(records))
file.parentFile?.mkdirs()
val tmp = File(file.parentFile, file.name + ".tmp")
tmp.writeBytes(out)
if (file.exists()) file.copyTo(File(file.parentFile, file.name + ".bak"), overwrite = true)
check(tmp.renameTo(file)) { "Could not save the vault" }
}
fun current(site: String, user: String) = records.firstOrNull { it.site == site && it.user == user && it.current }
fun currentList() = records.filter { it.current }.sortedWith(compareBy({ it.site }, { it.user }))
fun history(site: String, user: String) =
records.filter { it.site == site && it.user == user }.sortedBy { it.version }
fun add(site: String, user: String, version: Int, mode: String): Rec {
check(current(site, user) == null) { "Already on the list" }
return Rec(site, user, version, mode, today()).also { records += it; save() }
}
fun rotate(old: Rec, newMode: String = old.mode): Rec {
val i = records.indexOf(old)
check(i >= 0 && old.current)
records[i] = old.copy(until = today())
return Rec(old.site, old.user, old.version + 1, newMode, today()).also { records += it; save() }
}
/** Deletes one archived version; the current one stays. */
fun removeVersion(r: Rec) {
check(!r.current)
records.remove(r)
save()
}
fun remove(r: Rec) {
records.removeAll { it.same(r) }
save()
}
/** Adds what [other] knows; returns how many site versions were new. */
fun mergeIn(other: List<Rec>): Int {
val before = records.map { Triple(it.site, it.user, it.version) }.toSet()
val merged = merge(records, other)
records.clear()
records += merged
save()
return merged.count { Triple(it.site, it.user, it.version) !in before }
}
private fun today() = LocalDate.now().toString()
companion object {
private const val HEADER = "# nexopass vault v1"
private const val SEP = '\u001f'
/** Plain text form, shared by the vault, the script and export files. */
fun text(records: List<Rec>) = buildString {
append(HEADER).append('\n')
for (r in records) {
append(listOf(r.site, r.user, r.version, r.mode, r.since, r.until).joinToString(SEP.toString())).append('\n')
}
}
fun parse(text: String): List<Rec> = text.lines()
.filter { it.isNotEmpty() && !it.startsWith("#") }
.mapNotNull { line ->
val f = line.split(SEP)
val v = f.getOrNull(2)?.toIntOrNull()
if (f.size < 5 || v == null || NexoPass.normalizeSite(f[0]) != f[0]) null
else Rec(f[0], f[1], v, f[3], f[4], f.getOrElse(5) { "" })
}
/**
* Union of both lists. Per site and account the highest version is the
* current one, every lower version is archived.
*/
fun merge(a: List<Rec>, b: List<Rec>): List<Rec> =
(a + b).groupBy { it.site to it.user }.values.flatMap { group ->
val byVersion = group.groupBy { it.version }.toSortedMap()
.mapValues { (_, same) -> same.firstOrNull { !it.current } ?: same.first() }
val versions = byVersion.keys.toList()
versions.mapIndexed { i, v ->
val r = byVersion.getValue(v)
when {
i == versions.lastIndex -> r.copy(until = "")
r.current -> r.copy(until = byVersion.getValue(versions[i + 1]).since)
else -> r
}
}
}.sortedWith(compareBy({ it.site }, { it.user }, { it.version }))
}
}

View file

@ -0,0 +1,59 @@
package com.nexoniarz.nexopass.core
import org.bouncycastle.bcpg.HashAlgorithmTags
import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags
import org.bouncycastle.openpgp.PGPCompressedData
import org.bouncycastle.openpgp.PGPEncryptedDataGenerator
import org.bouncycastle.openpgp.PGPEncryptedDataList
import org.bouncycastle.openpgp.PGPLiteralData
import org.bouncycastle.openpgp.PGPLiteralDataGenerator
import org.bouncycastle.openpgp.PGPPBEEncryptedData
import org.bouncycastle.openpgp.PGPUtil
import org.bouncycastle.openpgp.bc.BcPGPObjectFactory
import org.bouncycastle.openpgp.operator.bc.BcPBEDataDecryptorFactory
import org.bouncycastle.openpgp.operator.bc.BcPBEKeyEncryptionMethodGenerator
import org.bouncycastle.openpgp.operator.bc.BcPGPDataEncryptorBuilder
import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider
import java.io.ByteArrayOutputStream
import java.security.SecureRandom
import java.util.Date
/**
* Export files are plain OpenPGP (AES-256, integrity protected), so the
* script opens them with gpg and the app opens what the script exports.
* The passphrase is derived from the master: only the same master opens them.
*/
object Export {
fun encrypt(passphrase: CharArray, plain: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
val gen = PGPEncryptedDataGenerator(
BcPGPDataEncryptorBuilder(SymmetricKeyAlgorithmTags.AES_256)
.setWithIntegrityPacket(true)
.setSecureRandom(SecureRandom()),
)
gen.addMethod(BcPBEKeyEncryptionMethodGenerator(passphrase, BcPGPDigestCalculatorProvider().get(HashAlgorithmTags.SHA256)))
gen.open(out, ByteArray(1 shl 12)).use { enc ->
PGPLiteralDataGenerator().open(enc, PGPLiteralData.BINARY, "nexopass", plain.size.toLong(), Date()).use { it.write(plain) }
}
return out.toByteArray()
}
/** Returns null when the passphrase is wrong or the file is not a valid export. */
fun decrypt(passphrase: CharArray, data: ByteArray): ByteArray? = try {
val list = BcPGPObjectFactory(PGPUtil.getDecoderStream(data.inputStream())).let { f ->
generateSequence { f.nextObject() }.filterIsInstance<PGPEncryptedDataList>().first()
}
val pbe = list.encryptedDataObjects.asSequence().filterIsInstance<PGPPBEEncryptedData>().first()
val clear = pbe.getDataStream(BcPBEDataDecryptorFactory(passphrase, BcPGPDigestCalculatorProvider()))
var factory = BcPGPObjectFactory(clear)
var obj = factory.nextObject()
if (obj is PGPCompressedData) {
factory = BcPGPObjectFactory(obj.dataStream)
obj = factory.nextObject()
}
val bytes = (obj as PGPLiteralData).inputStream.readBytes()
if (pbe.isIntegrityProtected && !pbe.verify()) null else bytes
} catch (e: Exception) {
null
}
}

View file

@ -0,0 +1,301 @@
package com.nexoniarz.nexopass.ui
import com.nexoniarz.nexopass.app.*
import com.nexoniarz.nexopass.core.*
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
val accountNameRegex = Regex("^[a-z0-9][a-z0-9_-]{0,31}$")
/** Logo, title and a line of text above the forms. */
@Composable
fun Header(subtitle: String) {
Logo(80.dp)
Spacer(Modifier.height(16.dp))
Text("NexoPass", style = MaterialTheme.typography.displaySmall.copy(brush = BrandBrush, fontWeight = FontWeight.Bold))
Text(
subtitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(22.dp))
}
@Composable
fun CenteredColumn(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxSize().systemBarsPadding().imePadding().verticalScroll(rememberScrollState()).padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
content = content,
)
}
// --- master forms --------------------------------------------------------------------------
/** One master field with Show; for opening an existing account. */
@Composable
fun MasterUnlockForm(busy: Boolean, button: String, onSubmit: (String) -> Unit) {
val master = remember { MasterState() }
var revealed by remember { mutableStateOf(false) }
fun submit() {
val m = master.value()
if (m.isNotEmpty() && !busy) onSubmit(m)
}
MasterInput(master, "Master", revealed) { submit() }
TextButton(onClick = { revealed = !revealed }) {
Icon(if (revealed) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(if (revealed) "Hide" else "Show")
}
GradientButton(if (busy) "Checking…" else button, enabled = !busy, onClick = ::submit)
}
/** Name + master twice; for a new account. */
@Composable
fun CreateAccountForm(app: AppState, busy: Boolean, defaultName: String, onCreate: (String, String) -> Unit) {
var name by remember { mutableStateOf(defaultName) }
val master = remember { MasterState() }
val repeat = remember { MasterState() }
var revealed by remember { mutableStateOf(false) }
var problem by remember { mutableStateOf<String?>(null) }
val value = master.value()
val norm = name.trim().lowercase()
fun submit() {
val m = master.value()
problem = when {
!accountNameRegex.matches(norm) -> "Account name: lowercase letters, digits, - or _."
norm in app.accounts -> "There is already an account called '$norm'."
m.isEmpty() -> "Type the master first."
m.toByteArray().size > NexoPass.MAX_MASTER_BYTES -> "Master is too long (max 127 bytes)."
NexoPass.masterStrength(m) == NexoPass.Strength.TOO_SHORT -> "Too short: at least 3 words and 12 characters."
m != repeat.value() -> "Masters don't match."
else -> null
}
if (problem == null && !busy) onCreate(norm, m)
}
OutlinedTextField(
name, { name = it }, Modifier.fillMaxWidth(), singleLine = true,
label = { Text("Account name") },
leadingIcon = { Icon(Icons.Rounded.AccountCircle, null) },
supportingText = { Text("e.g. main, work. Each account has its own master and site list.") },
shape = MaterialTheme.shapes.medium,
)
Spacer(Modifier.height(6.dp))
Text(
"The master: a new one, or the one you already use on your PC (same master, same passwords).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
MasterInput(master, "Master", revealed) {}
Spacer(Modifier.height(12.dp))
MasterInput(repeat, "Repeat master", revealed) { submit() }
if (value.isNotEmpty()) StrengthBar(NexoPass.masterStrength(value))
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { revealed = !revealed }) {
Icon(if (revealed) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(if (revealed) "Hide" else "Show")
}
Spacer(Modifier.weight(1f))
TextButton(onClick = {
val m = NexoPass.newMaster(app.words)
master.set(m)
repeat.set(m)
revealed = true
}) {
Icon(Icons.Rounded.Casino, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Random 6 words")
}
}
problem?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 8.dp)) }
GradientButton(if (busy) "Creating…" else "Create account", enabled = !busy, onClick = ::submit)
}
@Composable
fun StrengthBar(s: NexoPass.Strength) {
val (progress, color) = when (s) {
NexoPass.Strength.TOO_SHORT -> .15f to MaterialTheme.colorScheme.error
NexoPass.Strength.MINIMUM -> .45f to Color(0xFFF59E0B)
NexoPass.Strength.RECOMMENDED -> .7f to Color(0xFF34D399)
NexoPass.Strength.STRONG -> .85f to Color(0xFF34D399)
NexoPass.Strength.SAFEST -> 1f to Cyan
}
Column(Modifier.padding(top = 12.dp)) {
LinearProgressIndicator(
progress = { progress },
color = color,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
)
Text(s.label, style = MaterialTheme.typography.bodySmall, color = color, modifier = Modifier.padding(top = 6.dp))
}
}
// --- screens -------------------------------------------------------------------------------
/** Required right after unlocking with a master: the app unlock replaces typing the master. */
@Composable
fun AppUnlockSetupScreen(app: AppState) {
val scope = rememberCoroutineScope()
var method by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var askBio by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val bioOptions = remember { app.lock.bioOptions() }
CenteredColumn {
Header(if (askBio) "One more thing." else "Choose how you unlock NexoPass. You won't type your master anymore.")
GlassCard {
if (!askBio) {
app.lock.methods.forEach { m ->
when (m) {
"pin" -> MethodCard(Icons.Rounded.Pin, "PIN", "Quick to type.") { method = m }
"password" -> MethodCard(Icons.Rounded.Password, "Password", "The strongest option.") { method = m }
"pattern" -> MethodCard(Icons.Rounded.Pattern, "Pattern", "Connect 4+ dots.") { method = m }
}
}
Text(
"Too many wrong tries remove it; then you recover with your master. Keep the master in your head.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
} else {
Text("Unlock with your fingerprint too?", style = MaterialTheme.typography.titleMedium)
Text(
"Your ${methodName(app.lock.method)} stays as the backup.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
)
GradientButton("Turn on", enabled = true) {
app.lock.enableBio(bioOptions.first(), app.innerKey()) { err ->
if (err == null) app.finishAppSetup() else if (err.isNotEmpty()) error = err
}
}
TextButton(onClick = { app.finishAppSetup() }, modifier = Modifier.align(Alignment.CenterHorizontally)) { Text("Not now") }
}
AnimatedVisibility(busy) { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 12.dp).clip(CircleShape)) }
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp)) }
}
}
method?.let { m ->
SecretSetupDialog(m, onCancel = { method = null }) { secret ->
method = null
busy = true
scope.launch {
withContext(Dispatchers.Default) { app.setupApp(m, secret) }
busy = false
if (bioOptions.isNotEmpty()) askBio = true else app.finishAppSetup()
}
}
}
}
@Composable
private fun MethodCard(icon: ImageVector, title: String, text: String, onClick: () -> Unit) {
Row(
Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).clickable(onClick = onClick).padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(44.dp).clip(CircleShape), contentAlignment = Alignment.Center) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.fillMaxSize()) {}
Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer)
}
Column(Modifier.weight(1f).padding(start = 14.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.Rounded.ChevronRight, null)
}
}
/** An account whose master isn't in the keyring yet (added before, or after a recovery). */
@Composable
fun AccountMasterScreen(app: AppState, name: String, onAddAccount: () -> Unit) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
CenteredColumn {
Header("Account '$name' needs its master once. After that, your app unlock opens it.")
GlassCard {
MasterUnlockForm(busy, "Open '$name'") { m ->
busy = true
error = null
scope.launch {
val ok = withContext(Dispatchers.Default) { app.unlockWithMaster(name, m.toByteArray()) }
busy = false
if (!ok) error = "That master doesn't open '$name'."
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp)) }
}
val others = app.accounts.filter { it != name }
if (others.isNotEmpty()) {
Text("Or switch to:", modifier = Modifier.padding(top = 16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant)
others.forEach { a -> TextButton(onClick = { app.openAccount(a) }) { Text(a) } }
}
Row(Modifier.padding(top = 8.dp)) {
TextButton(onClick = onAddAccount) { Text("Add account") }
TextButton(onClick = app::lock) { Text("Lock") }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewAccountScreen(app: AppState, onBack: () -> Unit, onDone: () -> Unit) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text("New account") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
)
},
) { pad ->
Column(Modifier.padding(pad).imePadding().verticalScroll(rememberScrollState()).padding(16.dp)) {
GlassCard {
CreateAccountForm(app, busy, defaultName = "") { name, m ->
busy = true
scope.launch {
withContext(Dispatchers.Default) { app.createAccount(name, m.toByteArray()) }
busy = false
onDone()
}
}
}
}
}
}

View file

@ -0,0 +1,72 @@
package com.nexoniarz.nexopass.ui
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.runtime.*
import com.nexoniarz.nexopass.app.AppState
import com.nexoniarz.nexopass.app.LocalPlatform
import com.nexoniarz.nexopass.app.Screen
/** The whole UI, same on the phone and the PC. */
@Composable
fun App(app: AppState) {
val platform = LocalPlatform.current
platform.AutoLock(app)
val stage = when {
!app.appUnlocked -> 0
app.needsAppSetup -> 1
else -> 2
}
AnimatedContent(
targetState = stage,
transitionSpec = { (fadeIn(tween(400)) + scaleIn(initialScale = .96f)) togetherWith fadeOut(tween(200)) },
label = "stage",
) { st ->
when (st) {
0 -> LockScreen(app)
1 -> AppUnlockSetupScreen(app)
else -> UnlockedApp(app)
}
}
}
@Composable
private fun UnlockedApp(app: AppState) {
val platform = LocalPlatform.current
var screen by remember { mutableStateOf<Screen>(Screen.List) }
platform.BackHandler(screen != Screen.List) { screen = Screen.List }
val needs = app.needsMaster
val session = app.session
if (screen == Screen.NewAccount) {
NewAccountScreen(app, onBack = { screen = Screen.List }, onDone = { screen = Screen.List })
return
}
if (needs != null || session == null) {
AccountMasterScreen(app, needs ?: app.current, onAddAccount = { screen = Screen.NewAccount })
return
}
AnimatedContent(
targetState = screen,
transitionSpec = {
val forward = targetState != Screen.List
(slideInHorizontally { if (forward) it / 4 else -it / 4 } + fadeIn()) togetherWith
(slideOutHorizontally { if (forward) -it / 4 else it / 4 } + fadeOut())
},
label = "screen",
) { s ->
when (s) {
Screen.List -> ListScreen(
app, session,
onOpen = { screen = Screen.Detail(it.site, it.user) },
onAdd = { screen = Screen.Add },
onSettings = { screen = Screen.Settings },
onNewAccount = { screen = Screen.NewAccount },
)
Screen.Add -> AddScreen(session, onBack = { screen = Screen.List }, onOpen = { screen = Screen.Detail(it.site, it.user) })
Screen.Settings -> SettingsScreen(app, session, onBack = { screen = Screen.List })
Screen.NewAccount -> {}
is Screen.Detail -> DetailScreen(session, s.site, s.user, onBack = { screen = Screen.List })
}
}
}

View file

@ -0,0 +1,111 @@
package com.nexoniarz.nexopass.ui
import com.nexoniarz.nexopass.app.*
import com.nexoniarz.nexopass.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Key
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun GlassCard(
modifier: Modifier = Modifier,
alpha: Float = .72f,
padding: Dp = 18.dp,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = alpha),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = .5f)),
modifier = modifier.fillMaxWidth(),
) {
Column(Modifier.padding(padding), content = content)
}
}
@Composable
fun GradientButton(text: String, enabled: Boolean, onClick: () -> Unit) {
Box(
Modifier
.fillMaxWidth()
.height(54.dp)
.clip(CircleShape)
.background(if (enabled) BrandBrush else SolidColor(MaterialTheme.colorScheme.surfaceVariant))
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = if (enabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
fun Logo(size: Dp) {
Box(Modifier.size(size).clip(CircleShape).background(BrandBrush), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Key, null, tint = Color.White, modifier = Modifier.size(size * .5f))
}
}
private val avatarColors = listOf(Violet, Cyan, Pink, Color(0xFFF59E0B), Color(0xFF34D399), Color(0xFF60A5FA))
@Composable
fun Avatar(site: String, size: Dp) {
val c = avatarColors[(site.hashCode() and 0x7fffffff) % avatarColors.size]
Box(
Modifier.size(size).clip(CircleShape).background(Brush.linearGradient(listOf(c, c.copy(alpha = .55f)))),
contentAlignment = Alignment.Center,
) {
Text(site.take(1).uppercase(), color = Color.White, fontWeight = FontWeight.Bold, fontSize = (size.value * .42f).sp)
}
}
@Composable
fun Tag(text: String, color: Color) {
Surface(shape = CircleShape, color = color) {
Text(text, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
@Composable
fun SectionTitle(text: String) {
Text(
text.uppercase(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 1.5.sp,
modifier = Modifier.padding(top = 8.dp, start = 4.dp),
)
}
@Composable
fun EmptyState(text: String) {
Column(Modifier.fillMaxWidth().padding(top = 64.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Rounded.Key, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(12.dp))
Text(text, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
fun modeLabel(mode: String) = NexoPass.wordCount(mode)?.let { "$it words" } ?: "${mode.removePrefix("chars")} chars"

View file

@ -0,0 +1,283 @@
package com.nexoniarz.nexopass.ui
import com.nexoniarz.nexopass.app.*
import com.nexoniarz.nexopass.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Fingerprint
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
// --- master as words ---------------------------------------------------------------------
/** Master typed as words: every space turns the word into a chip. Case never matters. */
class MasterState {
val words: SnapshotStateList<String> = mutableStateListOf()
var text by mutableStateOf("")
fun onText(value: String) {
val parts = value.split(' ', '\t', '\n')
parts.dropLast(1).filter { it.isNotEmpty() }.forEach { words += it }
text = parts.last()
}
fun value() = NexoPass.normalizeMaster((words + text).joinToString(" "))
fun set(master: String) {
words.clear()
words += master.split(' ')
text = ""
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MasterInput(state: MasterState, label: String, revealed: Boolean, onDone: () -> Unit) {
Column {
if (state.words.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(bottom = 6.dp)) {
state.words.forEachIndexed { i, w ->
InputChip(
selected = false,
onClick = { state.words.removeAt(i) },
label = { Text(if (revealed) w.lowercase() else "•".repeat(w.length.coerceIn(3, 10))) },
trailingIcon = { Icon(Icons.Rounded.Close, "Remove word", Modifier.size(16.dp)) },
)
}
}
}
OutlinedTextField(
value = state.text,
onValueChange = state::onText,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(label) },
placeholder = { Text("type a word, then space") },
visualTransformation = if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onDone() }),
shape = MaterialTheme.shapes.medium,
)
}
}
// --- PIN ---------------------------------------------------------------------------------
@Composable
fun PinPad(onSubmit: (String) -> Unit, enabled: Boolean = true, clearKey: Any = Unit) {
var pin by remember(clearKey) { mutableStateOf("") }
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.height(20.dp)) {
repeat(maxOf(4, pin.length)) { i ->
Surface(
shape = CircleShape,
color = if (i < pin.length) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(14.dp),
) {}
}
}
Spacer(Modifier.height(20.dp))
val rows = listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9"), listOf("<", "0", "ok"))
for (row in rows) {
Row(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.padding(vertical = 7.dp)) {
for (k in row) {
when (k) {
"<" -> PadKey(icon = Icons.AutoMirrored.Rounded.Backspace, label = "Delete digit", enabled = enabled && pin.isNotEmpty()) { pin = pin.dropLast(1) }
"ok" -> PadKey(icon = Icons.Rounded.Check, label = "Confirm", enabled = enabled && pin.length >= 4, accent = true) {
val p = pin
pin = ""
onSubmit(p)
}
else -> PadKey(text = k, enabled = enabled && pin.length < 16) { pin += k }
}
}
}
}
}
}
@Composable
private fun PadKey(text: String? = null, icon: ImageVector? = null, label: String? = null, enabled: Boolean, accent: Boolean = false, onClick: () -> Unit) {
Surface(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
color = if (accent) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = .75f),
modifier = Modifier.size(70.dp),
) {
Box(contentAlignment = Alignment.Center) {
if (text != null) Text(text, fontSize = 26.sp, fontWeight = FontWeight.Medium)
if (icon != null) Icon(icon, label)
}
}
}
// --- pattern -----------------------------------------------------------------------------
/** 3x3 pattern. Gives back the dots in order as digits 1-9, e.g. "1478". */
@Composable
fun PatternLock(onDone: (String) -> Unit, enabled: Boolean = true) {
val path = remember { mutableStateListOf<Int>() }
var finger by remember { mutableStateOf<Offset?>(null) }
val done by rememberUpdatedState(onDone)
val dot = MaterialTheme.colorScheme.onSurfaceVariant
val line = MaterialTheme.colorScheme.primary
BoxWithConstraints(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
val side = minOf(maxWidth, 300.dp)
Canvas(
Modifier
.size(side)
.pointerInput(enabled) {
if (!enabled) return@pointerInput
fun hit(o: Offset): Int? {
val cell = size.width / 3f
val col = (o.x / cell).toInt()
val row = (o.y / cell).toInt()
if (col !in 0..2 || row !in 0..2) return null
val c = Offset((col + .5f) * cell, (row + .5f) * cell)
return if ((o - c).getDistance() < cell * .32f) row * 3 + col else null
}
detectDragGestures(
onDragStart = { o ->
path.clear()
hit(o)?.let { path += it }
finger = o
},
onDrag = { change, _ ->
finger = change.position
hit(change.position)?.let { if (it !in path) path += it }
},
onDragEnd = {
val p = path.joinToString("") { (it + 1).toString() }
path.clear()
finger = null
if (p.isNotEmpty()) done(p)
},
onDragCancel = {
path.clear()
finger = null
},
)
},
) {
val cell = size.width / 3f
fun center(i: Int) = Offset((i % 3 + .5f) * cell, (i / 3 + .5f) * cell)
for (i in 1 until path.size) drawLine(line, center(path[i - 1]), center(path[i]), 10f, StrokeCap.Round)
val f = finger
if (f != null && path.isNotEmpty()) drawLine(line.copy(alpha = .5f), center(path.last()), f, 10f, StrokeCap.Round)
for (i in 0 until 9) {
val on = i in path
drawCircle(if (on) line else dot.copy(alpha = .6f), radius = if (on) 18f else 11f, center = center(i))
if (on) drawCircle(line.copy(alpha = .18f), radius = cell * .28f, center = center(i))
}
}
}
}
// --- one entry for every quick-unlock method ----------------------------------------------
fun methodName(method: String) = when (method) {
"pin" -> "PIN"
"password" -> "password"
"pattern" -> "pattern"
else -> "master"
}
/** Asks once for a PIN, password or pattern. */
@Composable
fun SecretEntry(method: String, enabled: Boolean, clearKey: Any, onSubmit: (String) -> Unit) {
when (method) {
"pin" -> PinPad(onSubmit, enabled, clearKey)
"pattern" -> PatternLock(onSubmit, enabled)
else -> {
var pw by remember(clearKey) { mutableStateOf("") }
var shown by remember { mutableStateOf(false) }
Column {
OutlinedTextField(
pw, { pw = it }, Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Password") },
visualTransformation = if (shown) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (pw.isNotEmpty()) onSubmit(pw) }),
trailingIcon = { TextButton(onClick = { shown = !shown }) { Text(if (shown) "Hide" else "Show") } },
shape = MaterialTheme.shapes.medium,
)
Spacer(Modifier.height(12.dp))
GradientButton("Continue", enabled = enabled && pw.isNotEmpty()) { onSubmit(pw) }
}
}
}
}
/** New PIN/password/pattern: enter it, then repeat it. */
@Composable
fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> Unit) {
val lock = LocalPlatform.current.lock
var first by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var attempt by remember { mutableIntStateOf(0) }
Dialog(onDismissRequest = onCancel) {
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
if (first == null) "New ${methodName(method)}" else "Repeat ${methodName(method)}",
style = MaterialTheme.typography.headlineSmall,
)
Text(
"Opens NexoPass and all your accounts. Your masters keep working for recovery.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
)
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp)) }
SecretEntry(method, enabled = true, clearKey = attempt) { s ->
attempt++
val f = first
when {
f == null -> lock.problem(method, s)?.let { error = it } ?: run { first = s; error = null }
f != s -> { first = null; error = "They didn't match, start again." }
else -> onDone(s)
}
}
TextButton(onClick = onCancel, modifier = Modifier.padding(top = 8.dp)) { Text("Cancel") }
}
}
}
}
@Composable
fun BioButton(onClick: () -> Unit) {
FilledTonalButton(onClick = onClick, modifier = Modifier.clip(CircleShape)) {
Icon(Icons.Rounded.Fingerprint, null)
Spacer(Modifier.width(8.dp))
Text("Use biometrics")
}
}

View file

@ -0,0 +1,156 @@
package com.nexoniarz.nexopass.ui
import com.nexoniarz.nexopass.app.*
import com.nexoniarz.nexopass.core.*
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun LockScreen(app: AppState) {
val scope = rememberCoroutineScope()
val quick = app.lock
// Without an app unlock (update, too many wrong tries) or on request: a master.
var recover by remember { mutableStateOf(!quick.isSet) }
var account by remember { mutableStateOf(app.current) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var attempt by remember { mutableIntStateOf(0) }
fun unlockQuick(secret: String) {
busy = true
error = null
scope.launch {
val r = withContext(Dispatchers.Default) { quick.unlock(secret) }
when (r) {
is UnlockResult.Ok -> withContext(Dispatchers.Default) { app.unlockApp(r) }
is UnlockResult.Wrong -> {
attempt++
error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left."
}
UnlockResult.Wiped -> {
error = "Too many wrong attempts: the app unlock was removed. Recover with your master."
recover = true
}
}
busy = false
}
}
fun unlockBio() {
error = null
quick.unlockBio("Use ${methodName(quick.method)}", onOk = { r ->
busy = true
scope.launch {
withContext(Dispatchers.Default) { app.unlockApp(r) }
busy = false
}
}, onError = { if (it.isNotEmpty()) error = it })
}
fun unlockMaster(m: String) {
busy = true
error = null
scope.launch {
val ok = withContext(Dispatchers.Default) { app.unlockWithMaster(account, m.toByteArray()) }
busy = false
if (!ok) error = "That master doesn't open '$account'."
}
}
LaunchedEffect(Unit) {
if (!recover && quick.bioMode != "off") unlockBio()
}
if (app.accounts.isEmpty()) {
FirstRun(app)
return
}
CenteredColumn {
Header(
when {
!recover && quick.method == "pattern" -> "Draw your pattern."
!recover -> "Enter your ${methodName(quick.method)}."
app.migrated -> "NexoPass now has accounts and an app unlock. Enter your master once to set it up."
quick.isSet -> "Recover: enter the master of an account, then set a new app unlock."
else -> "Enter the master of an account, then choose your app unlock."
},
)
GlassCard {
if (!recover) {
SecretEntry(quick.method, enabled = !busy, clearKey = attempt) { unlockQuick(it) }
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = { recover = true; error = null }) { Text("Forgot it?") }
if (quick.bioMode != "off") BioButton(::unlockBio)
}
} else {
if (app.accounts.size > 1) {
Text("Account", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
AccountChips(app.accounts, account) { account = it }
Spacer(Modifier.height(8.dp))
}
MasterUnlockForm(busy, "Unlock '$account'") { unlockMaster(it) }
if (quick.isSet) {
Text(
"This replaces your app unlock. Other accounts will ask for their master once.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
)
}
}
AnimatedVisibility(error != null) {
Text(error.orEmpty(), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 10.dp))
}
AnimatedVisibility(busy) { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 14.dp).clip(CircleShape)) }
}
if (recover && quick.isSet) {
TextButton(onClick = { recover = false; error = null }, modifier = Modifier.padding(top = 8.dp)) {
Text("Back to ${methodName(quick.method)}")
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AccountChips(accounts: List<String>, selected: String, onPick: (String) -> Unit) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
accounts.forEach { a ->
FilterChip(selected = a == selected, onClick = { onPick(a) }, label = { Text(a) })
}
}
}
@Composable
private fun FirstRun(app: AppState) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
CenteredColumn {
Header("Welcome. Create your first account.")
GlassCard {
CreateAccountForm(app, busy, defaultName = "main") { name, m ->
busy = true
scope.launch {
withContext(Dispatchers.Default) { app.createAccount(name, m.toByteArray()) }
busy = false
}
}
}
}
}

View file

@ -0,0 +1,86 @@
package com.nexoniarz.nexopass.ui
import com.nexoniarz.nexopass.app.*
import com.nexoniarz.nexopass.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import kotlin.math.sqrt
import kotlin.random.Random
private const val COUNT = 46
private const val LINK_DP = 110f
/**
* Drifting dots joined by faint lines when they come close, over two soft
* glows. Stands still when turned off in settings or when the system
* turned animations off.
*/
@Composable
fun ParticleBackground(dark: Boolean, animated: Boolean, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
val bg = MaterialTheme.colorScheme.background
val dot = if (dark) Color.White else MaterialTheme.colorScheme.primary
val systemAnimations = LocalPlatform.current.animationsEnabled
val animate = animated && systemAnimations
val link = with(LocalDensity.current) { LINK_DP.dp2px(density) }
// Positions and speeds as fractions of the screen, so rotation keeps the layout.
val p = remember {
val r = Random(7)
Array(COUNT) { floatArrayOf(r.nextFloat(), r.nextFloat(), (r.nextFloat() - .5f) * .00012f, (r.nextFloat() - .5f) * .00012f, .6f + r.nextFloat() * 1.6f) }
}
var frame by remember { mutableLongStateOf(0L) }
if (animate) {
LaunchedEffect(Unit) {
var last = 0L
while (true) {
withFrameMillis { now ->
val dt = if (last == 0L) 16f else (now - last).coerceAtMost(48).toFloat()
last = now
for (q in p) {
q[0] = (q[0] + q[2] * dt + 1f) % 1f
q[1] = (q[1] + q[3] * dt + 1f) % 1f
}
frame = now
}
}
}
}
Box(modifier.fillMaxSize().background(bg)) {
Canvas(Modifier.fillMaxSize()) {
frame // read so every frame redraws
val w = size.width
val h = size.height
drawRect(Brush.radialGradient(listOf(Violet.copy(alpha = if (dark) .28f else .16f), Color.Transparent), Offset(w * .1f, h * .12f), w * .9f))
drawRect(Brush.radialGradient(listOf(Cyan.copy(alpha = if (dark) .18f else .12f), Color.Transparent), Offset(w * .95f, h * .85f), w * .9f))
for (i in 0 until COUNT) {
val a = p[i]
val ax = a[0] * w
val ay = a[1] * h
for (j in i + 1 until COUNT) {
val dx = ax - p[j][0] * w
val dy = ay - p[j][1] * h
val d = sqrt(dx * dx + dy * dy)
if (d < link) {
drawLine(dot.copy(alpha = (1f - d / link) * .22f), Offset(ax, ay), Offset(p[j][0] * w, p[j][1] * h), strokeWidth = 1.2f)
}
}
drawCircle(dot.copy(alpha = .55f), radius = a[4] * density, center = Offset(ax, ay))
}
}
content()
}
}
private fun Float.dp2px(density: Float) = this * density

View file

@ -0,0 +1,473 @@
package com.nexoniarz.nexopass.ui
import com.nexoniarz.nexopass.app.*
import com.nexoniarz.nexopass.core.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.LocalDate
const val APP_VERSION = "1.3.0"
private fun bioLabel(mode: String) = when (mode) {
"strong" -> "Fingerprint or face"
"device" -> "Biometrics or phone screen lock"
else -> "Off"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) {
val prefs = app.prefs
val quick = app.lock
val platform = LocalPlatform.current
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
fun toast(msg: String) = scope.launch { snackbar.currentSnackbarData?.dismiss(); snackbar.showSnackbar(msg) }
var dialog by remember { mutableStateOf<String?>(null) }
var setupMethod by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var shownMaster by remember { mutableStateOf<String?>(null) }
val bioOptions = remember { quick.bioOptions() }
// Read so the rows refresh after changes.
val method = prefs.quickMethod.let { quick.method }
val bio = prefs.bioMode.let { quick.bioMode }
val exportLauncher = platform.rememberExport({ session.exportBytes() }) { ok ->
toast(if (ok) "Exported. It opens only with this account's master, on the phone or the PC." else "Export failed.")
}
val importLauncher = platform.rememberImport { bytes ->
if (bytes == null) return@rememberImport
busy = true
scope.launch {
val n = withContext(Dispatchers.Default) { session.importBytes(bytes) }
busy = false
toast(if (n == null) "Can't open it: exported from another account (different master), or not a NexoPass export." else "Imported: $n new entr${if (n == 1) "y" else "ies"}.")
}
}
fun exportNow() = exportLauncher("nexopass-${session.name}-${LocalDate.now()}.pgp")
Scaffold(
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbar) },
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
)
},
) { pad ->
Column(Modifier.padding(pad).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(bottom = 32.dp)) {
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth().padding(bottom = 8.dp))
SectionTitle("Account: ${session.name}")
GlassCard(padding = 4.dp) {
SettingRow(Icons.Rounded.DriveFileRenameOutline, "Rename", "Currently \"${session.name}\"") { dialog = "rename" }
SettingRow(Icons.Rounded.Visibility, "Show master", "Needs your ${methodName(method)}") { dialog = "verify-master" }
SettingRow(Icons.Rounded.Upload, "Export", "Encrypted copy of this account's site list") { exportNow() }
SettingRow(Icons.Rounded.Download, "Import", "Merge an export from the phone or the PC") { importLauncher() }
SettingRow(Icons.Rounded.LinkOff, "Detach this account", "Remove it from this device", danger = true) { dialog = "detach" }
}
SectionTitle("App unlock")
GlassCard(padding = 4.dp) {
SettingRow(Icons.Rounded.Pin, "Unlock with", methodName(method).replaceFirstChar { it.uppercase() } + " · tap to change") { dialog = "verify-change" }
if (bioOptions.isNotEmpty()) {
SettingRow(Icons.Rounded.Fingerprint, "Biometrics", bioLabel(bio)) { dialog = "bio" }
}
val autoLock = maxOf(prefs.autoLockMinutes, platform.minAutoLockMinutes)
SettingRow(Icons.Rounded.Timer, "Auto-lock", if (autoLock == 0) "As soon as you leave the app" else "$autoLock min after leaving") { dialog = "autolock" }
SettingRow(Icons.Rounded.GppMaybe, "Wrong attempts allowed", "${prefs.maxAttempts}, then you recover with a master") { dialog = "attempts" }
}
SectionTitle("Passwords")
GlassCard(padding = 4.dp) {
SettingRow(Icons.Rounded.Password, "New sites use", modeLabel(prefs.defaultMode)) { dialog = "mode" }
SettingRow(Icons.Rounded.ContentPaste, "Clipboard clears after", "${prefs.clipSeconds} s") { dialog = "clip" }
}
SectionTitle("Appearance")
GlassCard(padding = 4.dp) {
SettingRow(Icons.Rounded.Palette, "Theme", prefs.theme.replaceFirstChar { it.uppercase() }) { dialog = "theme" }
SwitchRow(Icons.Rounded.AutoAwesome, "Animated background", "Particles behind everything", prefs.particles) { prefs.particles = it }
}
SectionTitle("About")
GlassCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Logo(48.dp)
Column(Modifier.padding(start = 14.dp)) {
Text("NexoPass $APP_VERSION", style = MaterialTheme.typography.titleMedium.copy(brush = BrandBrush, fontWeight = FontWeight.Bold))
Text("Made by Nexoniarz", style = MaterialTheme.typography.bodyMedium)
}
}
Spacer(Modifier.height(10.dp))
Text(
platform.aboutText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { dialog = "license" }, contentPadding = PaddingValues(0.dp)) { Text("License: Apache License 2.0") }
}
}
}
when (dialog) {
"rename" -> RenameDialog(app, session.name, onDismiss = { dialog = null }) { new ->
dialog = null
app.renameAccount(session.name, new)
}
"verify-master" -> VerifyDialog(app, "to see the master of '${session.name}'", onDismiss = { dialog = null }) {
dialog = null
shownMaster = app.masterOf(session.name)
}
"verify-change" -> VerifyDialog(app, "to change how you unlock", onDismiss = { dialog = null }) { dialog = "method" }
"method" -> ChoiceDialog(
"Unlock with",
quick.methods.map { it to methodName(it).replaceFirstChar { c -> c.uppercase() } },
method,
onDismiss = { dialog = null },
) { picked ->
dialog = null
setupMethod = picked
}
"bio" -> ChoiceDialog("Biometrics", listOf("off" to "Off") + bioOptions.map { it to bioLabel(it) }, bio, onDismiss = { dialog = null }) { picked ->
dialog = null
if (picked == "off") {
quick.disableBio()
toast("Biometrics off.")
} else {
quick.enableBio(picked, app.innerKey()) { err ->
if (err == null) toast("Biometric unlock is on.") else if (err.isNotEmpty()) toast(err)
}
}
}
"autolock" -> ChoiceDialog(
"Auto-lock",
listOf(0, 1, 5, 15, 30, 60).filter { it >= platform.minAutoLockMinutes }.map { it.toString() to if (it == 0) "Immediately" else "After $it min" },
prefs.autoLockMinutes.toString(),
onDismiss = { dialog = null },
) { prefs.autoLockMinutes = it.toInt(); dialog = null }
"attempts" -> ChoiceDialog("Wrong attempts allowed", listOf("3", "5", "10").map { it to it }, prefs.maxAttempts.toString(), onDismiss = { dialog = null }) {
prefs.maxAttempts = it.toInt()
dialog = null
}
"clip" -> ChoiceDialog("Clipboard clears after", listOf(10, 15, 30, 60, 120).map { it.toString() to "$it seconds" }, prefs.clipSeconds.toString(), onDismiss = { dialog = null }) {
prefs.clipSeconds = it.toInt()
dialog = null
}
"theme" -> ChoiceDialog("Theme", listOf("system" to "Follow the system", "dark" to "Dark", "light" to "Light"), prefs.theme, onDismiss = { dialog = null }) {
prefs.theme = it
dialog = null
}
"mode" -> {
var mode by remember { mutableStateOf(prefs.defaultMode) }
AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("New sites use") },
text = { Column { ModePicker(mode) { mode = it } } },
confirmButton = { TextButton(onClick = { prefs.defaultMode = mode; dialog = null }) { Text("Save") } },
dismissButton = { TextButton(onClick = { dialog = null }) { Text("Cancel") } },
)
}
"detach" -> DetachDialog(app, session.name, onExport = ::exportNow, onDismiss = { dialog = null }) {
dialog = null
onBack()
}
"license" -> LicenseDialog { dialog = null }
}
setupMethod?.let { m ->
SecretSetupDialog(m, onCancel = { setupMethod = null }) { secret ->
setupMethod = null
val hadBio = bio
busy = true
scope.launch {
withContext(Dispatchers.Default) { app.setupApp(m, secret) }
busy = false
toast("You now unlock with your ${methodName(m)}.")
// The biometric copy holds the old key: set it up again right away.
if (hadBio != "off") {
quick.enableBio(hadBio, app.innerKey()) { err ->
if (err != null && err.isNotEmpty()) toast(err) else if (err != null) toast("Biometrics are off now; turn them on again any time.")
}
}
}
}
}
shownMaster?.let { m ->
AlertDialog(
onDismissRequest = { shownMaster = null },
icon = { Icon(Icons.Rounded.Key, null) },
title = { Text("Master of '${session.name}'") },
text = {
Column {
Text(m, fontFamily = FontFamily.Monospace, fontSize = 20.sp, lineHeight = 30.sp)
Text(
"Make sure nobody is looking. Screenshots are blocked.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
},
confirmButton = { TextButton(onClick = { shownMaster = null }) { Text("Hide") } },
)
}
}
/** Confirms it's you with the app unlock (or biometrics). */
@Composable
private fun VerifyDialog(app: AppState, reason: String, onDismiss: () -> Unit, onOk: () -> Unit) {
val quick = app.lock
val scope = rememberCoroutineScope()
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var attempt by remember { mutableIntStateOf(0) }
Dialog(onDismissRequest = onDismiss) {
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Confirm it's you", style = MaterialTheme.typography.headlineSmall)
Text(
"Enter your ${methodName(quick.method)} $reason.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
)
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp)) }
SecretEntry(quick.method, enabled = !busy, clearKey = attempt) { secret ->
busy = true
scope.launch {
val r = withContext(Dispatchers.Default) { quick.unlock(secret) }
busy = false
attempt++
when (r) {
is UnlockResult.Ok -> onOk()
is UnlockResult.Wrong -> error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left."
UnlockResult.Wiped -> app.lock()
}
}
}
if (quick.bioMode != "off") {
Spacer(Modifier.height(12.dp))
BioButton {
quick.unlockBio("Cancel", onOk = { onOk() }, onError = { if (it.isNotEmpty()) error = it })
}
}
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 12.dp))
TextButton(onClick = onDismiss, modifier = Modifier.padding(top = 8.dp)) { Text("Cancel") }
}
}
}
}
@Composable
private fun RenameDialog(app: AppState, current: String, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(current) }
val norm = name.trim().lowercase()
val problem = when {
norm == current -> null
!accountNameRegex.matches(norm) -> "Lowercase letters, digits, - or _."
norm in app.accounts -> "There is already an account called '$norm'."
else -> null
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename account") },
text = {
OutlinedTextField(
name, { name = it }, singleLine = true,
label = { Text("Account name") },
isError = problem != null,
supportingText = { problem?.let { Text(it) } },
)
},
confirmButton = { TextButton(enabled = problem == null && norm != current, onClick = { onRename(norm) }) { Text("Rename") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/** Export offer, then app unlock, then the account's master, then "yes". */
@Composable
private fun DetachDialog(app: AppState, name: String, onExport: () -> Unit, onDismiss: () -> Unit, onDone: () -> Unit) {
val scope = rememberCoroutineScope()
var step by remember { mutableIntStateOf(0) }
var yes by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val master = remember { MasterState() }
when (step) {
0 -> AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Rounded.LinkOff, null) },
title = { Text("Detach '$name'?") },
text = {
Text(
"This removes the account from this phone: its site list with the archive and its saved master. " +
"Your logins keep their passwords and the master can recreate them, but the site list is gone " +
"unless you export it first (or it's still on your PC).",
)
},
confirmButton = { TextButton(onClick = { step = 1 }) { Text("Continue") } },
dismissButton = {
Row {
TextButton(onClick = onExport) { Text("Export first") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
1 -> VerifyDialog(app, "to detach '$name'", onDismiss = onDismiss) { step = 2 }
else -> Dialog(onDismissRequest = onDismiss) {
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Column(Modifier.padding(24.dp)) {
Text("Last check", style = MaterialTheme.typography.headlineSmall)
Text(
"Enter the master of '$name' and type yes.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp),
)
MasterInput(master, "Master of '$name'", revealed = false) {}
Spacer(Modifier.height(8.dp))
OutlinedTextField(yes, { yes = it }, Modifier.fillMaxWidth(), singleLine = true, label = { Text("Type yes") })
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp)) }
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 12.dp))
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) { Text("Cancel") }
Button(
enabled = !busy && yes.trim() == "yes" && master.value().isNotEmpty(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
onClick = {
busy = true
error = null
val m = master.value().toByteArray()
scope.launch {
val ok = withContext(Dispatchers.Default) { app.checkMaster(name, m) }
busy = false
if (ok) {
app.detachAccount(name)
onDone()
} else {
error = "That master doesn't open '$name'. Nothing was removed."
}
}
},
) { Text("Detach") }
}
}
}
}
}
}
@Composable
private fun LicenseDialog(onDismiss: () -> Unit) {
val platform = LocalPlatform.current
val text = remember { platform.resource("LICENSE").decodeToString() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Apache License 2.0") },
text = {
Text(
"Copyright 2026 Nexoniarz\n\n$text",
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
modifier = Modifier.verticalScroll(rememberScrollState()),
)
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
@Composable
private fun SettingRow(icon: ImageVector, title: String, value: String, enabled: Boolean = true, danger: Boolean = false, onClick: () -> Unit) {
val tint = when {
!enabled -> MaterialTheme.colorScheme.outline
danger -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
Row(
Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, tint = tint)
Column(Modifier.weight(1f).padding(start = 16.dp)) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = when {
!enabled -> MaterialTheme.colorScheme.outline
danger -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
},
)
Text(value, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun SwitchRow(icon: ImageVector, title: String, value: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f).padding(start = 16.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
Text(value, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked, onChange)
}
}
@Composable
private fun ChoiceDialog(
title: String,
options: List<Pair<String, String>>,
selected: String,
onDismiss: () -> Unit,
onPick: (String) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
options.forEach { (key, label) ->
Row(
Modifier.fillMaxWidth().selectable(selected = key == selected, onClick = { onPick(key) }).padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = key == selected, onClick = null)
Text(label, Modifier.padding(start = 12.dp))
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}

View file

@ -0,0 +1,465 @@
package com.nexoniarz.nexopass.ui
import com.nexoniarz.nexopass.app.*
import com.nexoniarz.nexopass.core.*
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// --- site list -----------------------------------------------------------------------------
@Composable
fun ListScreen(app: AppState, session: Session, onOpen: (Rec) -> Unit, onAdd: () -> Unit, onSettings: () -> Unit, onNewAccount: () -> Unit) {
val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
var checkWords by remember { mutableStateOf<String?>(null) }
val all = session.current
val q = query.trim().lowercase()
val list = all.filter { q.isEmpty() || q in it.site || q in it.user }
val guess = NexoPass.normalizeSite(query)
val didYouMean = if (list.isEmpty() && guess != null) NexoPass.suggestions(guess, "", all) else emptyList()
Scaffold(
containerColor = Color.Transparent,
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onAdd,
icon = { Icon(Icons.Rounded.Add, null) },
text = { Text("Add site") },
containerColor = MaterialTheme.colorScheme.primaryContainer,
)
},
) { pad ->
LazyColumn(
Modifier.fillMaxSize().padding(pad),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
Logo(44.dp)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text("NexoPass", style = MaterialTheme.typography.headlineSmall.copy(brush = BrandBrush, fontWeight = FontWeight.Bold))
AccountSwitcher(app, session, all.size, onNewAccount)
}
IconButton(onClick = { scope.launch { checkWords = withContext(Dispatchers.Default) { session.checkWords() } } }) {
Icon(Icons.Rounded.Verified, "Check words")
}
IconButton(onClick = onSettings) { Icon(Icons.Rounded.Settings, "Settings") }
FilledTonalIconButton(onClick = app::lock) { Icon(Icons.Rounded.Lock, "Lock") }
}
}
item {
OutlinedTextField(
query, { query = it }, Modifier.fillMaxWidth(),
placeholder = { Text("Search sites") },
leadingIcon = { Icon(Icons.Rounded.Search, null) },
trailingIcon = { if (query.isNotEmpty()) IconButton(onClick = { query = "" }) { Icon(Icons.Rounded.Close, "Clear") } },
singleLine = true,
shape = CircleShape,
colors = OutlinedTextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = .7f),
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = .85f),
),
)
}
if (didYouMean.isNotEmpty()) {
item { SectionTitle("Did you mean") }
items(didYouMean, key = { "s" + it.site + "\u001f" + it.user }) { SiteRow(it) { onOpen(it) } }
} else if (list.isEmpty()) {
item { EmptyState(if (all.isEmpty()) "No sites yet.\nTap \"Add site\" to start." else "Nothing found.") }
}
items(list, key = { it.site + "\u001f" + it.user }) { SiteRow(it) { onOpen(it) } }
}
}
app.firstRunCheck?.let { words ->
AlertDialog(
onDismissRequest = {},
icon = { Icon(Icons.Rounded.Verified, null) },
confirmButton = { TextButton(onClick = { app.firstRunCheck = null }) { Text("Got it") } },
title = { Text("Check words") },
text = { Text("$words\n\nYour PC shows the same two words for the same master. If they differ, the masters differ.") },
)
}
checkWords?.let {
AlertDialog(
onDismissRequest = { checkWords = null },
icon = { Icon(Icons.Rounded.Verified, null) },
confirmButton = { TextButton(onClick = { checkWords = null }) { Text("OK") } },
title = { Text("Check words") },
text = { Text(it, style = MaterialTheme.typography.titleLarge) },
)
}
}
@Composable
private fun SiteRow(r: Rec, onClick: () -> Unit) {
GlassCard(Modifier.clickable(onClick = onClick), padding = 14.dp) {
Row(verticalAlignment = Alignment.CenterVertically) {
Avatar(r.site, 44.dp)
Column(Modifier.weight(1f).padding(start = 14.dp)) {
Text(r.site, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (r.user.isNotEmpty()) Text(r.user, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Tag("v${r.version}", MaterialTheme.colorScheme.secondaryContainer)
Spacer(Modifier.width(6.dp))
Tag(modeLabel(r.mode), MaterialTheme.colorScheme.surfaceVariant)
}
}
}
/** "main ▾ · 12 sites": tap to switch account or add one. */
@Composable
private fun AccountSwitcher(app: AppState, session: Session, sites: Int, onNewAccount: () -> Unit) {
var open by remember { mutableStateOf(false) }
Box {
Row(
Modifier.clip(CircleShape).clickable { open = true }.padding(end = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(session.name, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Icon(Icons.Rounded.ArrowDropDown, "Switch account", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
Text(" · $sites site${if (sites == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
app.accounts.forEach { a ->
DropdownMenuItem(
text = { Text(a, fontWeight = if (a == session.name) FontWeight.Bold else FontWeight.Normal) },
leadingIcon = { Icon(if (a == session.name) Icons.Rounded.CheckCircle else Icons.Rounded.AccountCircle, null) },
onClick = { open = false; if (a != session.name) app.openAccount(a) },
)
}
HorizontalDivider()
DropdownMenuItem(text = { Text("Add account") }, leadingIcon = { Icon(Icons.Rounded.PersonAdd, null) }, onClick = { open = false; onNewAccount() })
}
}
}
// --- add site ------------------------------------------------------------------------------
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddScreen(session: Session, onBack: () -> Unit, onOpen: (Rec) -> Unit) {
var site by remember { mutableStateOf("") }
var user by remember { mutableStateOf("") }
var version by remember { mutableStateOf("1") }
var mode by remember { mutableStateOf(session.prefs.defaultMode) }
val normSite = NexoPass.normalizeSite(site)
val normUser = NexoPass.normalizeUser(user)
val ver = version.toIntOrNull()?.takeIf { it in 1..9999 }
val existing = if (normSite != null && normUser != null) session.current.firstOrNull { it.site == normSite && it.user == normUser } else null
val similar = if (normSite != null && normUser != null) NexoPass.suggestions(normSite, normUser, session.current) else emptyList()
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text("Add site") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
)
},
) { pad ->
Column(Modifier.padding(pad).imePadding().verticalScroll(rememberScrollState()).padding(16.dp)) {
GlassCard {
OutlinedTextField(
site, { site = it }, Modifier.fillMaxWidth(), singleLine = true,
label = { Text("Site") },
placeholder = { Text("discord, allegro.pl, …") },
leadingIcon = { Icon(Icons.Rounded.Language, null) },
supportingText = {
Text(
when {
site.isBlank() -> "Case doesn't matter. Links work too."
normSite == null -> "Not a valid site name."
else -> "Saved as: $normSite"
},
)
},
isError = site.isNotBlank() && normSite == null,
shape = MaterialTheme.shapes.medium,
)
OutlinedTextField(
user, { user = it }, Modifier.fillMaxWidth(), singleLine = true,
label = { Text("Account name (optional)") },
leadingIcon = { Icon(Icons.Rounded.Person, null) },
supportingText = { Text("Only if you have several accounts on this site.") },
shape = MaterialTheme.shapes.medium,
)
OutlinedTextField(
version, { version = it.filter(Char::isDigit) }, Modifier.fillMaxWidth(), singleLine = true,
label = { Text("Start at version") },
leadingIcon = { Icon(Icons.Rounded.History, null) },
supportingText = { Text("Keep 1, unless you already rotated this site on your PC.") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = ver == null,
shape = MaterialTheme.shapes.medium,
)
Spacer(Modifier.height(4.dp))
ModePicker(mode) { mode = it }
}
AnimatedVisibility(existing != null || similar.isNotEmpty()) {
GlassCard(Modifier.padding(top = 12.dp)) {
if (existing != null) {
Text("${existing.label} is already on your list.", style = MaterialTheme.typography.titleSmall)
TextButton(onClick = { onOpen(existing) }) { Text("Open it") }
} else {
Text("Did you mean:", style = MaterialTheme.typography.titleSmall)
similar.forEach { r ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable { onOpen(r) }.padding(vertical = 6.dp)) {
Avatar(r.site, 32.dp)
Text(r.label, Modifier.weight(1f).padding(start = 10.dp))
Text("Open", color = MaterialTheme.colorScheme.primary)
}
}
Text(
"If not, \"$normSite\" is a different site and you can add it below.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(16.dp))
GradientButton(
"Add",
enabled = normSite != null && normUser != null && ver != null && existing == null,
onClick = { onOpen(session.add(normSite!!, normUser!!, ver!!, mode)) },
)
}
}
}
@Composable
fun ModePicker(mode: String, onChange: (String) -> Unit) {
val words = NexoPass.wordCount(mode)
val chars = words == null
val length = if (chars) mode.removePrefix("chars").toInt() else 20
val count = words ?: 5
Text("Password type", style = MaterialTheme.typography.titleSmall)
Row(Modifier.padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = !chars, onClick = { onChange(NexoPass.wordsMode(count)) }, label = { Text("Words") })
FilterChip(selected = chars, onClick = { onChange("chars$length") }, label = { Text("Characters") })
}
if (chars) {
Text(
"$length random characters, for sites with a length limit",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Slider(
value = length.toFloat(),
onValueChange = { onChange("chars${it.toInt()}") },
valueRange = NexoPass.MIN_LENGTH.toFloat()..NexoPass.MAX_LENGTH.toFloat(),
steps = NexoPass.MAX_LENGTH - NexoPass.MIN_LENGTH - 1,
)
} else {
Text(
"$count words, easy to type. More words, stronger password.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Slider(
value = count.toFloat(),
onValueChange = { onChange(NexoPass.wordsMode(it.toInt())) },
valueRange = NexoPass.MIN_WORDS.toFloat()..NexoPass.MAX_WORDS.toFloat(),
steps = NexoPass.MAX_WORDS - NexoPass.MIN_WORDS - 1,
)
}
}
// --- site detail ---------------------------------------------------------------------------
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailScreen(session: Session, site: String, user: String, onBack: () -> Unit) {
val history = session.records.filter { it.site == site && it.user == user }.sortedByDescending { it.version }
val current = history.firstOrNull { it.current }
var confirmRotate by remember { mutableStateOf(false) }
var confirmRemove by remember { mutableStateOf(false) }
var deleteVersion by remember { mutableStateOf<Rec?>(null) }
val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val platform = LocalPlatform.current
val seconds = session.prefs.clipSeconds
val onCopy: (String) -> Unit = { pw ->
platform.copySecret(pw, seconds)
scope.launch { snackbar.currentSnackbarData?.dismiss(); snackbar.showSnackbar("Copied. Clipboard clears in $seconds s.") }
}
if (current == null) {
LaunchedEffect(Unit) { onBack() }
return
}
Scaffold(
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbar) },
topBar = {
TopAppBar(
title = {},
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") } },
actions = { IconButton(onClick = { confirmRemove = true }) { Icon(Icons.Rounded.Delete, "Remove") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
)
},
) { pad ->
LazyColumn(
Modifier.padding(pad),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Avatar(site, 64.dp)
Column(Modifier.padding(start = 16.dp)) {
Text(site, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
if (user.isNotEmpty()) Text(user, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
item { PasswordCard(session, current, highlight = true, onCopy = onCopy) }
item {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = .55f),
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.WarningAmber, null, tint = MaterialTheme.colorScheme.onErrorContainer)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text("Data breach?", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onErrorContainer)
Text("Get a brand new password, the old one is archived.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer)
}
FilledTonalButton(onClick = { confirmRotate = true }) { Text("v${current.version + 1}") }
}
}
}
val old = history.filter { !it.current }
if (old.isNotEmpty()) {
item { SectionTitle("Archive") }
items(old, key = { it.version }) { PasswordCard(session, it, highlight = false, onCopy = onCopy, onDelete = { deleteVersion = it }) }
}
}
}
if (confirmRotate) {
var mode by remember { mutableStateOf(current.mode) }
AlertDialog(
onDismissRequest = { confirmRotate = false },
icon = { Icon(Icons.Rounded.Autorenew, null) },
title = { Text("Switch to version ${current.version + 1}?") },
text = {
Column {
Text("Version ${current.version} goes to the archive. Then change the password on the site to the new one.")
Spacer(Modifier.height(12.dp))
ModePicker(mode) { mode = it }
}
},
confirmButton = { TextButton(onClick = { session.rotate(current, mode); confirmRotate = false }) { Text("Switch") } },
dismissButton = { TextButton(onClick = { confirmRotate = false }) { Text("Cancel") } },
)
}
deleteVersion?.let { r ->
AlertDialog(
onDismissRequest = { deleteVersion = null },
icon = { Icon(Icons.Rounded.Delete, null) },
title = { Text("Delete v${r.version} from the archive?") },
text = { Text("Only this old version disappears from the list. The current password doesn't change.") },
confirmButton = { TextButton(onClick = { session.removeVersion(r); deleteVersion = null }) { Text("Delete") } },
dismissButton = { TextButton(onClick = { deleteVersion = null }) { Text("Cancel") } },
)
}
if (confirmRemove) {
AlertDialog(
onDismissRequest = { confirmRemove = false },
icon = { Icon(Icons.Rounded.Delete, null) },
title = { Text("Remove ${current.label}?") },
text = { Text("Removes the site and its archive from the list. The passwords themselves don't change, you can add the site again any time.") },
confirmButton = { TextButton(onClick = { session.remove(current); confirmRemove = false; onBack() }) { Text("Remove") } },
dismissButton = { TextButton(onClick = { confirmRemove = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun PasswordCard(session: Session, r: Rec, highlight: Boolean, onCopy: (String) -> Unit, onDelete: (() -> Unit)? = null) {
var pw by remember(r) { mutableStateOf<String?>(null) }
var revealed by remember(r) { mutableStateOf(false) }
LaunchedEffect(r) { pw = withContext(Dispatchers.Default) { session.password(r) } }
val border = if (highlight) Modifier.border(1.5.dp, BrandBrush, MaterialTheme.shapes.large) else Modifier
GlassCard(border, alpha = if (highlight) .85f else .55f) {
Row(verticalAlignment = Alignment.CenterVertically) {
Tag("v${r.version}", if (highlight) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant)
Spacer(Modifier.width(8.dp))
Text(
modeLabel(r.mode) + " · " + if (r.current) "since ${r.since}" else "${r.since} → ${r.until}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
AnimatedContent(
targetState = if (pw == null) 0 else if (revealed) 2 else 1,
transitionSpec = { fadeIn(tween(250)) togetherWith fadeOut(tween(150)) },
label = "pw",
) { state ->
when (state) {
0 -> LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 12.dp))
2 -> Text(pw.orEmpty(), fontFamily = FontFamily.Monospace, fontSize = if (highlight) 19.sp else 16.sp, lineHeight = 26.sp)
else -> Text("•".repeat(16), fontFamily = FontFamily.Monospace, fontSize = if (highlight) 19.sp else 16.sp, letterSpacing = 2.sp)
}
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalIconButton(enabled = pw != null, onClick = { revealed = !revealed }) {
Icon(if (revealed) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, if (revealed) "Hide" else "Show")
}
if (highlight) {
Button(enabled = pw != null, onClick = { onCopy(pw!!) }, modifier = Modifier.weight(1f)) {
Icon(Icons.Rounded.ContentCopy, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Copy password")
}
} else {
FilledTonalIconButton(enabled = pw != null, onClick = { onCopy(pw!!) }) { Icon(Icons.Rounded.ContentCopy, "Copy") }
Spacer(Modifier.weight(1f))
if (onDelete != null) IconButton(onClick = onDelete) { Icon(Icons.Rounded.DeleteOutline, "Delete this version") }
}
}
}
}

View file

@ -0,0 +1,95 @@
package com.nexoniarz.nexopass.ui
import com.nexoniarz.nexopass.app.*
import com.nexoniarz.nexopass.core.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
val Violet = Color(0xFF8B5CF6)
val Cyan = Color(0xFF22D3EE)
val Pink = Color(0xFFF472B6)
private val Dark = darkColorScheme(
primary = Color(0xFFB69CFF),
onPrimary = Color(0xFF24005A),
primaryContainer = Color(0xFF4B2A9E),
onPrimaryContainer = Color(0xFFEADDFF),
secondary = Cyan,
onSecondary = Color(0xFF00363F),
secondaryContainer = Color(0xFF0E4E5A),
onSecondaryContainer = Color(0xFFB8F2FF),
tertiary = Pink,
onTertiary = Color(0xFF4A0029),
background = Color(0xFF07080F),
onBackground = Color(0xFFE6E1F2),
surface = Color(0xFF0D0F1C),
onSurface = Color(0xFFE6E1F2),
surfaceVariant = Color(0xFF1E2135),
onSurfaceVariant = Color(0xFFC6C2DA),
surfaceContainerLow = Color(0xFF12142A),
surfaceContainer = Color(0xFF171A31),
surfaceContainerHigh = Color(0xFF1E2139),
surfaceContainerHighest = Color(0xFF262A44),
outline = Color(0xFF6E6A86),
outlineVariant = Color(0xFF34364E),
error = Color(0xFFFF8A9A),
errorContainer = Color(0xFF5C1020),
onErrorContainer = Color(0xFFFFDADF),
)
private val Light = lightColorScheme(
primary = Color(0xFF6236D9),
onPrimary = Color.White,
primaryContainer = Color(0xFFE9DDFF),
onPrimaryContainer = Color(0xFF22005D),
secondary = Color(0xFF00788C),
onSecondary = Color.White,
secondaryContainer = Color(0xFFC2F1FA),
onSecondaryContainer = Color(0xFF001F25),
tertiary = Color(0xFFB0306E),
onTertiary = Color.White,
background = Color(0xFFF7F4FF),
onBackground = Color(0xFF1B1A24),
surface = Color(0xFFFBF9FF),
onSurface = Color(0xFF1B1A24),
surfaceVariant = Color(0xFFE6E0F4),
onSurfaceVariant = Color(0xFF48455A),
surfaceContainerLow = Color(0xFFF3EFFC),
surfaceContainer = Color(0xFFEEE9F9),
surfaceContainerHigh = Color(0xFFE8E3F5),
surfaceContainerHighest = Color(0xFFE2DCF0),
outline = Color(0xFF79758C),
outlineVariant = Color(0xFFCAC4DC),
)
private val NexoShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(20.dp),
large = RoundedCornerShape(28.dp),
extraLarge = RoundedCornerShape(36.dp),
)
/** Violet to cyan, used for the logo, headline and main buttons. */
val BrandBrush = Brush.linearGradient(listOf(Violet, Cyan))
@Composable
fun NexoTheme(dark: Boolean, content: @Composable () -> Unit) {
val scheme = if (dark) Dark else Light
MaterialTheme(colorScheme = scheme, shapes = NexoShapes) {
// Screens sit on transparent backgrounds (so the particles show) and
// translucent cards; Material can't pick a text color for those and
// would fall back to black, so set it here for the whole app.
CompositionLocalProvider(LocalContentColor provides scheme.onBackground, content = content)
}
}