mirror of
https://github.com/Nexoniarz/NexoPass.git
synced 2026-11-03 12:34:28 +00:00
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:
parent
b38efcaea0
commit
987cea431f
42 changed files with 1475 additions and 557 deletions
202
shared/resources/LICENSE
Normal file
202
shared/resources/LICENSE
Normal 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.
|
||||
7776
shared/resources/eff_large_wordlist.txt
Normal file
7776
shared/resources/eff_large_wordlist.txt
Normal file
File diff suppressed because it is too large
Load diff
229
shared/src/com/nexoniarz/nexopass/app/AppState.kt
Normal file
229
shared/src/com/nexoniarz/nexopass/app/AppState.kt
Normal 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
|
||||
}
|
||||
75
shared/src/com/nexoniarz/nexopass/app/Platform.kt
Normal file
75
shared/src/com/nexoniarz/nexopass/app/Platform.kt
Normal 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") }
|
||||
62
shared/src/com/nexoniarz/nexopass/app/Prefs.kt
Normal file
62
shared/src/com/nexoniarz/nexopass/app/Prefs.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
379
shared/src/com/nexoniarz/nexopass/core/Core.kt
Normal file
379
shared/src/com/nexoniarz/nexopass/core/Core.kt
Normal 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 }))
|
||||
}
|
||||
}
|
||||
59
shared/src/com/nexoniarz/nexopass/core/Export.kt
Normal file
59
shared/src/com/nexoniarz/nexopass/core/Export.kt
Normal 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
|
||||
}
|
||||
}
|
||||
301
shared/src/com/nexoniarz/nexopass/ui/AccountScreens.kt
Normal file
301
shared/src/com/nexoniarz/nexopass/ui/AccountScreens.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
shared/src/com/nexoniarz/nexopass/ui/App.kt
Normal file
72
shared/src/com/nexoniarz/nexopass/ui/App.kt
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
111
shared/src/com/nexoniarz/nexopass/ui/Components.kt
Normal file
111
shared/src/com/nexoniarz/nexopass/ui/Components.kt
Normal 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"
|
||||
283
shared/src/com/nexoniarz/nexopass/ui/Inputs.kt
Normal file
283
shared/src/com/nexoniarz/nexopass/ui/Inputs.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
156
shared/src/com/nexoniarz/nexopass/ui/LockScreen.kt
Normal file
156
shared/src/com/nexoniarz/nexopass/ui/LockScreen.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
shared/src/com/nexoniarz/nexopass/ui/Particles.kt
Normal file
86
shared/src/com/nexoniarz/nexopass/ui/Particles.kt
Normal 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
|
||||
473
shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt
Normal file
473
shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt
Normal 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") } },
|
||||
)
|
||||
}
|
||||
465
shared/src/com/nexoniarz/nexopass/ui/SiteScreens.kt
Normal file
465
shared/src/com/nexoniarz/nexopass/ui/SiteScreens.kt
Normal 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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
shared/src/com/nexoniarz/nexopass/ui/Theme.kt
Normal file
95
shared/src/com/nexoniarz/nexopass/ui/Theme.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue