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:
Nexoniarz 2026-10-08 14:29:36 +02:00
commit b38efcaea0
37 changed files with 11855 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.gradle/
.idea/
.kotlin/
build/
local.properties
*.iml
captures/

202
LICENSE Normal file
View file

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

7
NOTICE Normal file
View file

@ -0,0 +1,7 @@
NexoPass
Copyright 2026 Nexoniarz
Licensed under the Apache License, Version 2.0 (see LICENSE).
Includes the EFF Large Wordlist (CC BY 3.0 US, Electronic Frontier Foundation)
and Bouncy Castle (MIT License).

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# NexoPass
Passwords derived from a master, nothing stored in plain text. Android app, compatible with the `nexopass` script for NixOS.
*Made by **Nexoniarz***
## How it works
Every password is computed, never saved:
```
password = Argon2id(master, site + version + type + login)
```
The same master gives the same password on the phone and on the PC. What the app does store is the site list of each account (which sites, their current version, an archive of old versions), encrypted with AES-256-GCM under a key derived from that account's master.
- **Accounts.** Several accounts, each with its own master and site list. Switch between them in one tap.
- **App unlock.** A PIN, password or pattern (required), plus fingerprint/face if you want. It unlocks every account; the master is only typed to create or recover an account. The unlock data is encrypted twice, once with the Android Keystore (hardware), and too many wrong tries remove it.
- **Data breach?** Rotate the site to a new version; the old one goes to the archive.
- **Passwords** of 5-15 words or 12-64 characters, chosen per site.
- **Typos** like "discrod" get a "did you mean discord?".
- **Export/import**: OpenPGP files, the same format as the script, so the site list moves between phone and PC.
- No internet permission, screenshots blocked, clipboard cleared after a while, auto-lock, no cloud backup.
Android 8.0 or newer. Material 3, light and dark theme, animated background.
## Build
```
./gradlew assembleRelease
```
The APK lands in `app/build/outputs/apk/release/`. Release builds are signed with the debug key so they install right away; use your own key if you publish the app.
## Compatibility
`Core.kt` must stay byte-for-byte compatible with the script: same Argon2id parameters, salts, word list (EFF Large Wordlist, checked by SHA-256), site-name rules and password layout. Changing any of them changes every password.
## License
Apache License 2.0, see [LICENSE](LICENSE) and [NOTICE](NOTICE).

56
app/build.gradle.kts Normal file
View 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
View 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.**

View 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
View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,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()
}
}
}
}
}
}

View 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)
}

View 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 }))
}
}

View 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
}
}

View 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")
}
}

View 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
}
}
}
}
}

View 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 })
}
}
}

View 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

View 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()
}
}
}

View 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")
}
}
}
}

View 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") } },
)
}

View 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") }
}
}
}
}

View 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)
}
}

View 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>

View 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>

View 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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="nexo_bg">#FF07080F</color>
</resources>

View 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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="nexo_bg">#FFF7F4FF</color>
</resources>

View 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>

View 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>

5
build.gradle.kts Normal file
View file

@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}

4
gradle.properties Normal file
View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# 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
#
# https://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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

16
settings.gradle.kts Normal file
View file

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "NexoPass"
include(":app")