mirror of
https://github.com/Nexoniarz/NexoPass.git
synced 2026-11-03 20:44:27 +00:00
NexoPass 1.2.0: Android app
Accounts with their own masters, required app unlock (PIN, password, pattern, biometrics), 5-15 word or 12-64 character passwords, archive, typo suggestions, encrypted export/import compatible with the nexopass script. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b38efcaea0
37 changed files with 11855 additions and 0 deletions
56
app/build.gradle.kts
Normal file
56
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.nexoniarz.nexopass"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.nexoniarz.nexopass"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "1.2.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
// Signed with the debug key so a release build installs right away.
|
||||
// Replace with your own key if you ever publish the app.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
packaging {
|
||||
// Bouncy Castle jars ship the same OSGi/versioned manifests.
|
||||
resources.excludes += setOf("META-INF/versions/9/OSGI-INF/MANIFEST.MF", "META-INF/DEPENDENCIES", "META-INF/LICENSE*", "META-INF/NOTICE*")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
// biometric pulls an old fragment that breaks file pickers (requestCode crash)
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.5")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
|
||||
implementation("org.bouncycastle:bcpg-jdk18on:1.79")
|
||||
}
|
||||
4
app/proguard-rules.pro
vendored
Normal file
4
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Argon2 from BouncyCastle is used directly, keep it intact.
|
||||
-keep class org.bouncycastle.crypto.generators.Argon2BytesGenerator { *; }
|
||||
-keep class org.bouncycastle.crypto.params.Argon2Parameters** { *; }
|
||||
-dontwarn org.bouncycastle.**
|
||||
27
app/src/main/AndroidManifest.xml
Normal file
27
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- No INTERNET permission: the app cannot send anything anywhere. -->
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="NexoPass"
|
||||
android:largeHeap="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.NexoPass">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|uiMode|density"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
202
app/src/main/assets/LICENSE
Normal file
202
app/src/main/assets/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
app/src/main/assets/eff_large_wordlist.txt
Normal file
7776
app/src/main/assets/eff_large_wordlist.txt
Normal file
File diff suppressed because it is too large
Load diff
297
app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt
Normal file
297
app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
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 activity = LocalContext.current as FragmentActivity
|
||||
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 { QuickUnlock.bioOptions(activity) }
|
||||
|
||||
CenteredColumn {
|
||||
Header(if (askBio) "One more thing." else "Choose how you unlock NexoPass. You won't type your master anymore.")
|
||||
GlassCard {
|
||||
if (!askBio) {
|
||||
MethodCard(Icons.Rounded.Pin, "PIN", "Fast. 4+ digits, 6 is better.") { method = "pin" }
|
||||
MethodCard(Icons.Rounded.Password, "Password", "The strongest option.") { method = "password" }
|
||||
MethodCard(Icons.Rounded.Pattern, "Pattern", "Connect 4+ dots.") { method = "pattern" }
|
||||
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.quick.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.quick.enableBio(activity, 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
app/src/main/java/com/nexoniarz/nexopass/Components.kt
Normal file
128
app/src/main/java/com/nexoniarz/nexopass/Components.kt
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PersistableBundle
|
||||
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"
|
||||
|
||||
/** Copies without a clipboard preview or keyboard history, and clears it after [seconds]. */
|
||||
fun copySecret(context: Context, text: String, seconds: Int) {
|
||||
val cm = context.getSystemService(ClipboardManager::class.java)
|
||||
val clip = ClipData.newPlainText("NexoPass", text)
|
||||
clip.description.extras = PersistableBundle().apply {
|
||||
putBoolean("android.content.extra.IS_SENSITIVE", true)
|
||||
}
|
||||
cm.setPrimaryClip(clip)
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (Build.VERSION.SDK_INT >= 28) cm.clearPrimaryClip() else cm.setPrimaryClip(ClipData.newPlainText("", ""))
|
||||
}, seconds * 1000L)
|
||||
}
|
||||
348
app/src/main/java/com/nexoniarz/nexopass/Core.kt
Normal file
348
app/src/main/java/com/nexoniarz/nexopass/Core.kt
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
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): ByteArray {
|
||||
val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
|
||||
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
|
||||
.withIterations(ARGON_T)
|
||||
.withMemoryPowOfTwo(ARGON_M_POW)
|
||||
.withParallelism(ARGON_P)
|
||||
.withSalt(salt.toByteArray())
|
||||
.build()
|
||||
val out = ByteArray(bytes)
|
||||
Argon2BytesGenerator().apply { init(params) }.generateBytes(master, 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) =
|
||||
derive(master, "$SALT_PREFIX:export", 32).joinToString("") { "%02x".format(it) }.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
|
||||
}
|
||||
|
||||
/**
|
||||
* Site list with version archive, AES-256-GCM 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) {
|
||||
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 data = file.readBytes()
|
||||
if (data.size < MAGIC.size + 12 || !data.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) return false
|
||||
val plain = try {
|
||||
cipher(Cipher.DECRYPT_MODE, data.copyOfRange(MAGIC.size, MAGIC.size + 12))
|
||||
.doFinal(data, MAGIC.size + 12, data.size - MAGIC.size - 12)
|
||||
} catch (e: javax.crypto.AEADBadTagException) {
|
||||
return false
|
||||
}
|
||||
records += parse(plain.decodeToString())
|
||||
return true
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val text = text(records)
|
||||
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||
val out = MAGIC + iv + cipher(Cipher.ENCRYPT_MODE, iv).doFinal(text.toByteArray())
|
||||
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" }
|
||||
}
|
||||
|
||||
private fun cipher(mode: Int, iv: ByteArray) = Cipher.getInstance("AES/GCM/NoPadding").apply {
|
||||
init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
|
||||
updateAAD(MAGIC)
|
||||
}
|
||||
|
||||
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 val MAGIC = "NXP1".toByteArray()
|
||||
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
app/src/main/java/com/nexoniarz/nexopass/Export.kt
Normal file
59
app/src/main/java/com/nexoniarz/nexopass/Export.kt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
287
app/src/main/java/com/nexoniarz/nexopass/Inputs.kt
Normal file
287
app/src/main/java/com/nexoniarz/nexopass/Inputs.kt
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum for each method; null when fine. */
|
||||
fun secretProblem(method: String, s: String) = when (method) {
|
||||
"pin" -> if (s.length < 4) "Use at least 4 digits (6 is better)." else null
|
||||
"pattern" -> if (s.length < 4) "Connect at least 4 dots." else null
|
||||
else -> if (s.length < 6) "Use at least 6 characters." else null
|
||||
}
|
||||
|
||||
/** New PIN/password/pattern: enter it, then repeat it. */
|
||||
@Composable
|
||||
fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> Unit) {
|
||||
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(
|
||||
if (method == "pin") "Unlocks NexoPass on this phone. Your master keeps working too."
|
||||
else "Used to unlock NexoPass on this phone.",
|
||||
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 -> secretProblem(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
app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt
Normal file
156
app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun LockScreen(app: AppState) {
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
val scope = rememberCoroutineScope()
|
||||
val quick = app.quick
|
||||
// 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 QuickUnlock.Result.Ok -> withContext(Dispatchers.Default) { app.unlockApp(r) }
|
||||
is QuickUnlock.Result.Wrong -> {
|
||||
attempt++
|
||||
error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left."
|
||||
}
|
||||
QuickUnlock.Result.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(activity, "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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
349
app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt
Normal file
349
app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// FragmentActivity because the system biometric prompt needs one.
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
// No screenshots, no preview in the recent apps screen. Debug builds allow
|
||||
// screenshots so the UI can be checked during development.
|
||||
if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) {
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
val words = NexoPass.loadWords(assets.open("eff_large_wordlist.txt").use { it.readBytes() })
|
||||
val prefs = Prefs(this)
|
||||
val app = AppState(filesDir, words, prefs)
|
||||
setContent {
|
||||
val dark = when (prefs.theme) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
NexoTheme(dark) {
|
||||
ParticleBackground(dark = dark, animated = prefs.particles) { App(app) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(filesDir: File, val words: List<String>, val prefs: Prefs) {
|
||||
private val accountsDir = File(filesDir, "accounts")
|
||||
val quick = QuickUnlock(filesDir, prefs)
|
||||
|
||||
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
|
||||
/** True after moving a pre-accounts vault into the account "main". */
|
||||
var migrated by mutableStateOf(false)
|
||||
private set
|
||||
var firstRunCheck by mutableStateOf<String?>(null)
|
||||
|
||||
private var keyring: MutableMap<String, Unlocked> = LinkedHashMap()
|
||||
private var innerKey: ByteArray? = null
|
||||
|
||||
init {
|
||||
// 1.0/1.1 kept one vault in filesDir; it becomes the account "main".
|
||||
val old = File(filesDir, "vault.bin")
|
||||
if (old.exists() && !File(accountsDir, "main").exists()) {
|
||||
File(accountsDir, "main").mkdirs()
|
||||
old.renameTo(File(accountsDir, "main/vault.bin"))
|
||||
File(filesDir, "vault.bin.bak").takeIf { it.exists() }?.renameTo(File(accountsDir, "main/vault.bin.bak"))
|
||||
quick.wipe() // the old format held a single master
|
||||
prefs.currentAccount = "main"
|
||||
migrated = true
|
||||
}
|
||||
refreshAccounts()
|
||||
}
|
||||
|
||||
val current get() = prefs.currentAccount
|
||||
|
||||
fun refreshAccounts() {
|
||||
accounts = accountsDir.listFiles()?.filter { File(it, "vault.bin").exists() }?.map { it.name }?.sorted() ?: emptyList()
|
||||
if (accounts.isNotEmpty() && current !in accounts) prefs.currentAccount = accounts.first()
|
||||
}
|
||||
|
||||
private fun vaultFile(name: String) = File(accountsDir, "$name/vault.bin")
|
||||
|
||||
/** After the PIN/password/pattern or biometrics. */
|
||||
fun unlockApp(r: QuickUnlock.Result.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(vaultFile(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(vaultFile(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, vaultFile(name), words, prefs, u)
|
||||
if (!s.load()) return false
|
||||
session?.close()
|
||||
session = s
|
||||
prefs.currentAccount = name
|
||||
return true
|
||||
}
|
||||
|
||||
/** Slow (Argon2). */
|
||||
fun setupApp(method: String, secret: String) {
|
||||
innerKey = quick.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(vaultFile(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.
|
||||
quick.wipe()
|
||||
lock()
|
||||
} else {
|
||||
openAccount(accounts.first())
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveKeyring() {
|
||||
innerKey?.let { quick.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, file: File, val words: List<String>, val prefs: Prefs, u: Unlocked) {
|
||||
private val master = u.master.copyOf()
|
||||
private val vault = Vault(file, u.vaultKey.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
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun App(app: AppState) {
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
var stoppedAt = 0L
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> stoppedAt = SystemClock.elapsedRealtime()
|
||||
Lifecycle.Event.ON_START -> {
|
||||
val limit = app.prefs.autoLockMinutes * 60_000L
|
||||
// Never lock in the middle of the required app-unlock setup.
|
||||
if (stoppedAt > 0 && !app.needsAppSetup && SystemClock.elapsedRealtime() - stoppedAt >= limit) app.lock()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
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) {
|
||||
var screen by remember { mutableStateOf<Screen>(Screen.List) }
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
88
app/src/main/java/com/nexoniarz/nexopass/Particles.kt
Normal file
88
app/src/main/java/com/nexoniarz/nexopass/Particles.kt
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.provider.Settings
|
||||
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.LocalContext
|
||||
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 context = LocalContext.current
|
||||
val systemAnimations = remember {
|
||||
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
|
||||
}
|
||||
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
|
||||
55
app/src/main/java/com/nexoniarz/nexopass/Prefs.kt
Normal file
55
app/src/main/java/com/nexoniarz/nexopass/Prefs.kt
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
/** App settings. Nothing secret is stored here. Backed by Compose state so the UI follows changes. */
|
||||
class Prefs(context: Context) {
|
||||
private val sp = context.getSharedPreferences("nexopass", Context.MODE_PRIVATE)
|
||||
|
||||
/** 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, 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 (only meaningful while quick.bin exists) */
|
||||
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(sp.getInt(key, def))
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>) = state.intValue
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
|
||||
state.intValue = value
|
||||
sp.edit().putInt(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class StringPref(val key: String, def: String) : ReadWriteProperty<Any?, String> {
|
||||
private val state = mutableStateOf(sp.getString(key, def) ?: def)
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
|
||||
state.value = value
|
||||
sp.edit().putString(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class BoolPref(val key: String, def: Boolean) : ReadWriteProperty<Any?, Boolean> {
|
||||
private val state = mutableStateOf(sp.getBoolean(key, def))
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) {
|
||||
state.value = value
|
||||
sp.edit().putBoolean(key, value).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
269
app/src/main/java/com/nexoniarz/nexopass/Security.kt
Normal file
269
app/src/main/java/com/nexoniarz/nexopass/Security.kt
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.AEADBadTagException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/** What opens one account: its vault key and its master. */
|
||||
class Unlocked(val vaultKey: ByteArray, val master: ByteArray)
|
||||
|
||||
/** Every account's keys, encrypted under the app unlock. */
|
||||
object Keyring {
|
||||
private const val SEP = '\u001f'
|
||||
|
||||
fun encode(k: Map<String, Unlocked>) = k.entries.joinToString("\n") { (name, u) ->
|
||||
name + SEP + u.vaultKey.joinToString("") { "%02x".format(it) } + SEP + u.master.decodeToString()
|
||||
}.toByteArray()
|
||||
|
||||
fun decode(b: ByteArray): MutableMap<String, Unlocked> = b.decodeToString().lines()
|
||||
.mapNotNull { line ->
|
||||
val f = line.split(SEP, limit = 3)
|
||||
if (f.size != 3 || f[1].length != 64) null
|
||||
else f[0] to Unlocked(f[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray(), f[2].toByteArray())
|
||||
}.toMap(LinkedHashMap())
|
||||
}
|
||||
|
||||
/**
|
||||
* The app unlock (PIN, password or pattern, plus optional biometrics).
|
||||
*
|
||||
* quick.bin: salt, then the keyring encrypted twice. Inner layer: AES-GCM with
|
||||
* innerKey = Argon2id(secret, salt). Outer layer: AES-GCM with a key that lives
|
||||
* in the phone's secure hardware (Android Keystore) and never leaves it, so a
|
||||
* copied file is useless elsewhere. Wrong secrets are counted and wipe it.
|
||||
*
|
||||
* bio.bin: innerKey, encrypted with a Keystore key that only works right after
|
||||
* a fingerprint/face (or screen lock) check. New fingerprints invalidate it.
|
||||
*/
|
||||
class QuickUnlock(dir: File, private val prefs: Prefs) {
|
||||
private val quickFile = File(dir, "quick.bin")
|
||||
private val bioFile = File(dir, "bio.bin")
|
||||
|
||||
val isSet get() = quickFile.exists()
|
||||
val method: String get() = if (isSet) prefs.quickMethod else "none"
|
||||
val bioMode: String get() = if (isSet && bioFile.exists()) prefs.bioMode else "off"
|
||||
|
||||
sealed interface Result {
|
||||
class Ok(val innerKey: ByteArray, val keyring: MutableMap<String, Unlocked>) : Result
|
||||
class Wrong(val attemptsLeft: Int) : Result
|
||||
/** Too many wrong attempts (or the hardware key is gone): recover with a master. */
|
||||
object Wiped : Result
|
||||
}
|
||||
|
||||
/** New secret: new salt and key. Returns the inner key. Slow (Argon2). */
|
||||
fun setup(method: String, secret: String, keyring: Map<String, Unlocked>): ByteArray {
|
||||
disableBio() // it holds the old inner key
|
||||
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val key = innerKey(secret, salt)
|
||||
write(salt, key, keyring)
|
||||
prefs.quickMethod = method
|
||||
prefs.failedAttempts = 0
|
||||
return key
|
||||
}
|
||||
|
||||
/** Re-encrypts the keyring after accounts changed. */
|
||||
fun save(innerKey: ByteArray, keyring: Map<String, Unlocked>) {
|
||||
write(quickFile.readBytes().copyOfRange(0, 16), innerKey, keyring)
|
||||
}
|
||||
|
||||
/** Slow (Argon2), run off the main thread. */
|
||||
fun unlock(secret: String): Result {
|
||||
val data = quickFile.readBytes()
|
||||
val inner = outer(data) ?: run { wipe(); return Result.Wiped }
|
||||
val key = innerKey(secret, data.copyOfRange(0, 16))
|
||||
val plain = try {
|
||||
gcm(Cipher.DECRYPT_MODE, key, inner)
|
||||
} catch (e: AEADBadTagException) {
|
||||
prefs.failedAttempts += 1
|
||||
val left = prefs.maxAttempts - prefs.failedAttempts
|
||||
if (left <= 0) {
|
||||
wipe()
|
||||
return Result.Wiped
|
||||
}
|
||||
return Result.Wrong(left)
|
||||
}
|
||||
prefs.failedAttempts = 0
|
||||
return Result.Ok(key, Keyring.decode(plain))
|
||||
}
|
||||
|
||||
private fun openWith(key: ByteArray): MutableMap<String, Unlocked>? {
|
||||
val inner = outer(quickFile.readBytes()) ?: return null
|
||||
return try { Keyring.decode(gcm(Cipher.DECRYPT_MODE, key, inner)) } catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
fun wipe() {
|
||||
quickFile.delete()
|
||||
bioFile.delete()
|
||||
prefs.quickMethod = "none"
|
||||
prefs.bioMode = "off"
|
||||
prefs.failedAttempts = 0
|
||||
KeyStore.getInstance(KEYSTORE).apply { load(null) }.run {
|
||||
for (a in listOf(ALIAS_QUICK, ALIAS_BIO)) if (containsAlias(a)) deleteEntry(a)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableBio() {
|
||||
bioFile.delete()
|
||||
prefs.bioMode = "off"
|
||||
KeyStore.getInstance(KEYSTORE).apply { load(null) }.run { if (containsAlias(ALIAS_BIO)) deleteEntry(ALIAS_BIO) }
|
||||
}
|
||||
|
||||
/** Asks for a fingerprint/face (or screen lock) and stores the inner key under it. */
|
||||
fun enableBio(activity: FragmentActivity, mode: String, innerKey: ByteArray, done: (String?) -> Unit) {
|
||||
disableBio()
|
||||
val cipher = Cipher.getInstance(GCM).apply { init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_BIO, mode)) }
|
||||
prompt(activity, cipher, mode, "Turn on biometric unlock", "Cancel", onOk = { c ->
|
||||
bioFile.writeBytes(c.iv + c.doFinal(innerKey))
|
||||
prefs.bioMode = mode
|
||||
done(null)
|
||||
}, onError = done)
|
||||
}
|
||||
|
||||
/** Fingerprint/face, then opens the keyring. Errors are "" when the user just cancelled. */
|
||||
fun unlockBio(activity: FragmentActivity, cancelText: String, onOk: (Result.Ok) -> Unit, onError: (String) -> Unit) {
|
||||
val data = bioFile.readBytes()
|
||||
val cipher = try {
|
||||
Cipher.getInstance(GCM).apply {
|
||||
init(Cipher.DECRYPT_MODE, keystoreKey(ALIAS_BIO, prefs.bioMode), GCMParameterSpec(128, data, 0, 12))
|
||||
}
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
disableBio()
|
||||
onError("Biometrics changed on this phone, so biometric unlock was turned off. Turn it on again in Settings.")
|
||||
return
|
||||
}
|
||||
prompt(activity, cipher, prefs.bioMode, "Unlock NexoPass", cancelText, onOk = { c ->
|
||||
val key = c.doFinal(data, 12, data.size - 12)
|
||||
val keyring = openWith(key)
|
||||
if (keyring == null) onError("Biometric unlock no longer matches. Use your ${methodName(method)}.")
|
||||
else onOk(Result.Ok(key, keyring))
|
||||
}, onError = onError)
|
||||
}
|
||||
|
||||
private fun write(salt: ByteArray, key: ByteArray, keyring: Map<String, Unlocked>) {
|
||||
val inner = gcm(Cipher.ENCRYPT_MODE, key, Keyring.encode(keyring))
|
||||
val outer = Cipher.getInstance(GCM).run {
|
||||
init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_QUICK, null))
|
||||
iv + doFinal(inner)
|
||||
}
|
||||
val tmp = File(quickFile.parentFile, "quick.tmp")
|
||||
tmp.writeBytes(salt + outer)
|
||||
check(tmp.renameTo(quickFile)) { "Could not save the app unlock" }
|
||||
}
|
||||
|
||||
/** Removes the hardware layer; null if the hardware key is gone. */
|
||||
private fun outer(data: ByteArray): ByteArray? = try {
|
||||
Cipher.getInstance(GCM).run {
|
||||
init(Cipher.DECRYPT_MODE, keystoreKey(ALIAS_QUICK, null), GCMParameterSpec(128, data, 16, 12))
|
||||
doFinal(data, 28, data.size - 28)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun prompt(
|
||||
activity: FragmentActivity,
|
||||
cipher: Cipher,
|
||||
mode: String,
|
||||
title: String,
|
||||
cancelText: String,
|
||||
onOk: (Cipher) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
try {
|
||||
onOk(result.cryptoObject!!.cipher!!)
|
||||
} catch (e: Exception) {
|
||||
onError("Biometric unlock failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
// Cancelling is not an error worth showing.
|
||||
val cancelled = errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
|
||||
errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_CANCELED
|
||||
onError(if (cancelled) "" else errString.toString())
|
||||
}
|
||||
}
|
||||
val info = BiometricPrompt.PromptInfo.Builder().setTitle(title).setSubtitle("NexoPass")
|
||||
if (mode == "device") {
|
||||
info.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
} else {
|
||||
info.setAllowedAuthenticators(BIOMETRIC_STRONG).setNegativeButtonText(cancelText)
|
||||
}
|
||||
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
|
||||
.authenticate(info.build(), BiometricPrompt.CryptoObject(cipher))
|
||||
}
|
||||
|
||||
private fun innerKey(secret: String, salt: ByteArray) =
|
||||
NexoPass.derive(secret.toByteArray(), "nexopass:quick:" + salt.joinToString("") { "%02x".format(it) }, 32)
|
||||
|
||||
/** iv + ciphertext with a plain AES key. */
|
||||
private fun gcm(mode: Int, key: ByteArray, data: ByteArray): ByteArray {
|
||||
val c = Cipher.getInstance(GCM)
|
||||
return if (mode == Cipher.ENCRYPT_MODE) {
|
||||
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||
c.init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
|
||||
iv + c.doFinal(data)
|
||||
} else {
|
||||
c.init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, data, 0, 12))
|
||||
c.doFinal(data, 12, data.size - 12)
|
||||
}
|
||||
}
|
||||
|
||||
private fun keystoreKey(alias: String, bioMode: String?): SecretKey {
|
||||
val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
(ks.getKey(alias, null) as? SecretKey)?.let { return it }
|
||||
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
if (bioMode != null) {
|
||||
spec.setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true)
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
val types = if (bioMode == "device") {
|
||||
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
|
||||
} else {
|
||||
KeyProperties.AUTH_BIOMETRIC_STRONG
|
||||
}
|
||||
spec.setUserAuthenticationParameters(0, types)
|
||||
}
|
||||
}
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
||||
.apply { init(spec.build()) }
|
||||
.generateKey()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEYSTORE = "AndroidKeyStore"
|
||||
private const val GCM = "AES/GCM/NoPadding"
|
||||
private const val ALIAS_QUICK = "nexopass.quick"
|
||||
private const val ALIAS_BIO = "nexopass.bio"
|
||||
|
||||
/** Which biometric options this phone supports. */
|
||||
fun bioOptions(activity: FragmentActivity): List<String> {
|
||||
val bm = BiometricManager.from(activity)
|
||||
return buildList {
|
||||
if (bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) add("strong")
|
||||
if (Build.VERSION.SDK_INT >= 30 &&
|
||||
bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
) add("device")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
486
app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt
Normal file
486
app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.platform.LocalContext
|
||||
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 androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
|
||||
const val APP_VERSION = "1.2.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.quick
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
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 { QuickUnlock.bioOptions(activity) }
|
||||
// Read so the rows refresh after changes.
|
||||
val method = prefs.quickMethod.let { quick.method }
|
||||
val bio = prefs.bioMode.let { quick.bioMode }
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pgp-encrypted")) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
runCatching { activity.contentResolver.openOutputStream(uri)!!.use { it.write(session.exportBytes()) } }.isSuccess
|
||||
}
|
||||
busy = false
|
||||
toast(if (ok) "Exported. It opens only with this account's master, here or with nexopass on PC." else "Export failed.")
|
||||
}
|
||||
}
|
||||
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
busy = true
|
||||
scope.launch {
|
||||
val n = withContext(Dispatchers.IO) {
|
||||
runCatching { activity.contentResolver.openInputStream(uri)!!.use { it.readBytes() } }.getOrNull()?.let { session.importBytes(it) }
|
||||
}
|
||||
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.launch("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 this phone or your PC") { importLauncher.launch(arrayOf("*/*")) }
|
||||
SettingRow(Icons.Rounded.LinkOff, "Detach this account", "Remove it from this phone", 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" }
|
||||
SettingRow(
|
||||
Icons.Rounded.Fingerprint, "Biometrics",
|
||||
if (bioOptions.isEmpty()) "Not available on this phone" else bioLabel(bio),
|
||||
enabled = bioOptions.isNotEmpty(),
|
||||
) { dialog = "bio" }
|
||||
SettingRow(Icons.Rounded.Timer, "Auto-lock", if (prefs.autoLockMinutes == 0) "As soon as you leave the app" else "${prefs.autoLockMinutes} 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(
|
||||
"Same passwords as nexopass on NixOS. Nothing leaves this phone: the app has no internet permission.",
|
||||
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",
|
||||
listOf("pin" to "PIN", "password" to "Password", "pattern" to "Pattern"),
|
||||
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(activity, 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).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(activity, 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.quick
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
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 QuickUnlock.Result.Ok -> onOk()
|
||||
is QuickUnlock.Result.Wrong -> error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left."
|
||||
QuickUnlock.Result.Wiped -> app.lock()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (quick.bioMode != "off") {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
BioButton {
|
||||
quick.unlockBio(activity, "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 context = LocalContext.current
|
||||
val text = remember { context.assets.open("LICENSE").use { it.readBytes().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") } },
|
||||
)
|
||||
}
|
||||
463
app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt
Normal file
463
app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
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.platform.LocalContext
|
||||
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 context = LocalContext.current
|
||||
val seconds = session.prefs.clipSeconds
|
||||
val onCopy: (String) -> Unit = { pw ->
|
||||
copySecret(context, 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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
app/src/main/java/com/nexoniarz/nexopass/Theme.kt
Normal file
92
app/src/main/java/com/nexoniarz/nexopass/Theme.kt
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
21
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
21
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:endX="108"
|
||||
android:endY="108">
|
||||
<item android:offset="0" android:color="#FF8B5CF6" />
|
||||
<item android:offset="1" android:color="#FF22D3EE" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
18
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
18
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:pivotX="54" android:pivotY="54" android:rotation="-45">
|
||||
<!-- Key bow: a ring. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M42,40a14,14 0,1 1,0 28a14,14 0,1 1,0 -28zM42,48.5a5.5,5.5 0,1 0,0 11a5.5,5.5 0,1 0,0 -11z" />
|
||||
<!-- Shaft with two teeth. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M54,50.5h26v7h-4v7h-7v-7h-3v5h-6v-5h-6z" />
|
||||
</group>
|
||||
</vector>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
4
app/src/main/res/values-night/colors.xml
Normal file
4
app/src/main/res/values-night/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="nexo_bg">#FF07080F</color>
|
||||
</resources>
|
||||
8
app/src/main/res/values-night/themes.xml
Normal file
8
app/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.NexoPass" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/nexo_bg</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
4
app/src/main/res/values/colors.xml
Normal file
4
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="nexo_bg">#FFF7F4FF</color>
|
||||
</resources>
|
||||
8
app/src/main/res/values/themes.xml
Normal file
8
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.NexoPass" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@color/nexo_bg</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
10
app/src/main/res/xml/data_extraction_rules.xml
Normal file
10
app/src/main/res/xml/data_extraction_rules.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Keep the vault out of cloud backups and device-to-device transfers. -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="file" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="file" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
Loading…
Reference in a new issue