mirror of
https://github.com/Nexoniarz/NexoPass.git
synced 2026-11-02 20:24:25 +00:00
NexoPass 1.2.0: Android app
Accounts with their own masters, required app unlock (PIN, password, pattern, biometrics), 5-15 word or 12-64 character passwords, archive, typo suggestions, encrypted export/import compatible with the nexopass script. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b38efcaea0
37 changed files with 11855 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.gradle/
|
||||
.idea/
|
||||
.kotlin/
|
||||
build/
|
||||
local.properties
|
||||
*.iml
|
||||
captures/
|
||||
202
LICENSE
Normal file
202
LICENSE
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
7
NOTICE
Normal file
7
NOTICE
Normal 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
41
README.md
Normal 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
56
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.nexoniarz.nexopass"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.nexoniarz.nexopass"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "1.2.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
// Signed with the debug key so a release build installs right away.
|
||||
// Replace with your own key if you ever publish the app.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
packaging {
|
||||
// Bouncy Castle jars ship the same OSGi/versioned manifests.
|
||||
resources.excludes += setOf("META-INF/versions/9/OSGI-INF/MANIFEST.MF", "META-INF/DEPENDENCIES", "META-INF/LICENSE*", "META-INF/NOTICE*")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
// biometric pulls an old fragment that breaks file pickers (requestCode crash)
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.5")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
|
||||
implementation("org.bouncycastle:bcpg-jdk18on:1.79")
|
||||
}
|
||||
4
app/proguard-rules.pro
vendored
Normal file
4
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Argon2 from BouncyCastle is used directly, keep it intact.
|
||||
-keep class org.bouncycastle.crypto.generators.Argon2BytesGenerator { *; }
|
||||
-keep class org.bouncycastle.crypto.params.Argon2Parameters** { *; }
|
||||
-dontwarn org.bouncycastle.**
|
||||
27
app/src/main/AndroidManifest.xml
Normal file
27
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- No INTERNET permission: the app cannot send anything anywhere. -->
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="NexoPass"
|
||||
android:largeHeap="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.NexoPass">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|uiMode|density"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
202
app/src/main/assets/LICENSE
Normal file
202
app/src/main/assets/LICENSE
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
7776
app/src/main/assets/eff_large_wordlist.txt
Normal file
7776
app/src/main/assets/eff_large_wordlist.txt
Normal file
File diff suppressed because it is too large
Load diff
297
app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt
Normal file
297
app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
val accountNameRegex = Regex("^[a-z0-9][a-z0-9_-]{0,31}$")
|
||||
|
||||
/** Logo, title and a line of text above the forms. */
|
||||
@Composable
|
||||
fun Header(subtitle: String) {
|
||||
Logo(80.dp)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("NexoPass", style = MaterialTheme.typography.displaySmall.copy(brush = BrandBrush, fontWeight = FontWeight.Bold))
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(22.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CenteredColumn(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().systemBarsPadding().imePadding().verticalScroll(rememberScrollState()).padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
// --- master forms --------------------------------------------------------------------------
|
||||
|
||||
/** One master field with Show; for opening an existing account. */
|
||||
@Composable
|
||||
fun MasterUnlockForm(busy: Boolean, button: String, onSubmit: (String) -> Unit) {
|
||||
val master = remember { MasterState() }
|
||||
var revealed by remember { mutableStateOf(false) }
|
||||
fun submit() {
|
||||
val m = master.value()
|
||||
if (m.isNotEmpty() && !busy) onSubmit(m)
|
||||
}
|
||||
MasterInput(master, "Master", revealed) { submit() }
|
||||
TextButton(onClick = { revealed = !revealed }) {
|
||||
Icon(if (revealed) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(if (revealed) "Hide" else "Show")
|
||||
}
|
||||
GradientButton(if (busy) "Checking…" else button, enabled = !busy, onClick = ::submit)
|
||||
}
|
||||
|
||||
/** Name + master twice; for a new account. */
|
||||
@Composable
|
||||
fun CreateAccountForm(app: AppState, busy: Boolean, defaultName: String, onCreate: (String, String) -> Unit) {
|
||||
var name by remember { mutableStateOf(defaultName) }
|
||||
val master = remember { MasterState() }
|
||||
val repeat = remember { MasterState() }
|
||||
var revealed by remember { mutableStateOf(false) }
|
||||
var problem by remember { mutableStateOf<String?>(null) }
|
||||
val value = master.value()
|
||||
val norm = name.trim().lowercase()
|
||||
|
||||
fun submit() {
|
||||
val m = master.value()
|
||||
problem = when {
|
||||
!accountNameRegex.matches(norm) -> "Account name: lowercase letters, digits, - or _."
|
||||
norm in app.accounts -> "There is already an account called '$norm'."
|
||||
m.isEmpty() -> "Type the master first."
|
||||
m.toByteArray().size > NexoPass.MAX_MASTER_BYTES -> "Master is too long (max 127 bytes)."
|
||||
NexoPass.masterStrength(m) == NexoPass.Strength.TOO_SHORT -> "Too short: at least 3 words and 12 characters."
|
||||
m != repeat.value() -> "Masters don't match."
|
||||
else -> null
|
||||
}
|
||||
if (problem == null && !busy) onCreate(norm, m)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
name, { name = it }, Modifier.fillMaxWidth(), singleLine = true,
|
||||
label = { Text("Account name") },
|
||||
leadingIcon = { Icon(Icons.Rounded.AccountCircle, null) },
|
||||
supportingText = { Text("e.g. main, work. Each account has its own master and site list.") },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"The master: a new one, or the one you already use on your PC (same master, same passwords).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
MasterInput(master, "Master", revealed) {}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MasterInput(repeat, "Repeat master", revealed) { submit() }
|
||||
if (value.isNotEmpty()) StrengthBar(NexoPass.masterStrength(value))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = { revealed = !revealed }) {
|
||||
Icon(if (revealed) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(if (revealed) "Hide" else "Show")
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = {
|
||||
val m = NexoPass.newMaster(app.words)
|
||||
master.set(m)
|
||||
repeat.set(m)
|
||||
revealed = true
|
||||
}) {
|
||||
Icon(Icons.Rounded.Casino, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Random 6 words")
|
||||
}
|
||||
}
|
||||
problem?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 8.dp)) }
|
||||
GradientButton(if (busy) "Creating…" else "Create account", enabled = !busy, onClick = ::submit)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StrengthBar(s: NexoPass.Strength) {
|
||||
val (progress, color) = when (s) {
|
||||
NexoPass.Strength.TOO_SHORT -> .15f to MaterialTheme.colorScheme.error
|
||||
NexoPass.Strength.MINIMUM -> .45f to Color(0xFFF59E0B)
|
||||
NexoPass.Strength.RECOMMENDED -> .7f to Color(0xFF34D399)
|
||||
NexoPass.Strength.STRONG -> .85f to Color(0xFF34D399)
|
||||
NexoPass.Strength.SAFEST -> 1f to Cyan
|
||||
}
|
||||
Column(Modifier.padding(top = 12.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
color = color,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape),
|
||||
)
|
||||
Text(s.label, style = MaterialTheme.typography.bodySmall, color = color, modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// --- screens -------------------------------------------------------------------------------
|
||||
|
||||
/** Required right after unlocking with a master: the app unlock replaces typing the master. */
|
||||
@Composable
|
||||
fun AppUnlockSetupScreen(app: AppState) {
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
val scope = rememberCoroutineScope()
|
||||
var method by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var askBio by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val bioOptions = remember { QuickUnlock.bioOptions(activity) }
|
||||
|
||||
CenteredColumn {
|
||||
Header(if (askBio) "One more thing." else "Choose how you unlock NexoPass. You won't type your master anymore.")
|
||||
GlassCard {
|
||||
if (!askBio) {
|
||||
MethodCard(Icons.Rounded.Pin, "PIN", "Fast. 4+ digits, 6 is better.") { method = "pin" }
|
||||
MethodCard(Icons.Rounded.Password, "Password", "The strongest option.") { method = "password" }
|
||||
MethodCard(Icons.Rounded.Pattern, "Pattern", "Connect 4+ dots.") { method = "pattern" }
|
||||
Text(
|
||||
"Too many wrong tries remove it; then you recover with your master. Keep the master in your head.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Text("Unlock with your fingerprint too?", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Your ${methodName(app.quick.method)} stays as the backup.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
|
||||
)
|
||||
GradientButton("Turn on", enabled = true) {
|
||||
app.quick.enableBio(activity, bioOptions.first(), app.innerKey()) { err ->
|
||||
if (err == null) app.finishAppSetup() else if (err.isNotEmpty()) error = err
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { app.finishAppSetup() }, modifier = Modifier.align(Alignment.CenterHorizontally)) { Text("Not now") }
|
||||
}
|
||||
AnimatedVisibility(busy) { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 12.dp).clip(CircleShape)) }
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
method?.let { m ->
|
||||
SecretSetupDialog(m, onCancel = { method = null }) { secret ->
|
||||
method = null
|
||||
busy = true
|
||||
scope.launch {
|
||||
withContext(Dispatchers.Default) { app.setupApp(m, secret) }
|
||||
busy = false
|
||||
if (bioOptions.isNotEmpty()) askBio = true else app.finishAppSetup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MethodCard(icon: ImageVector, title: String, text: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).clickable(onClick = onClick).padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(44.dp).clip(CircleShape), contentAlignment = Alignment.Center) {
|
||||
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.fillMaxSize()) {}
|
||||
Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(start = 14.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.Rounded.ChevronRight, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** An account whose master isn't in the keyring yet (added before, or after a recovery). */
|
||||
@Composable
|
||||
fun AccountMasterScreen(app: AppState, name: String, onAddAccount: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
CenteredColumn {
|
||||
Header("Account '$name' needs its master once. After that, your app unlock opens it.")
|
||||
GlassCard {
|
||||
MasterUnlockForm(busy, "Open '$name'") { m ->
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.Default) { app.unlockWithMaster(name, m.toByteArray()) }
|
||||
busy = false
|
||||
if (!ok) error = "That master doesn't open '$name'."
|
||||
}
|
||||
}
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp)) }
|
||||
}
|
||||
val others = app.accounts.filter { it != name }
|
||||
if (others.isNotEmpty()) {
|
||||
Text("Or switch to:", modifier = Modifier.padding(top = 16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
others.forEach { a -> TextButton(onClick = { app.openAccount(a) }) { Text(a) } }
|
||||
}
|
||||
Row(Modifier.padding(top = 8.dp)) {
|
||||
TextButton(onClick = onAddAccount) { Text("Add account") }
|
||||
TextButton(onClick = app::lock) { Text("Lock") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewAccountScreen(app: AppState, onBack: () -> Unit, onDone: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("New account") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(Modifier.padding(pad).imePadding().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
GlassCard {
|
||||
CreateAccountForm(app, busy, defaultName = "") { name, m ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
withContext(Dispatchers.Default) { app.createAccount(name, m.toByteArray()) }
|
||||
busy = false
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
app/src/main/java/com/nexoniarz/nexopass/Components.kt
Normal file
128
app/src/main/java/com/nexoniarz/nexopass/Components.kt
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PersistableBundle
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Key
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun GlassCard(
|
||||
modifier: Modifier = Modifier,
|
||||
alpha: Float = .72f,
|
||||
padding: Dp = 18.dp,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = alpha),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = .5f)),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(padding), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GradientButton(text: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(54.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (enabled) BrandBrush else SolidColor(MaterialTheme.colorScheme.surfaceVariant))
|
||||
.clickable(enabled = enabled, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (enabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Logo(size: Dp) {
|
||||
Box(Modifier.size(size).clip(CircleShape).background(BrandBrush), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Rounded.Key, null, tint = Color.White, modifier = Modifier.size(size * .5f))
|
||||
}
|
||||
}
|
||||
|
||||
private val avatarColors = listOf(Violet, Cyan, Pink, Color(0xFFF59E0B), Color(0xFF34D399), Color(0xFF60A5FA))
|
||||
|
||||
@Composable
|
||||
fun Avatar(site: String, size: Dp) {
|
||||
val c = avatarColors[(site.hashCode() and 0x7fffffff) % avatarColors.size]
|
||||
Box(
|
||||
Modifier.size(size).clip(CircleShape).background(Brush.linearGradient(listOf(c, c.copy(alpha = .55f)))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(site.take(1).uppercase(), color = Color.White, fontWeight = FontWeight.Bold, fontSize = (size.value * .42f).sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Tag(text: String, color: Color) {
|
||||
Surface(shape = CircleShape, color = color) {
|
||||
Text(text, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionTitle(text: String) {
|
||||
Text(
|
||||
text.uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
letterSpacing = 1.5.sp,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyState(text: String) {
|
||||
Column(Modifier.fillMaxWidth().padding(top = 64.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Rounded.Key, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.outline)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(text, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
fun modeLabel(mode: String) = NexoPass.wordCount(mode)?.let { "$it words" } ?: "${mode.removePrefix("chars")} chars"
|
||||
|
||||
/** Copies without a clipboard preview or keyboard history, and clears it after [seconds]. */
|
||||
fun copySecret(context: Context, text: String, seconds: Int) {
|
||||
val cm = context.getSystemService(ClipboardManager::class.java)
|
||||
val clip = ClipData.newPlainText("NexoPass", text)
|
||||
clip.description.extras = PersistableBundle().apply {
|
||||
putBoolean("android.content.extra.IS_SENSITIVE", true)
|
||||
}
|
||||
cm.setPrimaryClip(clip)
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (Build.VERSION.SDK_INT >= 28) cm.clearPrimaryClip() else cm.setPrimaryClip(ClipData.newPlainText("", ""))
|
||||
}, seconds * 1000L)
|
||||
}
|
||||
348
app/src/main/java/com/nexoniarz/nexopass/Core.kt
Normal file
348
app/src/main/java/com/nexoniarz/nexopass/Core.kt
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
|
||||
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.LocalDate
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
// Must stay byte-for-byte compatible with the nexopass bash script.
|
||||
object NexoPass {
|
||||
const val WORDLIST_SHA256 = "addd35536511597a02fa0a9ff1e5284677b8883b83e986e43f15a3db996b903e"
|
||||
|
||||
// Changing any of these changes every password.
|
||||
private const val SALT_PREFIX = "nexopass:v1"
|
||||
private const val ARGON_T = 3
|
||||
private const val ARGON_M_POW = 16 // 2^16 KiB = 64 MiB
|
||||
private const val ARGON_P = 1
|
||||
private const val SITE_BYTES = 1024
|
||||
private const val WORD_COUNT = 5 // "words" means 5; other counts are "words<N>"
|
||||
private const val UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
private const val LOWER = "abcdefghijkmnopqrstuvwxyz"
|
||||
private const val DIGITS = "23456789"
|
||||
private const val SYMBOLS = "!@#$%&*?-_+="
|
||||
private val TWO_PART_TLDS = setOf(
|
||||
"com.pl", "net.pl", "org.pl", "edu.pl", "gov.pl", "info.pl", "biz.pl",
|
||||
"co.uk", "org.uk", "ac.uk", "com.au", "co.jp", "com.br",
|
||||
)
|
||||
|
||||
const val MAX_MASTER_BYTES = 127
|
||||
const val MIN_LENGTH = 12
|
||||
const val MAX_LENGTH = 64
|
||||
const val MIN_WORDS = 5
|
||||
const val MAX_WORDS = 15
|
||||
|
||||
/** Mode string for a number of words. 5 stays "words" so older entries keep their passwords. */
|
||||
fun wordsMode(n: Int) = if (n == WORD_COUNT) "words" else "words$n"
|
||||
|
||||
/** Number of words for a words mode, or null for a characters mode. */
|
||||
fun wordCount(mode: String) = when {
|
||||
mode == "words" -> WORD_COUNT
|
||||
mode.startsWith("words") -> mode.removePrefix("words").toInt()
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun loadWords(bytes: ByteArray): List<String> {
|
||||
val sum = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
require(sum == WORDLIST_SHA256) { "Word list differs from the EFF original" }
|
||||
return bytes.decodeToString().lines().filter { it.isNotEmpty() }.map { it.substringAfter('\t') }
|
||||
}
|
||||
|
||||
private fun isAsciiSpace(c: Char) = c == ' ' || c in '\t'..'\r'
|
||||
private fun asciiLower(s: String) = String(CharArray(s.length) { s[it].let { c -> if (c in 'A'..'Z') c + 32 else c } })
|
||||
|
||||
/** 'https://www.Allegro.pl/konto' -> 'allegro', 'Discord' -> 'discord', invalid -> null. */
|
||||
fun normalizeSite(raw: String): String? {
|
||||
var s = asciiLower(raw).trim(::isAsciiSpace)
|
||||
if ("://" in s) s = s.substringAfter("://")
|
||||
val cut = s.indexOfFirst { it == '/' || it == '?' || it == '#' }
|
||||
if (cut >= 0) s = s.substring(0, cut)
|
||||
s = s.substringAfterLast('@').substringBefore(':').removePrefix("www.").removeSuffix(".")
|
||||
if (s.isEmpty() || s.startsWith(".") || s.endsWith(".") || ".." in s) return null
|
||||
val l = s.split('.')
|
||||
val n = l.size
|
||||
s = when {
|
||||
n >= 3 && "${l[n - 2]}.${l[n - 1]}" in TWO_PART_TLDS -> l[n - 3]
|
||||
n >= 2 -> l[n - 2]
|
||||
else -> l[0]
|
||||
}
|
||||
return s.takeIf { it.isNotEmpty() && it.all { c -> c in 'a'..'z' || c in '0'..'9' || c == '-' } }
|
||||
}
|
||||
|
||||
/** Lowercase per code point, like the script's towlower(). */
|
||||
private fun lower(s: String): String {
|
||||
val sb = StringBuilder(s.length)
|
||||
s.codePoints().forEach { sb.appendCodePoint(Character.toLowerCase(it)) }
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun normalizeUser(raw: String): String? {
|
||||
val u = lower(raw).trim(::isAsciiSpace)
|
||||
return u.takeIf { it.none { c -> c < ' ' || c == '\u007f' } }
|
||||
}
|
||||
|
||||
/** Lowercase, and any run of spaces/tabs/newlines becomes a single space. */
|
||||
fun normalizeMaster(raw: String): String =
|
||||
lower(raw).split(' ', '\t', '\n').filter { it.isNotEmpty() }.joinToString(" ")
|
||||
|
||||
const val MIN_MASTER_WORDS = 3
|
||||
const val MIN_MASTER_BYTES = 12
|
||||
|
||||
enum class Strength(val label: String) {
|
||||
TOO_SHORT("Too short: at least 3 words, 12 characters"),
|
||||
MINIMUM("3 words: minimum"),
|
||||
RECOMMENDED("4 words: recommended"),
|
||||
STRONG("5 words: strong"),
|
||||
SAFEST("6+ words: safest"),
|
||||
}
|
||||
|
||||
/** Same rule as the script. Only used when a vault is created. */
|
||||
fun masterStrength(master: String): Strength {
|
||||
val n = master.split(' ').count { it.isNotEmpty() }
|
||||
return when {
|
||||
n < MIN_MASTER_WORDS || master.toByteArray().size < MIN_MASTER_BYTES -> Strength.TOO_SHORT
|
||||
n == 3 -> Strength.MINIMUM
|
||||
n == 4 -> Strength.RECOMMENDED
|
||||
n == 5 -> Strength.STRONG
|
||||
else -> Strength.SAFEST
|
||||
}
|
||||
}
|
||||
|
||||
/** Edit distance, a swap of two neighbouring letters counts as one. */
|
||||
fun distance(a: String, b: String): Int {
|
||||
val w = b.length + 1
|
||||
val d = IntArray((a.length + 1) * w)
|
||||
for (i in 0..a.length) d[i * w] = i
|
||||
for (j in 0..b.length) d[j] = j
|
||||
for (i in 1..a.length) for (j in 1..b.length) {
|
||||
val cost = if (a[i - 1] == b[j - 1]) 0 else 1
|
||||
var v = minOf(d[(i - 1) * w + j] + 1, d[i * w + j - 1] + 1, d[(i - 1) * w + j - 1] + cost)
|
||||
if (i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1]) v = minOf(v, d[(i - 2) * w + j - 2] + 1)
|
||||
d[i * w + j] = v
|
||||
}
|
||||
return d[a.length * w + b.length]
|
||||
}
|
||||
|
||||
/** Sites close to [site]; empty when [site] itself is on the list. */
|
||||
fun suggestions(site: String, user: String, current: List<Rec>): List<Rec> {
|
||||
if (current.any { it.site == site && it.user == user }) return emptyList()
|
||||
val limit = if (site.length <= 4) 1 else 2
|
||||
return current.asSequence()
|
||||
.filter { kotlin.math.abs(it.site.length - site.length) <= limit }
|
||||
.map { it to distance(site, it.site) }
|
||||
.filter { it.second <= limit }
|
||||
.sortedBy { it.second }
|
||||
.map { it.first }
|
||||
.take(5)
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun derive(master: ByteArray, salt: String, bytes: Int): ByteArray {
|
||||
val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
|
||||
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
|
||||
.withIterations(ARGON_T)
|
||||
.withMemoryPowOfTwo(ARGON_M_POW)
|
||||
.withParallelism(ARGON_P)
|
||||
.withSalt(salt.toByteArray())
|
||||
.build()
|
||||
val out = ByteArray(bytes)
|
||||
Argon2BytesGenerator().apply { init(params) }.generateBytes(master, out)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Consumes 32-bit numbers from derived bytes, the same way the script does. */
|
||||
private class Picker(private val data: ByteArray) {
|
||||
private var pos = 0
|
||||
fun pick(n: Int): Int {
|
||||
val limit = (1L shl 32) - (1L shl 32) % n
|
||||
while (pos + 4 <= data.size) {
|
||||
var x = 0L
|
||||
for (k in 0 until 4) x = (x shl 8) or (data[pos + k].toLong() and 0xff)
|
||||
pos += 4
|
||||
if (x < limit) return (x % n).toInt()
|
||||
}
|
||||
error("Ran out of random data")
|
||||
}
|
||||
}
|
||||
|
||||
fun password(master: ByteArray, words: List<String>, site: String, user: String, version: Int, mode: String): String {
|
||||
val p = Picker(derive(master, "$SALT_PREFIX:site:$site:$version:$mode:$user", SITE_BYTES))
|
||||
val count = wordCount(mode)
|
||||
if (count != null) {
|
||||
val chosen = List(count) { words[p.pick(words.size)].replaceFirstChar { it.uppercaseChar() } }
|
||||
return chosen.joinToString("-") + DIGITS[p.pick(DIGITS.length)]
|
||||
}
|
||||
val len = mode.removePrefix("chars").toInt()
|
||||
val all = UPPER + LOWER + DIGITS + SYMBOLS
|
||||
val c = ArrayList<Char>(len)
|
||||
for (group in listOf(UPPER, LOWER, DIGITS, SYMBOLS)) c += group[p.pick(group.length)]
|
||||
repeat(len - 4) { c += all[p.pick(all.length)] }
|
||||
for (i in len - 1 downTo 1) {
|
||||
val j = p.pick(i + 1)
|
||||
val t = c[i]; c[i] = c[j]; c[j] = t
|
||||
}
|
||||
return c.joinToString("")
|
||||
}
|
||||
|
||||
/** Two words that are always the same for the same master, on every device. */
|
||||
fun checkWords(master: ByteArray, words: List<String>): String {
|
||||
val p = Picker(derive(master, "$SALT_PREFIX:check", 32))
|
||||
return words[p.pick(words.size)] + " " + words[p.pick(words.size)]
|
||||
}
|
||||
|
||||
fun vaultKey(master: ByteArray) = derive(master, "$SALT_PREFIX:vault", 32)
|
||||
|
||||
/** OpenPGP passphrase for export files, the same one the script uses. */
|
||||
fun exportPassphrase(master: ByteArray) =
|
||||
derive(master, "$SALT_PREFIX:export", 32).joinToString("") { "%02x".format(it) }.toCharArray()
|
||||
|
||||
fun newMaster(words: List<String>, count: Int = 6): String {
|
||||
val rnd = SecureRandom()
|
||||
return List(count) { words[rnd.nextInt(words.size)] }.joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/** One version of one site. until == "" means it is the current version. */
|
||||
data class Rec(
|
||||
val site: String,
|
||||
val user: String,
|
||||
val version: Int,
|
||||
val mode: String,
|
||||
val since: String,
|
||||
val until: String = "",
|
||||
) {
|
||||
val current get() = until.isEmpty()
|
||||
val label get() = if (user.isEmpty()) "$site v$version" else "$site ($user) v$version"
|
||||
fun same(o: Rec) = site == o.site && user == o.user
|
||||
}
|
||||
|
||||
/**
|
||||
* Site list with version archive, AES-256-GCM encrypted with a key derived from
|
||||
* the master. Holds no passwords, only what is needed to derive them again.
|
||||
*/
|
||||
class Vault(private val file: File, private val key: ByteArray) {
|
||||
val records = mutableListOf<Rec>()
|
||||
|
||||
fun exists() = file.exists()
|
||||
|
||||
/** Returns false when the key is wrong (or the file is damaged). */
|
||||
fun load(): Boolean {
|
||||
records.clear()
|
||||
if (!file.exists()) return true
|
||||
val data = file.readBytes()
|
||||
if (data.size < MAGIC.size + 12 || !data.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) return false
|
||||
val plain = try {
|
||||
cipher(Cipher.DECRYPT_MODE, data.copyOfRange(MAGIC.size, MAGIC.size + 12))
|
||||
.doFinal(data, MAGIC.size + 12, data.size - MAGIC.size - 12)
|
||||
} catch (e: javax.crypto.AEADBadTagException) {
|
||||
return false
|
||||
}
|
||||
records += parse(plain.decodeToString())
|
||||
return true
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val text = text(records)
|
||||
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||
val out = MAGIC + iv + cipher(Cipher.ENCRYPT_MODE, iv).doFinal(text.toByteArray())
|
||||
file.parentFile?.mkdirs()
|
||||
val tmp = File(file.parentFile, file.name + ".tmp")
|
||||
tmp.writeBytes(out)
|
||||
if (file.exists()) file.copyTo(File(file.parentFile, file.name + ".bak"), overwrite = true)
|
||||
check(tmp.renameTo(file)) { "Could not save the vault" }
|
||||
}
|
||||
|
||||
private fun cipher(mode: Int, iv: ByteArray) = Cipher.getInstance("AES/GCM/NoPadding").apply {
|
||||
init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
|
||||
updateAAD(MAGIC)
|
||||
}
|
||||
|
||||
fun current(site: String, user: String) = records.firstOrNull { it.site == site && it.user == user && it.current }
|
||||
|
||||
fun currentList() = records.filter { it.current }.sortedWith(compareBy({ it.site }, { it.user }))
|
||||
|
||||
fun history(site: String, user: String) =
|
||||
records.filter { it.site == site && it.user == user }.sortedBy { it.version }
|
||||
|
||||
fun add(site: String, user: String, version: Int, mode: String): Rec {
|
||||
check(current(site, user) == null) { "Already on the list" }
|
||||
return Rec(site, user, version, mode, today()).also { records += it; save() }
|
||||
}
|
||||
|
||||
fun rotate(old: Rec, newMode: String = old.mode): Rec {
|
||||
val i = records.indexOf(old)
|
||||
check(i >= 0 && old.current)
|
||||
records[i] = old.copy(until = today())
|
||||
return Rec(old.site, old.user, old.version + 1, newMode, today()).also { records += it; save() }
|
||||
}
|
||||
|
||||
/** Deletes one archived version; the current one stays. */
|
||||
fun removeVersion(r: Rec) {
|
||||
check(!r.current)
|
||||
records.remove(r)
|
||||
save()
|
||||
}
|
||||
|
||||
fun remove(r: Rec) {
|
||||
records.removeAll { it.same(r) }
|
||||
save()
|
||||
}
|
||||
|
||||
/** Adds what [other] knows; returns how many site versions were new. */
|
||||
fun mergeIn(other: List<Rec>): Int {
|
||||
val before = records.map { Triple(it.site, it.user, it.version) }.toSet()
|
||||
val merged = merge(records, other)
|
||||
records.clear()
|
||||
records += merged
|
||||
save()
|
||||
return merged.count { Triple(it.site, it.user, it.version) !in before }
|
||||
}
|
||||
|
||||
private fun today() = LocalDate.now().toString()
|
||||
|
||||
companion object {
|
||||
private val MAGIC = "NXP1".toByteArray()
|
||||
private const val HEADER = "# nexopass vault v1"
|
||||
private const val SEP = '\u001f'
|
||||
|
||||
/** Plain text form, shared by the vault, the script and export files. */
|
||||
fun text(records: List<Rec>) = buildString {
|
||||
append(HEADER).append('\n')
|
||||
for (r in records) {
|
||||
append(listOf(r.site, r.user, r.version, r.mode, r.since, r.until).joinToString(SEP.toString())).append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
fun parse(text: String): List<Rec> = text.lines()
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
.mapNotNull { line ->
|
||||
val f = line.split(SEP)
|
||||
val v = f.getOrNull(2)?.toIntOrNull()
|
||||
if (f.size < 5 || v == null || NexoPass.normalizeSite(f[0]) != f[0]) null
|
||||
else Rec(f[0], f[1], v, f[3], f[4], f.getOrElse(5) { "" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of both lists. Per site and account the highest version is the
|
||||
* current one, every lower version is archived.
|
||||
*/
|
||||
fun merge(a: List<Rec>, b: List<Rec>): List<Rec> =
|
||||
(a + b).groupBy { it.site to it.user }.values.flatMap { group ->
|
||||
val byVersion = group.groupBy { it.version }.toSortedMap()
|
||||
.mapValues { (_, same) -> same.firstOrNull { !it.current } ?: same.first() }
|
||||
val versions = byVersion.keys.toList()
|
||||
versions.mapIndexed { i, v ->
|
||||
val r = byVersion.getValue(v)
|
||||
when {
|
||||
i == versions.lastIndex -> r.copy(until = "")
|
||||
r.current -> r.copy(until = byVersion.getValue(versions[i + 1]).since)
|
||||
else -> r
|
||||
}
|
||||
}
|
||||
}.sortedWith(compareBy({ it.site }, { it.user }, { it.version }))
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/com/nexoniarz/nexopass/Export.kt
Normal file
59
app/src/main/java/com/nexoniarz/nexopass/Export.kt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import org.bouncycastle.bcpg.HashAlgorithmTags
|
||||
import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags
|
||||
import org.bouncycastle.openpgp.PGPCompressedData
|
||||
import org.bouncycastle.openpgp.PGPEncryptedDataGenerator
|
||||
import org.bouncycastle.openpgp.PGPEncryptedDataList
|
||||
import org.bouncycastle.openpgp.PGPLiteralData
|
||||
import org.bouncycastle.openpgp.PGPLiteralDataGenerator
|
||||
import org.bouncycastle.openpgp.PGPPBEEncryptedData
|
||||
import org.bouncycastle.openpgp.PGPUtil
|
||||
import org.bouncycastle.openpgp.bc.BcPGPObjectFactory
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPBEDataDecryptorFactory
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPBEKeyEncryptionMethodGenerator
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPGPDataEncryptorBuilder
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.SecureRandom
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Export files are plain OpenPGP (AES-256, integrity protected), so the
|
||||
* script opens them with gpg and the app opens what the script exports.
|
||||
* The passphrase is derived from the master: only the same master opens them.
|
||||
*/
|
||||
object Export {
|
||||
fun encrypt(passphrase: CharArray, plain: ByteArray): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val gen = PGPEncryptedDataGenerator(
|
||||
BcPGPDataEncryptorBuilder(SymmetricKeyAlgorithmTags.AES_256)
|
||||
.setWithIntegrityPacket(true)
|
||||
.setSecureRandom(SecureRandom()),
|
||||
)
|
||||
gen.addMethod(BcPBEKeyEncryptionMethodGenerator(passphrase, BcPGPDigestCalculatorProvider().get(HashAlgorithmTags.SHA256)))
|
||||
gen.open(out, ByteArray(1 shl 12)).use { enc ->
|
||||
PGPLiteralDataGenerator().open(enc, PGPLiteralData.BINARY, "nexopass", plain.size.toLong(), Date()).use { it.write(plain) }
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** Returns null when the passphrase is wrong or the file is not a valid export. */
|
||||
fun decrypt(passphrase: CharArray, data: ByteArray): ByteArray? = try {
|
||||
val list = BcPGPObjectFactory(PGPUtil.getDecoderStream(data.inputStream())).let { f ->
|
||||
generateSequence { f.nextObject() }.filterIsInstance<PGPEncryptedDataList>().first()
|
||||
}
|
||||
val pbe = list.encryptedDataObjects.asSequence().filterIsInstance<PGPPBEEncryptedData>().first()
|
||||
val clear = pbe.getDataStream(BcPBEDataDecryptorFactory(passphrase, BcPGPDigestCalculatorProvider()))
|
||||
var factory = BcPGPObjectFactory(clear)
|
||||
var obj = factory.nextObject()
|
||||
if (obj is PGPCompressedData) {
|
||||
factory = BcPGPObjectFactory(obj.dataStream)
|
||||
obj = factory.nextObject()
|
||||
}
|
||||
val bytes = (obj as PGPLiteralData).inputStream.readBytes()
|
||||
if (pbe.isIntegrityProtected && !pbe.verify()) null else bytes
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
287
app/src/main/java/com/nexoniarz/nexopass/Inputs.kt
Normal file
287
app/src/main/java/com/nexoniarz/nexopass/Inputs.kt
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Backspace
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Fingerprint
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
// --- master as words ---------------------------------------------------------------------
|
||||
|
||||
/** Master typed as words: every space turns the word into a chip. Case never matters. */
|
||||
class MasterState {
|
||||
val words: SnapshotStateList<String> = mutableStateListOf()
|
||||
var text by mutableStateOf("")
|
||||
|
||||
fun onText(value: String) {
|
||||
val parts = value.split(' ', '\t', '\n')
|
||||
parts.dropLast(1).filter { it.isNotEmpty() }.forEach { words += it }
|
||||
text = parts.last()
|
||||
}
|
||||
|
||||
fun value() = NexoPass.normalizeMaster((words + text).joinToString(" "))
|
||||
|
||||
fun set(master: String) {
|
||||
words.clear()
|
||||
words += master.split(' ')
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MasterInput(state: MasterState, label: String, revealed: Boolean, onDone: () -> Unit) {
|
||||
Column {
|
||||
if (state.words.isNotEmpty()) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
state.words.forEachIndexed { i, w ->
|
||||
InputChip(
|
||||
selected = false,
|
||||
onClick = { state.words.removeAt(i) },
|
||||
label = { Text(if (revealed) w.lowercase() else "•".repeat(w.length.coerceIn(3, 10))) },
|
||||
trailingIcon = { Icon(Icons.Rounded.Close, "Remove word", Modifier.size(16.dp)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.text,
|
||||
onValueChange = state::onText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text(label) },
|
||||
placeholder = { Text("type a word, then space") },
|
||||
visualTransformation = if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { onDone() }),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PIN ---------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun PinPad(onSubmit: (String) -> Unit, enabled: Boolean = true, clearKey: Any = Unit) {
|
||||
var pin by remember(clearKey) { mutableStateOf("") }
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.height(20.dp)) {
|
||||
repeat(maxOf(4, pin.length)) { i ->
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (i < pin.length) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
) {}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
val rows = listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9"), listOf("<", "0", "ok"))
|
||||
for (row in rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.padding(vertical = 7.dp)) {
|
||||
for (k in row) {
|
||||
when (k) {
|
||||
"<" -> PadKey(icon = Icons.AutoMirrored.Rounded.Backspace, label = "Delete digit", enabled = enabled && pin.isNotEmpty()) { pin = pin.dropLast(1) }
|
||||
"ok" -> PadKey(icon = Icons.Rounded.Check, label = "Confirm", enabled = enabled && pin.length >= 4, accent = true) {
|
||||
val p = pin
|
||||
pin = ""
|
||||
onSubmit(p)
|
||||
}
|
||||
else -> PadKey(text = k, enabled = enabled && pin.length < 16) { pin += k }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PadKey(text: String? = null, icon: ImageVector? = null, label: String? = null, enabled: Boolean, accent: Boolean = false, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = CircleShape,
|
||||
color = if (accent) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = .75f),
|
||||
modifier = Modifier.size(70.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (text != null) Text(text, fontSize = 26.sp, fontWeight = FontWeight.Medium)
|
||||
if (icon != null) Icon(icon, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- pattern -----------------------------------------------------------------------------
|
||||
|
||||
/** 3x3 pattern. Gives back the dots in order as digits 1-9, e.g. "1478". */
|
||||
@Composable
|
||||
fun PatternLock(onDone: (String) -> Unit, enabled: Boolean = true) {
|
||||
val path = remember { mutableStateListOf<Int>() }
|
||||
var finger by remember { mutableStateOf<Offset?>(null) }
|
||||
val done by rememberUpdatedState(onDone)
|
||||
val dot = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val line = MaterialTheme.colorScheme.primary
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
val side = minOf(maxWidth, 300.dp)
|
||||
Canvas(
|
||||
Modifier
|
||||
.size(side)
|
||||
.pointerInput(enabled) {
|
||||
if (!enabled) return@pointerInput
|
||||
fun hit(o: Offset): Int? {
|
||||
val cell = size.width / 3f
|
||||
val col = (o.x / cell).toInt()
|
||||
val row = (o.y / cell).toInt()
|
||||
if (col !in 0..2 || row !in 0..2) return null
|
||||
val c = Offset((col + .5f) * cell, (row + .5f) * cell)
|
||||
return if ((o - c).getDistance() < cell * .32f) row * 3 + col else null
|
||||
}
|
||||
detectDragGestures(
|
||||
onDragStart = { o ->
|
||||
path.clear()
|
||||
hit(o)?.let { path += it }
|
||||
finger = o
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
finger = change.position
|
||||
hit(change.position)?.let { if (it !in path) path += it }
|
||||
},
|
||||
onDragEnd = {
|
||||
val p = path.joinToString("") { (it + 1).toString() }
|
||||
path.clear()
|
||||
finger = null
|
||||
if (p.isNotEmpty()) done(p)
|
||||
},
|
||||
onDragCancel = {
|
||||
path.clear()
|
||||
finger = null
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
val cell = size.width / 3f
|
||||
fun center(i: Int) = Offset((i % 3 + .5f) * cell, (i / 3 + .5f) * cell)
|
||||
for (i in 1 until path.size) drawLine(line, center(path[i - 1]), center(path[i]), 10f, StrokeCap.Round)
|
||||
val f = finger
|
||||
if (f != null && path.isNotEmpty()) drawLine(line.copy(alpha = .5f), center(path.last()), f, 10f, StrokeCap.Round)
|
||||
for (i in 0 until 9) {
|
||||
val on = i in path
|
||||
drawCircle(if (on) line else dot.copy(alpha = .6f), radius = if (on) 18f else 11f, center = center(i))
|
||||
if (on) drawCircle(line.copy(alpha = .18f), radius = cell * .28f, center = center(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- one entry for every quick-unlock method ----------------------------------------------
|
||||
|
||||
fun methodName(method: String) = when (method) {
|
||||
"pin" -> "PIN"
|
||||
"password" -> "password"
|
||||
"pattern" -> "pattern"
|
||||
else -> "master"
|
||||
}
|
||||
|
||||
/** Asks once for a PIN, password or pattern. */
|
||||
@Composable
|
||||
fun SecretEntry(method: String, enabled: Boolean, clearKey: Any, onSubmit: (String) -> Unit) {
|
||||
when (method) {
|
||||
"pin" -> PinPad(onSubmit, enabled, clearKey)
|
||||
"pattern" -> PatternLock(onSubmit, enabled)
|
||||
else -> {
|
||||
var pw by remember(clearKey) { mutableStateOf("") }
|
||||
var shown by remember { mutableStateOf(false) }
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
pw, { pw = it }, Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Password") },
|
||||
visualTransformation = if (shown) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { if (pw.isNotEmpty()) onSubmit(pw) }),
|
||||
trailingIcon = { TextButton(onClick = { shown = !shown }) { Text(if (shown) "Hide" else "Show") } },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
GradientButton("Continue", enabled = enabled && pw.isNotEmpty()) { onSubmit(pw) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum for each method; null when fine. */
|
||||
fun secretProblem(method: String, s: String) = when (method) {
|
||||
"pin" -> if (s.length < 4) "Use at least 4 digits (6 is better)." else null
|
||||
"pattern" -> if (s.length < 4) "Connect at least 4 dots." else null
|
||||
else -> if (s.length < 6) "Use at least 6 characters." else null
|
||||
}
|
||||
|
||||
/** New PIN/password/pattern: enter it, then repeat it. */
|
||||
@Composable
|
||||
fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> Unit) {
|
||||
var first by remember { mutableStateOf<String?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var attempt by remember { mutableIntStateOf(0) }
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||
Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
if (first == null) "New ${methodName(method)}" else "Repeat ${methodName(method)}",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
if (method == "pin") "Unlocks NexoPass on this phone. Your master keeps working too."
|
||||
else "Used to unlock NexoPass on this phone.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
|
||||
)
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp)) }
|
||||
SecretEntry(method, enabled = true, clearKey = attempt) { s ->
|
||||
attempt++
|
||||
val f = first
|
||||
when {
|
||||
f == null -> secretProblem(method, s)?.let { error = it } ?: run { first = s; error = null }
|
||||
f != s -> { first = null; error = "They didn't match, start again." }
|
||||
else -> onDone(s)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onCancel, modifier = Modifier.padding(top = 8.dp)) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BioButton(onClick: () -> Unit) {
|
||||
FilledTonalButton(onClick = onClick, modifier = Modifier.clip(CircleShape)) {
|
||||
Icon(Icons.Rounded.Fingerprint, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Use biometrics")
|
||||
}
|
||||
}
|
||||
156
app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt
Normal file
156
app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun LockScreen(app: AppState) {
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
val scope = rememberCoroutineScope()
|
||||
val quick = app.quick
|
||||
// Without an app unlock (update, too many wrong tries) or on request: a master.
|
||||
var recover by remember { mutableStateOf(!quick.isSet) }
|
||||
var account by remember { mutableStateOf(app.current) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var attempt by remember { mutableIntStateOf(0) }
|
||||
|
||||
fun unlockQuick(secret: String) {
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val r = withContext(Dispatchers.Default) { quick.unlock(secret) }
|
||||
when (r) {
|
||||
is QuickUnlock.Result.Ok -> withContext(Dispatchers.Default) { app.unlockApp(r) }
|
||||
is QuickUnlock.Result.Wrong -> {
|
||||
attempt++
|
||||
error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left."
|
||||
}
|
||||
QuickUnlock.Result.Wiped -> {
|
||||
error = "Too many wrong attempts: the app unlock was removed. Recover with your master."
|
||||
recover = true
|
||||
}
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
fun unlockBio() {
|
||||
error = null
|
||||
quick.unlockBio(activity, "Use ${methodName(quick.method)}", onOk = { r ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
withContext(Dispatchers.Default) { app.unlockApp(r) }
|
||||
busy = false
|
||||
}
|
||||
}, onError = { if (it.isNotEmpty()) error = it })
|
||||
}
|
||||
|
||||
fun unlockMaster(m: String) {
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.Default) { app.unlockWithMaster(account, m.toByteArray()) }
|
||||
busy = false
|
||||
if (!ok) error = "That master doesn't open '$account'."
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!recover && quick.bioMode != "off") unlockBio()
|
||||
}
|
||||
|
||||
if (app.accounts.isEmpty()) {
|
||||
FirstRun(app)
|
||||
return
|
||||
}
|
||||
|
||||
CenteredColumn {
|
||||
Header(
|
||||
when {
|
||||
!recover && quick.method == "pattern" -> "Draw your pattern."
|
||||
!recover -> "Enter your ${methodName(quick.method)}."
|
||||
app.migrated -> "NexoPass now has accounts and an app unlock. Enter your master once to set it up."
|
||||
quick.isSet -> "Recover: enter the master of an account, then set a new app unlock."
|
||||
else -> "Enter the master of an account, then choose your app unlock."
|
||||
},
|
||||
)
|
||||
GlassCard {
|
||||
if (!recover) {
|
||||
SecretEntry(quick.method, enabled = !busy, clearKey = attempt) { unlockQuick(it) }
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = { recover = true; error = null }) { Text("Forgot it?") }
|
||||
if (quick.bioMode != "off") BioButton(::unlockBio)
|
||||
}
|
||||
} else {
|
||||
if (app.accounts.size > 1) {
|
||||
Text("Account", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
AccountChips(app.accounts, account) { account = it }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
MasterUnlockForm(busy, "Unlock '$account'") { unlockMaster(it) }
|
||||
if (quick.isSet) {
|
||||
Text(
|
||||
"This replaces your app unlock. Other accounts will ask for their master once.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(error != null) {
|
||||
Text(error.orEmpty(), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 10.dp))
|
||||
}
|
||||
AnimatedVisibility(busy) { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 14.dp).clip(CircleShape)) }
|
||||
}
|
||||
if (recover && quick.isSet) {
|
||||
TextButton(onClick = { recover = false; error = null }, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text("Back to ${methodName(quick.method)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AccountChips(accounts: List<String>, selected: String, onPick: (String) -> Unit) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
accounts.forEach { a ->
|
||||
FilterChip(selected = a == selected, onClick = { onPick(a) }, label = { Text(a) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirstRun(app: AppState) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
CenteredColumn {
|
||||
Header("Welcome. Create your first account.")
|
||||
GlassCard {
|
||||
CreateAccountForm(app, busy, defaultName = "main") { name, m ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
withContext(Dispatchers.Default) { app.createAccount(name, m.toByteArray()) }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
349
app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt
Normal file
349
app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// FragmentActivity because the system biometric prompt needs one.
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
// No screenshots, no preview in the recent apps screen. Debug builds allow
|
||||
// screenshots so the UI can be checked during development.
|
||||
if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) {
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
val words = NexoPass.loadWords(assets.open("eff_large_wordlist.txt").use { it.readBytes() })
|
||||
val prefs = Prefs(this)
|
||||
val app = AppState(filesDir, words, prefs)
|
||||
setContent {
|
||||
val dark = when (prefs.theme) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
NexoTheme(dark) {
|
||||
ParticleBackground(dark = dark, animated = prefs.particles) { App(app) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole app: accounts on disk, the app unlock, and the keyring (every
|
||||
* account's vault key and master) while unlocked. Nothing secret is written
|
||||
* anywhere except encrypted.
|
||||
*/
|
||||
class AppState(filesDir: File, val words: List<String>, val prefs: Prefs) {
|
||||
private val accountsDir = File(filesDir, "accounts")
|
||||
val quick = QuickUnlock(filesDir, prefs)
|
||||
|
||||
var accounts by mutableStateOf(listOf<String>())
|
||||
private set
|
||||
var appUnlocked by mutableStateOf(false)
|
||||
private set
|
||||
/** Unlocked with a master (recovery, first run, update): an app unlock must be set now. */
|
||||
var needsAppSetup by mutableStateOf(false)
|
||||
private set
|
||||
var session by mutableStateOf<Session?>(null)
|
||||
private set
|
||||
/** Set when the account's master isn't in the keyring yet. */
|
||||
var needsMaster by mutableStateOf<String?>(null)
|
||||
private set
|
||||
/** True after moving a pre-accounts vault into the account "main". */
|
||||
var migrated by mutableStateOf(false)
|
||||
private set
|
||||
var firstRunCheck by mutableStateOf<String?>(null)
|
||||
|
||||
private var keyring: MutableMap<String, Unlocked> = LinkedHashMap()
|
||||
private var innerKey: ByteArray? = null
|
||||
|
||||
init {
|
||||
// 1.0/1.1 kept one vault in filesDir; it becomes the account "main".
|
||||
val old = File(filesDir, "vault.bin")
|
||||
if (old.exists() && !File(accountsDir, "main").exists()) {
|
||||
File(accountsDir, "main").mkdirs()
|
||||
old.renameTo(File(accountsDir, "main/vault.bin"))
|
||||
File(filesDir, "vault.bin.bak").takeIf { it.exists() }?.renameTo(File(accountsDir, "main/vault.bin.bak"))
|
||||
quick.wipe() // the old format held a single master
|
||||
prefs.currentAccount = "main"
|
||||
migrated = true
|
||||
}
|
||||
refreshAccounts()
|
||||
}
|
||||
|
||||
val current get() = prefs.currentAccount
|
||||
|
||||
fun refreshAccounts() {
|
||||
accounts = accountsDir.listFiles()?.filter { File(it, "vault.bin").exists() }?.map { it.name }?.sorted() ?: emptyList()
|
||||
if (accounts.isNotEmpty() && current !in accounts) prefs.currentAccount = accounts.first()
|
||||
}
|
||||
|
||||
private fun vaultFile(name: String) = File(accountsDir, "$name/vault.bin")
|
||||
|
||||
/** After the PIN/password/pattern or biometrics. */
|
||||
fun unlockApp(r: QuickUnlock.Result.Ok) {
|
||||
innerKey = r.innerKey
|
||||
keyring = r.keyring
|
||||
needsAppSetup = false
|
||||
openAccount(current)
|
||||
appUnlocked = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an account with its master (recovery, update, or an account not in
|
||||
* the keyring yet). Slow. False when the master doesn't open it.
|
||||
*/
|
||||
fun unlockWithMaster(name: String, master: ByteArray): Boolean {
|
||||
val u = Unlocked(NexoPass.vaultKey(master), master)
|
||||
if (!Vault(vaultFile(name), u.vaultKey).load()) return false
|
||||
keyring[name] = u
|
||||
if (innerKey == null) needsAppSetup = true else saveKeyring()
|
||||
needsMaster = null
|
||||
open(name, u)
|
||||
appUnlocked = true
|
||||
return true
|
||||
}
|
||||
|
||||
/** New account: empty vault, master into the keyring. Slow. */
|
||||
fun createAccount(name: String, master: ByteArray) {
|
||||
val u = Unlocked(NexoPass.vaultKey(master), master)
|
||||
Vault(vaultFile(name), u.vaultKey).save()
|
||||
keyring[name] = u
|
||||
refreshAccounts()
|
||||
firstRunCheck = NexoPass.checkWords(master, words)
|
||||
if (innerKey == null) needsAppSetup = true else saveKeyring()
|
||||
needsMaster = null
|
||||
open(name, u)
|
||||
appUnlocked = true
|
||||
}
|
||||
|
||||
fun openAccount(name: String) {
|
||||
prefs.currentAccount = name
|
||||
session?.close()
|
||||
session = null
|
||||
val u = keyring[name]
|
||||
needsMaster = if (u != null && open(name, u)) null else name
|
||||
}
|
||||
|
||||
private fun open(name: String, u: Unlocked): Boolean {
|
||||
val s = Session(name, vaultFile(name), words, prefs, u)
|
||||
if (!s.load()) return false
|
||||
session?.close()
|
||||
session = s
|
||||
prefs.currentAccount = name
|
||||
return true
|
||||
}
|
||||
|
||||
/** Slow (Argon2). */
|
||||
fun setupApp(method: String, secret: String) {
|
||||
innerKey = quick.setup(method, secret, keyring)
|
||||
}
|
||||
|
||||
fun finishAppSetup() {
|
||||
needsAppSetup = false
|
||||
}
|
||||
|
||||
fun innerKey() = innerKey!!
|
||||
|
||||
fun masterOf(name: String) = keyring[name]?.master?.decodeToString()
|
||||
|
||||
fun renameAccount(old: String, new: String) {
|
||||
session?.close()
|
||||
session = null
|
||||
File(accountsDir, old).renameTo(File(accountsDir, new))
|
||||
keyring.remove(old)?.let { keyring[new] = it }
|
||||
saveKeyring()
|
||||
refreshAccounts()
|
||||
openAccount(new)
|
||||
}
|
||||
|
||||
/** True when [master] opens [name]. Slow. */
|
||||
fun checkMaster(name: String, master: ByteArray) = Vault(vaultFile(name), NexoPass.vaultKey(master)).load()
|
||||
|
||||
fun detachAccount(name: String) {
|
||||
if (session?.name == name) {
|
||||
session?.close()
|
||||
session = null
|
||||
}
|
||||
File(accountsDir, name).deleteRecursively()
|
||||
keyring.remove(name)?.let { it.master.fill(0); it.vaultKey.fill(0) }
|
||||
saveKeyring()
|
||||
refreshAccounts()
|
||||
if (accounts.isEmpty()) {
|
||||
// Nothing left: start over.
|
||||
quick.wipe()
|
||||
lock()
|
||||
} else {
|
||||
openAccount(accounts.first())
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveKeyring() {
|
||||
innerKey?.let { quick.save(it, keyring) }
|
||||
}
|
||||
|
||||
fun lock() {
|
||||
session?.close()
|
||||
session = null
|
||||
keyring.values.forEach { it.master.fill(0); it.vaultKey.fill(0) }
|
||||
keyring = LinkedHashMap()
|
||||
innerKey?.fill(0)
|
||||
innerKey = null
|
||||
needsMaster = null
|
||||
needsAppSetup = false
|
||||
appUnlocked = false
|
||||
migrated = false
|
||||
}
|
||||
}
|
||||
|
||||
/** One open account. Nothing here is written to disk except its vault. */
|
||||
class Session(val name: String, file: File, val words: List<String>, val prefs: Prefs, u: Unlocked) {
|
||||
private val master = u.master.copyOf()
|
||||
private val vault = Vault(file, u.vaultKey.copyOf())
|
||||
private val cache = ConcurrentHashMap<Rec, String>()
|
||||
var records by mutableStateOf(listOf<Rec>())
|
||||
private set
|
||||
|
||||
fun load(): Boolean {
|
||||
if (!vault.load()) return false
|
||||
records = vault.records.toList()
|
||||
return true
|
||||
}
|
||||
|
||||
fun close() {
|
||||
master.fill(0)
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
val current get() = records.filter { it.current }.sortedWith(compareBy({ it.site }, { it.user }))
|
||||
|
||||
fun password(r: Rec) = cache.getOrPut(r) {
|
||||
NexoPass.password(master, words, r.site, r.user, r.version, r.mode)
|
||||
}
|
||||
|
||||
fun checkWords() = NexoPass.checkWords(master, words)
|
||||
|
||||
fun add(site: String, user: String, version: Int, mode: String) = edit { add(site, user, version, mode) }
|
||||
fun rotate(r: Rec, mode: String) = edit { rotate(r, mode) }
|
||||
fun remove(r: Rec) = edit { remove(r); r }
|
||||
fun removeVersion(r: Rec) = edit { removeVersion(r); r }
|
||||
|
||||
/** Encrypted copy of the site list; slow (Argon2). */
|
||||
fun exportBytes(): ByteArray =
|
||||
Export.encrypt(NexoPass.exportPassphrase(master), Vault.text(vault.records).toByteArray())
|
||||
|
||||
/** Merges an export into the list. Number of new entries, or null if it doesn't open. Slow. */
|
||||
fun importBytes(data: ByteArray): Int? {
|
||||
val plain = Export.decrypt(NexoPass.exportPassphrase(master), data) ?: return null
|
||||
val n = vault.mergeIn(Vault.parse(plain.decodeToString()))
|
||||
records = vault.records.toList()
|
||||
return n
|
||||
}
|
||||
|
||||
private fun edit(block: Vault.() -> Rec): Rec {
|
||||
val r = vault.block()
|
||||
records = vault.records.toList()
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface Screen {
|
||||
data object List : Screen
|
||||
data object Add : Screen
|
||||
data object Settings : Screen
|
||||
data object NewAccount : Screen
|
||||
data class Detail(val site: String, val user: String) : Screen
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun App(app: AppState) {
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
var stoppedAt = 0L
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> stoppedAt = SystemClock.elapsedRealtime()
|
||||
Lifecycle.Event.ON_START -> {
|
||||
val limit = app.prefs.autoLockMinutes * 60_000L
|
||||
// Never lock in the middle of the required app-unlock setup.
|
||||
if (stoppedAt > 0 && !app.needsAppSetup && SystemClock.elapsedRealtime() - stoppedAt >= limit) app.lock()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val stage = when {
|
||||
!app.appUnlocked -> 0
|
||||
app.needsAppSetup -> 1
|
||||
else -> 2
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = stage,
|
||||
transitionSpec = { (fadeIn(tween(400)) + scaleIn(initialScale = .96f)) togetherWith fadeOut(tween(200)) },
|
||||
label = "stage",
|
||||
) { st ->
|
||||
when (st) {
|
||||
0 -> LockScreen(app)
|
||||
1 -> AppUnlockSetupScreen(app)
|
||||
else -> UnlockedApp(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnlockedApp(app: AppState) {
|
||||
var screen by remember { mutableStateOf<Screen>(Screen.List) }
|
||||
BackHandler(screen != Screen.List) { screen = Screen.List }
|
||||
val needs = app.needsMaster
|
||||
val session = app.session
|
||||
if (screen == Screen.NewAccount) {
|
||||
NewAccountScreen(app, onBack = { screen = Screen.List }, onDone = { screen = Screen.List })
|
||||
return
|
||||
}
|
||||
if (needs != null || session == null) {
|
||||
AccountMasterScreen(app, needs ?: app.current, onAddAccount = { screen = Screen.NewAccount })
|
||||
return
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = screen,
|
||||
transitionSpec = {
|
||||
val forward = targetState != Screen.List
|
||||
(slideInHorizontally { if (forward) it / 4 else -it / 4 } + fadeIn()) togetherWith
|
||||
(slideOutHorizontally { if (forward) -it / 4 else it / 4 } + fadeOut())
|
||||
},
|
||||
label = "screen",
|
||||
) { s ->
|
||||
when (s) {
|
||||
Screen.List -> ListScreen(
|
||||
app, session,
|
||||
onOpen = { screen = Screen.Detail(it.site, it.user) },
|
||||
onAdd = { screen = Screen.Add },
|
||||
onSettings = { screen = Screen.Settings },
|
||||
onNewAccount = { screen = Screen.NewAccount },
|
||||
)
|
||||
Screen.Add -> AddScreen(session, onBack = { screen = Screen.List }, onOpen = { screen = Screen.Detail(it.site, it.user) })
|
||||
Screen.Settings -> SettingsScreen(app, session, onBack = { screen = Screen.List })
|
||||
Screen.NewAccount -> {}
|
||||
is Screen.Detail -> DetailScreen(session, s.site, s.user, onBack = { screen = Screen.List })
|
||||
}
|
||||
}
|
||||
}
|
||||
88
app/src/main/java/com/nexoniarz/nexopass/Particles.kt
Normal file
88
app/src/main/java/com/nexoniarz/nexopass/Particles.kt
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val COUNT = 46
|
||||
private const val LINK_DP = 110f
|
||||
|
||||
/**
|
||||
* Drifting dots joined by faint lines when they come close, over two soft
|
||||
* glows. Stands still when turned off in settings or when the system
|
||||
* turned animations off.
|
||||
*/
|
||||
@Composable
|
||||
fun ParticleBackground(dark: Boolean, animated: Boolean, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
|
||||
val bg = MaterialTheme.colorScheme.background
|
||||
val dot = if (dark) Color.White else MaterialTheme.colorScheme.primary
|
||||
val context = LocalContext.current
|
||||
val systemAnimations = remember {
|
||||
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
|
||||
}
|
||||
val animate = animated && systemAnimations
|
||||
val link = with(LocalDensity.current) { LINK_DP.dp2px(density) }
|
||||
|
||||
// Positions and speeds as fractions of the screen, so rotation keeps the layout.
|
||||
val p = remember {
|
||||
val r = Random(7)
|
||||
Array(COUNT) { floatArrayOf(r.nextFloat(), r.nextFloat(), (r.nextFloat() - .5f) * .00012f, (r.nextFloat() - .5f) * .00012f, .6f + r.nextFloat() * 1.6f) }
|
||||
}
|
||||
var frame by remember { mutableLongStateOf(0L) }
|
||||
if (animate) {
|
||||
LaunchedEffect(Unit) {
|
||||
var last = 0L
|
||||
while (true) {
|
||||
withFrameMillis { now ->
|
||||
val dt = if (last == 0L) 16f else (now - last).coerceAtMost(48).toFloat()
|
||||
last = now
|
||||
for (q in p) {
|
||||
q[0] = (q[0] + q[2] * dt + 1f) % 1f
|
||||
q[1] = (q[1] + q[3] * dt + 1f) % 1f
|
||||
}
|
||||
frame = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxSize().background(bg)) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
frame // read so every frame redraws
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
drawRect(Brush.radialGradient(listOf(Violet.copy(alpha = if (dark) .28f else .16f), Color.Transparent), Offset(w * .1f, h * .12f), w * .9f))
|
||||
drawRect(Brush.radialGradient(listOf(Cyan.copy(alpha = if (dark) .18f else .12f), Color.Transparent), Offset(w * .95f, h * .85f), w * .9f))
|
||||
for (i in 0 until COUNT) {
|
||||
val a = p[i]
|
||||
val ax = a[0] * w
|
||||
val ay = a[1] * h
|
||||
for (j in i + 1 until COUNT) {
|
||||
val dx = ax - p[j][0] * w
|
||||
val dy = ay - p[j][1] * h
|
||||
val d = sqrt(dx * dx + dy * dy)
|
||||
if (d < link) {
|
||||
drawLine(dot.copy(alpha = (1f - d / link) * .22f), Offset(ax, ay), Offset(p[j][0] * w, p[j][1] * h), strokeWidth = 1.2f)
|
||||
}
|
||||
}
|
||||
drawCircle(dot.copy(alpha = .55f), radius = a[4] * density, center = Offset(ax, ay))
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.dp2px(density: Float) = this * density
|
||||
55
app/src/main/java/com/nexoniarz/nexopass/Prefs.kt
Normal file
55
app/src/main/java/com/nexoniarz/nexopass/Prefs.kt
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
/** App settings. Nothing secret is stored here. Backed by Compose state so the UI follows changes. */
|
||||
class Prefs(context: Context) {
|
||||
private val sp = context.getSharedPreferences("nexopass", Context.MODE_PRIVATE)
|
||||
|
||||
/** system, dark, light */
|
||||
var theme by StringPref("theme", "system")
|
||||
var particles by BoolPref("particles", true)
|
||||
/** Minutes in the background before the app locks; 0 = as soon as you leave. */
|
||||
var autoLockMinutes by IntPref("auto_lock", 5)
|
||||
var clipSeconds by IntPref("clip_seconds", 30)
|
||||
/** words, or chars<N> */
|
||||
var defaultMode by StringPref("default_mode", "words")
|
||||
var maxAttempts by IntPref("max_attempts", 5)
|
||||
var failedAttempts by IntPref("failed", 0)
|
||||
/** pin, password, pattern (only meaningful while quick.bin exists) */
|
||||
var quickMethod by StringPref("quick_method", "none")
|
||||
var currentAccount by StringPref("current_account", "main")
|
||||
/** off, strong (fingerprint/face), device (biometrics or phone screen lock) */
|
||||
var bioMode by StringPref("bio_mode", "off")
|
||||
|
||||
private inner class IntPref(val key: String, def: Int) : ReadWriteProperty<Any?, Int> {
|
||||
private val state = mutableIntStateOf(sp.getInt(key, def))
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>) = state.intValue
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
|
||||
state.intValue = value
|
||||
sp.edit().putInt(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class StringPref(val key: String, def: String) : ReadWriteProperty<Any?, String> {
|
||||
private val state = mutableStateOf(sp.getString(key, def) ?: def)
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
|
||||
state.value = value
|
||||
sp.edit().putString(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class BoolPref(val key: String, def: Boolean) : ReadWriteProperty<Any?, Boolean> {
|
||||
private val state = mutableStateOf(sp.getBoolean(key, def))
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) {
|
||||
state.value = value
|
||||
sp.edit().putBoolean(key, value).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
269
app/src/main/java/com/nexoniarz/nexopass/Security.kt
Normal file
269
app/src/main/java/com/nexoniarz/nexopass/Security.kt
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.AEADBadTagException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/** What opens one account: its vault key and its master. */
|
||||
class Unlocked(val vaultKey: ByteArray, val master: ByteArray)
|
||||
|
||||
/** Every account's keys, encrypted under the app unlock. */
|
||||
object Keyring {
|
||||
private const val SEP = '\u001f'
|
||||
|
||||
fun encode(k: Map<String, Unlocked>) = k.entries.joinToString("\n") { (name, u) ->
|
||||
name + SEP + u.vaultKey.joinToString("") { "%02x".format(it) } + SEP + u.master.decodeToString()
|
||||
}.toByteArray()
|
||||
|
||||
fun decode(b: ByteArray): MutableMap<String, Unlocked> = b.decodeToString().lines()
|
||||
.mapNotNull { line ->
|
||||
val f = line.split(SEP, limit = 3)
|
||||
if (f.size != 3 || f[1].length != 64) null
|
||||
else f[0] to Unlocked(f[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray(), f[2].toByteArray())
|
||||
}.toMap(LinkedHashMap())
|
||||
}
|
||||
|
||||
/**
|
||||
* The app unlock (PIN, password or pattern, plus optional biometrics).
|
||||
*
|
||||
* quick.bin: salt, then the keyring encrypted twice. Inner layer: AES-GCM with
|
||||
* innerKey = Argon2id(secret, salt). Outer layer: AES-GCM with a key that lives
|
||||
* in the phone's secure hardware (Android Keystore) and never leaves it, so a
|
||||
* copied file is useless elsewhere. Wrong secrets are counted and wipe it.
|
||||
*
|
||||
* bio.bin: innerKey, encrypted with a Keystore key that only works right after
|
||||
* a fingerprint/face (or screen lock) check. New fingerprints invalidate it.
|
||||
*/
|
||||
class QuickUnlock(dir: File, private val prefs: Prefs) {
|
||||
private val quickFile = File(dir, "quick.bin")
|
||||
private val bioFile = File(dir, "bio.bin")
|
||||
|
||||
val isSet get() = quickFile.exists()
|
||||
val method: String get() = if (isSet) prefs.quickMethod else "none"
|
||||
val bioMode: String get() = if (isSet && bioFile.exists()) prefs.bioMode else "off"
|
||||
|
||||
sealed interface Result {
|
||||
class Ok(val innerKey: ByteArray, val keyring: MutableMap<String, Unlocked>) : Result
|
||||
class Wrong(val attemptsLeft: Int) : Result
|
||||
/** Too many wrong attempts (or the hardware key is gone): recover with a master. */
|
||||
object Wiped : Result
|
||||
}
|
||||
|
||||
/** New secret: new salt and key. Returns the inner key. Slow (Argon2). */
|
||||
fun setup(method: String, secret: String, keyring: Map<String, Unlocked>): ByteArray {
|
||||
disableBio() // it holds the old inner key
|
||||
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val key = innerKey(secret, salt)
|
||||
write(salt, key, keyring)
|
||||
prefs.quickMethod = method
|
||||
prefs.failedAttempts = 0
|
||||
return key
|
||||
}
|
||||
|
||||
/** Re-encrypts the keyring after accounts changed. */
|
||||
fun save(innerKey: ByteArray, keyring: Map<String, Unlocked>) {
|
||||
write(quickFile.readBytes().copyOfRange(0, 16), innerKey, keyring)
|
||||
}
|
||||
|
||||
/** Slow (Argon2), run off the main thread. */
|
||||
fun unlock(secret: String): Result {
|
||||
val data = quickFile.readBytes()
|
||||
val inner = outer(data) ?: run { wipe(); return Result.Wiped }
|
||||
val key = innerKey(secret, data.copyOfRange(0, 16))
|
||||
val plain = try {
|
||||
gcm(Cipher.DECRYPT_MODE, key, inner)
|
||||
} catch (e: AEADBadTagException) {
|
||||
prefs.failedAttempts += 1
|
||||
val left = prefs.maxAttempts - prefs.failedAttempts
|
||||
if (left <= 0) {
|
||||
wipe()
|
||||
return Result.Wiped
|
||||
}
|
||||
return Result.Wrong(left)
|
||||
}
|
||||
prefs.failedAttempts = 0
|
||||
return Result.Ok(key, Keyring.decode(plain))
|
||||
}
|
||||
|
||||
private fun openWith(key: ByteArray): MutableMap<String, Unlocked>? {
|
||||
val inner = outer(quickFile.readBytes()) ?: return null
|
||||
return try { Keyring.decode(gcm(Cipher.DECRYPT_MODE, key, inner)) } catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
fun wipe() {
|
||||
quickFile.delete()
|
||||
bioFile.delete()
|
||||
prefs.quickMethod = "none"
|
||||
prefs.bioMode = "off"
|
||||
prefs.failedAttempts = 0
|
||||
KeyStore.getInstance(KEYSTORE).apply { load(null) }.run {
|
||||
for (a in listOf(ALIAS_QUICK, ALIAS_BIO)) if (containsAlias(a)) deleteEntry(a)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableBio() {
|
||||
bioFile.delete()
|
||||
prefs.bioMode = "off"
|
||||
KeyStore.getInstance(KEYSTORE).apply { load(null) }.run { if (containsAlias(ALIAS_BIO)) deleteEntry(ALIAS_BIO) }
|
||||
}
|
||||
|
||||
/** Asks for a fingerprint/face (or screen lock) and stores the inner key under it. */
|
||||
fun enableBio(activity: FragmentActivity, mode: String, innerKey: ByteArray, done: (String?) -> Unit) {
|
||||
disableBio()
|
||||
val cipher = Cipher.getInstance(GCM).apply { init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_BIO, mode)) }
|
||||
prompt(activity, cipher, mode, "Turn on biometric unlock", "Cancel", onOk = { c ->
|
||||
bioFile.writeBytes(c.iv + c.doFinal(innerKey))
|
||||
prefs.bioMode = mode
|
||||
done(null)
|
||||
}, onError = done)
|
||||
}
|
||||
|
||||
/** Fingerprint/face, then opens the keyring. Errors are "" when the user just cancelled. */
|
||||
fun unlockBio(activity: FragmentActivity, cancelText: String, onOk: (Result.Ok) -> Unit, onError: (String) -> Unit) {
|
||||
val data = bioFile.readBytes()
|
||||
val cipher = try {
|
||||
Cipher.getInstance(GCM).apply {
|
||||
init(Cipher.DECRYPT_MODE, keystoreKey(ALIAS_BIO, prefs.bioMode), GCMParameterSpec(128, data, 0, 12))
|
||||
}
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
disableBio()
|
||||
onError("Biometrics changed on this phone, so biometric unlock was turned off. Turn it on again in Settings.")
|
||||
return
|
||||
}
|
||||
prompt(activity, cipher, prefs.bioMode, "Unlock NexoPass", cancelText, onOk = { c ->
|
||||
val key = c.doFinal(data, 12, data.size - 12)
|
||||
val keyring = openWith(key)
|
||||
if (keyring == null) onError("Biometric unlock no longer matches. Use your ${methodName(method)}.")
|
||||
else onOk(Result.Ok(key, keyring))
|
||||
}, onError = onError)
|
||||
}
|
||||
|
||||
private fun write(salt: ByteArray, key: ByteArray, keyring: Map<String, Unlocked>) {
|
||||
val inner = gcm(Cipher.ENCRYPT_MODE, key, Keyring.encode(keyring))
|
||||
val outer = Cipher.getInstance(GCM).run {
|
||||
init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_QUICK, null))
|
||||
iv + doFinal(inner)
|
||||
}
|
||||
val tmp = File(quickFile.parentFile, "quick.tmp")
|
||||
tmp.writeBytes(salt + outer)
|
||||
check(tmp.renameTo(quickFile)) { "Could not save the app unlock" }
|
||||
}
|
||||
|
||||
/** Removes the hardware layer; null if the hardware key is gone. */
|
||||
private fun outer(data: ByteArray): ByteArray? = try {
|
||||
Cipher.getInstance(GCM).run {
|
||||
init(Cipher.DECRYPT_MODE, keystoreKey(ALIAS_QUICK, null), GCMParameterSpec(128, data, 16, 12))
|
||||
doFinal(data, 28, data.size - 28)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun prompt(
|
||||
activity: FragmentActivity,
|
||||
cipher: Cipher,
|
||||
mode: String,
|
||||
title: String,
|
||||
cancelText: String,
|
||||
onOk: (Cipher) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
try {
|
||||
onOk(result.cryptoObject!!.cipher!!)
|
||||
} catch (e: Exception) {
|
||||
onError("Biometric unlock failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
// Cancelling is not an error worth showing.
|
||||
val cancelled = errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
|
||||
errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_CANCELED
|
||||
onError(if (cancelled) "" else errString.toString())
|
||||
}
|
||||
}
|
||||
val info = BiometricPrompt.PromptInfo.Builder().setTitle(title).setSubtitle("NexoPass")
|
||||
if (mode == "device") {
|
||||
info.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
} else {
|
||||
info.setAllowedAuthenticators(BIOMETRIC_STRONG).setNegativeButtonText(cancelText)
|
||||
}
|
||||
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
|
||||
.authenticate(info.build(), BiometricPrompt.CryptoObject(cipher))
|
||||
}
|
||||
|
||||
private fun innerKey(secret: String, salt: ByteArray) =
|
||||
NexoPass.derive(secret.toByteArray(), "nexopass:quick:" + salt.joinToString("") { "%02x".format(it) }, 32)
|
||||
|
||||
/** iv + ciphertext with a plain AES key. */
|
||||
private fun gcm(mode: Int, key: ByteArray, data: ByteArray): ByteArray {
|
||||
val c = Cipher.getInstance(GCM)
|
||||
return if (mode == Cipher.ENCRYPT_MODE) {
|
||||
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||
c.init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
|
||||
iv + c.doFinal(data)
|
||||
} else {
|
||||
c.init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, data, 0, 12))
|
||||
c.doFinal(data, 12, data.size - 12)
|
||||
}
|
||||
}
|
||||
|
||||
private fun keystoreKey(alias: String, bioMode: String?): SecretKey {
|
||||
val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
(ks.getKey(alias, null) as? SecretKey)?.let { return it }
|
||||
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
if (bioMode != null) {
|
||||
spec.setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true)
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
val types = if (bioMode == "device") {
|
||||
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
|
||||
} else {
|
||||
KeyProperties.AUTH_BIOMETRIC_STRONG
|
||||
}
|
||||
spec.setUserAuthenticationParameters(0, types)
|
||||
}
|
||||
}
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
||||
.apply { init(spec.build()) }
|
||||
.generateKey()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEYSTORE = "AndroidKeyStore"
|
||||
private const val GCM = "AES/GCM/NoPadding"
|
||||
private const val ALIAS_QUICK = "nexopass.quick"
|
||||
private const val ALIAS_BIO = "nexopass.bio"
|
||||
|
||||
/** Which biometric options this phone supports. */
|
||||
fun bioOptions(activity: FragmentActivity): List<String> {
|
||||
val bm = BiometricManager.from(activity)
|
||||
return buildList {
|
||||
if (bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) add("strong")
|
||||
if (Build.VERSION.SDK_INT >= 30 &&
|
||||
bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
) add("device")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
486
app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt
Normal file
486
app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
|
||||
const val APP_VERSION = "1.2.0"
|
||||
|
||||
private fun bioLabel(mode: String) = when (mode) {
|
||||
"strong" -> "Fingerprint or face"
|
||||
"device" -> "Biometrics or phone screen lock"
|
||||
else -> "Off"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) {
|
||||
val prefs = app.prefs
|
||||
val quick = app.quick
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
fun toast(msg: String) = scope.launch { snackbar.currentSnackbarData?.dismiss(); snackbar.showSnackbar(msg) }
|
||||
|
||||
var dialog by remember { mutableStateOf<String?>(null) }
|
||||
var setupMethod by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var shownMaster by remember { mutableStateOf<String?>(null) }
|
||||
val bioOptions = remember { QuickUnlock.bioOptions(activity) }
|
||||
// Read so the rows refresh after changes.
|
||||
val method = prefs.quickMethod.let { quick.method }
|
||||
val bio = prefs.bioMode.let { quick.bioMode }
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pgp-encrypted")) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
runCatching { activity.contentResolver.openOutputStream(uri)!!.use { it.write(session.exportBytes()) } }.isSuccess
|
||||
}
|
||||
busy = false
|
||||
toast(if (ok) "Exported. It opens only with this account's master, here or with nexopass on PC." else "Export failed.")
|
||||
}
|
||||
}
|
||||
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
busy = true
|
||||
scope.launch {
|
||||
val n = withContext(Dispatchers.IO) {
|
||||
runCatching { activity.contentResolver.openInputStream(uri)!!.use { it.readBytes() } }.getOrNull()?.let { session.importBytes(it) }
|
||||
}
|
||||
busy = false
|
||||
toast(if (n == null) "Can't open it: exported from another account (different master), or not a NexoPass export." else "Imported: $n new entr${if (n == 1) "y" else "ies"}.")
|
||||
}
|
||||
}
|
||||
fun exportNow() = exportLauncher.launch("nexopass-${session.name}-${LocalDate.now()}.pgp")
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(Modifier.padding(pad).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(bottom = 32.dp)) {
|
||||
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth().padding(bottom = 8.dp))
|
||||
|
||||
SectionTitle("Account: ${session.name}")
|
||||
GlassCard(padding = 4.dp) {
|
||||
SettingRow(Icons.Rounded.DriveFileRenameOutline, "Rename", "Currently \"${session.name}\"") { dialog = "rename" }
|
||||
SettingRow(Icons.Rounded.Visibility, "Show master", "Needs your ${methodName(method)}") { dialog = "verify-master" }
|
||||
SettingRow(Icons.Rounded.Upload, "Export", "Encrypted copy of this account's site list") { exportNow() }
|
||||
SettingRow(Icons.Rounded.Download, "Import", "Merge an export from this phone or your PC") { importLauncher.launch(arrayOf("*/*")) }
|
||||
SettingRow(Icons.Rounded.LinkOff, "Detach this account", "Remove it from this phone", danger = true) { dialog = "detach" }
|
||||
}
|
||||
|
||||
SectionTitle("App unlock")
|
||||
GlassCard(padding = 4.dp) {
|
||||
SettingRow(Icons.Rounded.Pin, "Unlock with", methodName(method).replaceFirstChar { it.uppercase() } + " · tap to change") { dialog = "verify-change" }
|
||||
SettingRow(
|
||||
Icons.Rounded.Fingerprint, "Biometrics",
|
||||
if (bioOptions.isEmpty()) "Not available on this phone" else bioLabel(bio),
|
||||
enabled = bioOptions.isNotEmpty(),
|
||||
) { dialog = "bio" }
|
||||
SettingRow(Icons.Rounded.Timer, "Auto-lock", if (prefs.autoLockMinutes == 0) "As soon as you leave the app" else "${prefs.autoLockMinutes} min after leaving") { dialog = "autolock" }
|
||||
SettingRow(Icons.Rounded.GppMaybe, "Wrong attempts allowed", "${prefs.maxAttempts}, then you recover with a master") { dialog = "attempts" }
|
||||
}
|
||||
|
||||
SectionTitle("Passwords")
|
||||
GlassCard(padding = 4.dp) {
|
||||
SettingRow(Icons.Rounded.Password, "New sites use", modeLabel(prefs.defaultMode)) { dialog = "mode" }
|
||||
SettingRow(Icons.Rounded.ContentPaste, "Clipboard clears after", "${prefs.clipSeconds} s") { dialog = "clip" }
|
||||
}
|
||||
|
||||
SectionTitle("Appearance")
|
||||
GlassCard(padding = 4.dp) {
|
||||
SettingRow(Icons.Rounded.Palette, "Theme", prefs.theme.replaceFirstChar { it.uppercase() }) { dialog = "theme" }
|
||||
SwitchRow(Icons.Rounded.AutoAwesome, "Animated background", "Particles behind everything", prefs.particles) { prefs.particles = it }
|
||||
}
|
||||
|
||||
SectionTitle("About")
|
||||
GlassCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Logo(48.dp)
|
||||
Column(Modifier.padding(start = 14.dp)) {
|
||||
Text("NexoPass $APP_VERSION", style = MaterialTheme.typography.titleMedium.copy(brush = BrandBrush, fontWeight = FontWeight.Bold))
|
||||
Text("Made by Nexoniarz", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"Same passwords as nexopass on NixOS. Nothing leaves this phone: the app has no internet permission.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = { dialog = "license" }, contentPadding = PaddingValues(0.dp)) { Text("License: Apache License 2.0") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (dialog) {
|
||||
"rename" -> RenameDialog(app, session.name, onDismiss = { dialog = null }) { new ->
|
||||
dialog = null
|
||||
app.renameAccount(session.name, new)
|
||||
}
|
||||
"verify-master" -> VerifyDialog(app, "to see the master of '${session.name}'", onDismiss = { dialog = null }) {
|
||||
dialog = null
|
||||
shownMaster = app.masterOf(session.name)
|
||||
}
|
||||
"verify-change" -> VerifyDialog(app, "to change how you unlock", onDismiss = { dialog = null }) { dialog = "method" }
|
||||
"method" -> ChoiceDialog(
|
||||
"Unlock with",
|
||||
listOf("pin" to "PIN", "password" to "Password", "pattern" to "Pattern"),
|
||||
method,
|
||||
onDismiss = { dialog = null },
|
||||
) { picked ->
|
||||
dialog = null
|
||||
setupMethod = picked
|
||||
}
|
||||
"bio" -> ChoiceDialog("Biometrics", listOf("off" to "Off") + bioOptions.map { it to bioLabel(it) }, bio, onDismiss = { dialog = null }) { picked ->
|
||||
dialog = null
|
||||
if (picked == "off") {
|
||||
quick.disableBio()
|
||||
toast("Biometrics off.")
|
||||
} else {
|
||||
quick.enableBio(activity, picked, app.innerKey()) { err ->
|
||||
if (err == null) toast("Biometric unlock is on.") else if (err.isNotEmpty()) toast(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
"autolock" -> ChoiceDialog(
|
||||
"Auto-lock",
|
||||
listOf(0, 1, 5, 15, 30, 60).map { it.toString() to if (it == 0) "Immediately" else "After $it min" },
|
||||
prefs.autoLockMinutes.toString(),
|
||||
onDismiss = { dialog = null },
|
||||
) { prefs.autoLockMinutes = it.toInt(); dialog = null }
|
||||
"attempts" -> ChoiceDialog("Wrong attempts allowed", listOf("3", "5", "10").map { it to it }, prefs.maxAttempts.toString(), onDismiss = { dialog = null }) {
|
||||
prefs.maxAttempts = it.toInt()
|
||||
dialog = null
|
||||
}
|
||||
"clip" -> ChoiceDialog("Clipboard clears after", listOf(10, 15, 30, 60, 120).map { it.toString() to "$it seconds" }, prefs.clipSeconds.toString(), onDismiss = { dialog = null }) {
|
||||
prefs.clipSeconds = it.toInt()
|
||||
dialog = null
|
||||
}
|
||||
"theme" -> ChoiceDialog("Theme", listOf("system" to "Follow the system", "dark" to "Dark", "light" to "Light"), prefs.theme, onDismiss = { dialog = null }) {
|
||||
prefs.theme = it
|
||||
dialog = null
|
||||
}
|
||||
"mode" -> {
|
||||
var mode by remember { mutableStateOf(prefs.defaultMode) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialog = null },
|
||||
title = { Text("New sites use") },
|
||||
text = { Column { ModePicker(mode) { mode = it } } },
|
||||
confirmButton = { TextButton(onClick = { prefs.defaultMode = mode; dialog = null }) { Text("Save") } },
|
||||
dismissButton = { TextButton(onClick = { dialog = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
"detach" -> DetachDialog(app, session.name, onExport = ::exportNow, onDismiss = { dialog = null }) {
|
||||
dialog = null
|
||||
onBack()
|
||||
}
|
||||
"license" -> LicenseDialog { dialog = null }
|
||||
}
|
||||
|
||||
setupMethod?.let { m ->
|
||||
SecretSetupDialog(m, onCancel = { setupMethod = null }) { secret ->
|
||||
setupMethod = null
|
||||
val hadBio = bio
|
||||
busy = true
|
||||
scope.launch {
|
||||
withContext(Dispatchers.Default) { app.setupApp(m, secret) }
|
||||
busy = false
|
||||
toast("You now unlock with your ${methodName(m)}.")
|
||||
// The biometric copy holds the old key: set it up again right away.
|
||||
if (hadBio != "off") {
|
||||
quick.enableBio(activity, hadBio, app.innerKey()) { err ->
|
||||
if (err != null && err.isNotEmpty()) toast(err) else if (err != null) toast("Biometrics are off now; turn them on again any time.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shownMaster?.let { m ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { shownMaster = null },
|
||||
icon = { Icon(Icons.Rounded.Key, null) },
|
||||
title = { Text("Master of '${session.name}'") },
|
||||
text = {
|
||||
Column {
|
||||
Text(m, fontFamily = FontFamily.Monospace, fontSize = 20.sp, lineHeight = 30.sp)
|
||||
Text(
|
||||
"Make sure nobody is looking. Screenshots are blocked.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { shownMaster = null }) { Text("Hide") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirms it's you with the app unlock (or biometrics). */
|
||||
@Composable
|
||||
private fun VerifyDialog(app: AppState, reason: String, onDismiss: () -> Unit, onOk: () -> Unit) {
|
||||
val quick = app.quick
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
val scope = rememberCoroutineScope()
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var attempt by remember { mutableIntStateOf(0) }
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||
Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Confirm it's you", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Enter your ${methodName(quick.method)} $reason.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
|
||||
)
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp)) }
|
||||
SecretEntry(quick.method, enabled = !busy, clearKey = attempt) { secret ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
val r = withContext(Dispatchers.Default) { quick.unlock(secret) }
|
||||
busy = false
|
||||
attempt++
|
||||
when (r) {
|
||||
is QuickUnlock.Result.Ok -> onOk()
|
||||
is QuickUnlock.Result.Wrong -> error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left."
|
||||
QuickUnlock.Result.Wiped -> app.lock()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (quick.bioMode != "off") {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
BioButton {
|
||||
quick.unlockBio(activity, "Cancel", onOk = { onOk() }, onError = { if (it.isNotEmpty()) error = it })
|
||||
}
|
||||
}
|
||||
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 12.dp))
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.padding(top = 8.dp)) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameDialog(app: AppState, current: String, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf(current) }
|
||||
val norm = name.trim().lowercase()
|
||||
val problem = when {
|
||||
norm == current -> null
|
||||
!accountNameRegex.matches(norm) -> "Lowercase letters, digits, - or _."
|
||||
norm in app.accounts -> "There is already an account called '$norm'."
|
||||
else -> null
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename account") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
name, { name = it }, singleLine = true,
|
||||
label = { Text("Account name") },
|
||||
isError = problem != null,
|
||||
supportingText = { problem?.let { Text(it) } },
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(enabled = problem == null && norm != current, onClick = { onRename(norm) }) { Text("Rename") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Export offer, then app unlock, then the account's master, then "yes". */
|
||||
@Composable
|
||||
private fun DetachDialog(app: AppState, name: String, onExport: () -> Unit, onDismiss: () -> Unit, onDone: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var step by remember { mutableIntStateOf(0) }
|
||||
var yes by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val master = remember { MasterState() }
|
||||
|
||||
when (step) {
|
||||
0 -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Rounded.LinkOff, null) },
|
||||
title = { Text("Detach '$name'?") },
|
||||
text = {
|
||||
Text(
|
||||
"This removes the account from this phone: its site list with the archive and its saved master. " +
|
||||
"Your logins keep their passwords and the master can recreate them, but the site list is gone " +
|
||||
"unless you export it first (or it's still on your PC).",
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { step = 1 }) { Text("Continue") } },
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onExport) { Text("Export first") }
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
1 -> VerifyDialog(app, "to detach '$name'", onDismiss = onDismiss) { step = 2 }
|
||||
else -> Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
|
||||
Column(Modifier.padding(24.dp)) {
|
||||
Text("Last check", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Enter the master of '$name' and type yes.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
MasterInput(master, "Master of '$name'", revealed = false) {}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(yes, { yes = it }, Modifier.fillMaxWidth(), singleLine = true, label = { Text("Type yes") })
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp)) }
|
||||
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 12.dp))
|
||||
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
Button(
|
||||
enabled = !busy && yes.trim() == "yes" && master.value().isNotEmpty(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
onClick = {
|
||||
busy = true
|
||||
error = null
|
||||
val m = master.value().toByteArray()
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.Default) { app.checkMaster(name, m) }
|
||||
busy = false
|
||||
if (ok) {
|
||||
app.detachAccount(name)
|
||||
onDone()
|
||||
} else {
|
||||
error = "That master doesn't open '$name'. Nothing was removed."
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text("Detach") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LicenseDialog(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val text = remember { context.assets.open("LICENSE").use { it.readBytes().decodeToString() } }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Apache License 2.0") },
|
||||
text = {
|
||||
Text(
|
||||
"Copyright 2026 Nexoniarz\n\n$text",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingRow(icon: ImageVector, title: String, value: String, enabled: Boolean = true, danger: Boolean = false, onClick: () -> Unit) {
|
||||
val tint = when {
|
||||
!enabled -> MaterialTheme.colorScheme.outline
|
||||
danger -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, null, tint = tint)
|
||||
Column(Modifier.weight(1f).padding(start = 16.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = when {
|
||||
!enabled -> MaterialTheme.colorScheme.outline
|
||||
danger -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(icon: ImageVector, title: String, value: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Column(Modifier.weight(1f).padding(start = 16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(value, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(checked, onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceDialog(
|
||||
title: String,
|
||||
options: List<Pair<String, String>>,
|
||||
selected: String,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
options.forEach { (key, label) ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().selectable(selected = key == selected, onClick = { onPick(key) }).padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = key == selected, onClick = null)
|
||||
Text(label, Modifier.padding(start = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
463
app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt
Normal file
463
app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// --- site list -----------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun ListScreen(app: AppState, session: Session, onOpen: (Rec) -> Unit, onAdd: () -> Unit, onSettings: () -> Unit, onNewAccount: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var checkWords by remember { mutableStateOf<String?>(null) }
|
||||
val all = session.current
|
||||
val q = query.trim().lowercase()
|
||||
val list = all.filter { q.isEmpty() || q in it.site || q in it.user }
|
||||
val guess = NexoPass.normalizeSite(query)
|
||||
val didYouMean = if (list.isEmpty() && guess != null) NexoPass.suggestions(guess, "", all) else emptyList()
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onAdd,
|
||||
icon = { Icon(Icons.Rounded.Add, null) },
|
||||
text = { Text("Add site") },
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(pad),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Logo(44.dp)
|
||||
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text("NexoPass", style = MaterialTheme.typography.headlineSmall.copy(brush = BrandBrush, fontWeight = FontWeight.Bold))
|
||||
AccountSwitcher(app, session, all.size, onNewAccount)
|
||||
}
|
||||
IconButton(onClick = { scope.launch { checkWords = withContext(Dispatchers.Default) { session.checkWords() } } }) {
|
||||
Icon(Icons.Rounded.Verified, "Check words")
|
||||
}
|
||||
IconButton(onClick = onSettings) { Icon(Icons.Rounded.Settings, "Settings") }
|
||||
FilledTonalIconButton(onClick = app::lock) { Icon(Icons.Rounded.Lock, "Lock") }
|
||||
}
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
query, { query = it }, Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search sites") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, null) },
|
||||
trailingIcon = { if (query.isNotEmpty()) IconButton(onClick = { query = "" }) { Icon(Icons.Rounded.Close, "Clear") } },
|
||||
singleLine = true,
|
||||
shape = CircleShape,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = .7f),
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = .85f),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (didYouMean.isNotEmpty()) {
|
||||
item { SectionTitle("Did you mean") }
|
||||
items(didYouMean, key = { "s" + it.site + "\u001f" + it.user }) { SiteRow(it) { onOpen(it) } }
|
||||
} else if (list.isEmpty()) {
|
||||
item { EmptyState(if (all.isEmpty()) "No sites yet.\nTap \"Add site\" to start." else "Nothing found.") }
|
||||
}
|
||||
items(list, key = { it.site + "\u001f" + it.user }) { SiteRow(it) { onOpen(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
app.firstRunCheck?.let { words ->
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
icon = { Icon(Icons.Rounded.Verified, null) },
|
||||
confirmButton = { TextButton(onClick = { app.firstRunCheck = null }) { Text("Got it") } },
|
||||
title = { Text("Check words") },
|
||||
text = { Text("$words\n\nYour PC shows the same two words for the same master. If they differ, the masters differ.") },
|
||||
)
|
||||
}
|
||||
checkWords?.let {
|
||||
AlertDialog(
|
||||
onDismissRequest = { checkWords = null },
|
||||
icon = { Icon(Icons.Rounded.Verified, null) },
|
||||
confirmButton = { TextButton(onClick = { checkWords = null }) { Text("OK") } },
|
||||
title = { Text("Check words") },
|
||||
text = { Text(it, style = MaterialTheme.typography.titleLarge) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SiteRow(r: Rec, onClick: () -> Unit) {
|
||||
GlassCard(Modifier.clickable(onClick = onClick), padding = 14.dp) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Avatar(r.site, 44.dp)
|
||||
Column(Modifier.weight(1f).padding(start = 14.dp)) {
|
||||
Text(r.site, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
if (r.user.isNotEmpty()) Text(r.user, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Tag("v${r.version}", MaterialTheme.colorScheme.secondaryContainer)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Tag(modeLabel(r.mode), MaterialTheme.colorScheme.surfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "main ▾ · 12 sites": tap to switch account or add one. */
|
||||
@Composable
|
||||
private fun AccountSwitcher(app: AppState, session: Session, sites: Int, onNewAccount: () -> Unit) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Row(
|
||||
Modifier.clip(CircleShape).clickable { open = true }.padding(end = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(session.name, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Icon(Icons.Rounded.ArrowDropDown, "Switch account", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
Text(" · $sites site${if (sites == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
app.accounts.forEach { a ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(a, fontWeight = if (a == session.name) FontWeight.Bold else FontWeight.Normal) },
|
||||
leadingIcon = { Icon(if (a == session.name) Icons.Rounded.CheckCircle else Icons.Rounded.AccountCircle, null) },
|
||||
onClick = { open = false; if (a != session.name) app.openAccount(a) },
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(text = { Text("Add account") }, leadingIcon = { Icon(Icons.Rounded.PersonAdd, null) }, onClick = { open = false; onNewAccount() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- add site ------------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddScreen(session: Session, onBack: () -> Unit, onOpen: (Rec) -> Unit) {
|
||||
var site by remember { mutableStateOf("") }
|
||||
var user by remember { mutableStateOf("") }
|
||||
var version by remember { mutableStateOf("1") }
|
||||
var mode by remember { mutableStateOf(session.prefs.defaultMode) }
|
||||
val normSite = NexoPass.normalizeSite(site)
|
||||
val normUser = NexoPass.normalizeUser(user)
|
||||
val ver = version.toIntOrNull()?.takeIf { it in 1..9999 }
|
||||
val existing = if (normSite != null && normUser != null) session.current.firstOrNull { it.site == normSite && it.user == normUser } else null
|
||||
val similar = if (normSite != null && normUser != null) NexoPass.suggestions(normSite, normUser, session.current) else emptyList()
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Add site") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(Modifier.padding(pad).imePadding().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
GlassCard {
|
||||
OutlinedTextField(
|
||||
site, { site = it }, Modifier.fillMaxWidth(), singleLine = true,
|
||||
label = { Text("Site") },
|
||||
placeholder = { Text("discord, allegro.pl, …") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Language, null) },
|
||||
supportingText = {
|
||||
Text(
|
||||
when {
|
||||
site.isBlank() -> "Case doesn't matter. Links work too."
|
||||
normSite == null -> "Not a valid site name."
|
||||
else -> "Saved as: $normSite"
|
||||
},
|
||||
)
|
||||
},
|
||||
isError = site.isNotBlank() && normSite == null,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
OutlinedTextField(
|
||||
user, { user = it }, Modifier.fillMaxWidth(), singleLine = true,
|
||||
label = { Text("Account name (optional)") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Person, null) },
|
||||
supportingText = { Text("Only if you have several accounts on this site.") },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
OutlinedTextField(
|
||||
version, { version = it.filter(Char::isDigit) }, Modifier.fillMaxWidth(), singleLine = true,
|
||||
label = { Text("Start at version") },
|
||||
leadingIcon = { Icon(Icons.Rounded.History, null) },
|
||||
supportingText = { Text("Keep 1, unless you already rotated this site on your PC.") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
isError = ver == null,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ModePicker(mode) { mode = it }
|
||||
}
|
||||
|
||||
AnimatedVisibility(existing != null || similar.isNotEmpty()) {
|
||||
GlassCard(Modifier.padding(top = 12.dp)) {
|
||||
if (existing != null) {
|
||||
Text("${existing.label} is already on your list.", style = MaterialTheme.typography.titleSmall)
|
||||
TextButton(onClick = { onOpen(existing) }) { Text("Open it") }
|
||||
} else {
|
||||
Text("Did you mean:", style = MaterialTheme.typography.titleSmall)
|
||||
similar.forEach { r ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable { onOpen(r) }.padding(vertical = 6.dp)) {
|
||||
Avatar(r.site, 32.dp)
|
||||
Text(r.label, Modifier.weight(1f).padding(start = 10.dp))
|
||||
Text("Open", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"If not, \"$normSite\" is a different site and you can add it below.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GradientButton(
|
||||
"Add",
|
||||
enabled = normSite != null && normUser != null && ver != null && existing == null,
|
||||
onClick = { onOpen(session.add(normSite!!, normUser!!, ver!!, mode)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModePicker(mode: String, onChange: (String) -> Unit) {
|
||||
val words = NexoPass.wordCount(mode)
|
||||
val chars = words == null
|
||||
val length = if (chars) mode.removePrefix("chars").toInt() else 20
|
||||
val count = words ?: 5
|
||||
Text("Password type", style = MaterialTheme.typography.titleSmall)
|
||||
Row(Modifier.padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = !chars, onClick = { onChange(NexoPass.wordsMode(count)) }, label = { Text("Words") })
|
||||
FilterChip(selected = chars, onClick = { onChange("chars$length") }, label = { Text("Characters") })
|
||||
}
|
||||
if (chars) {
|
||||
Text(
|
||||
"$length random characters, for sites with a length limit",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Slider(
|
||||
value = length.toFloat(),
|
||||
onValueChange = { onChange("chars${it.toInt()}") },
|
||||
valueRange = NexoPass.MIN_LENGTH.toFloat()..NexoPass.MAX_LENGTH.toFloat(),
|
||||
steps = NexoPass.MAX_LENGTH - NexoPass.MIN_LENGTH - 1,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"$count words, easy to type. More words, stronger password.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Slider(
|
||||
value = count.toFloat(),
|
||||
onValueChange = { onChange(NexoPass.wordsMode(it.toInt())) },
|
||||
valueRange = NexoPass.MIN_WORDS.toFloat()..NexoPass.MAX_WORDS.toFloat(),
|
||||
steps = NexoPass.MAX_WORDS - NexoPass.MIN_WORDS - 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- site detail ---------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DetailScreen(session: Session, site: String, user: String, onBack: () -> Unit) {
|
||||
val history = session.records.filter { it.site == site && it.user == user }.sortedByDescending { it.version }
|
||||
val current = history.firstOrNull { it.current }
|
||||
var confirmRotate by remember { mutableStateOf(false) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
var deleteVersion by remember { mutableStateOf<Rec?>(null) }
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val seconds = session.prefs.clipSeconds
|
||||
val onCopy: (String) -> Unit = { pw ->
|
||||
copySecret(context, pw, seconds)
|
||||
scope.launch { snackbar.currentSnackbarData?.dismiss(); snackbar.showSnackbar("Copied. Clipboard clears in $seconds s.") }
|
||||
}
|
||||
|
||||
if (current == null) {
|
||||
LaunchedEffect(Unit) { onBack() }
|
||||
return
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") } },
|
||||
actions = { IconButton(onClick = { confirmRemove = true }) { Icon(Icons.Rounded.Delete, "Remove") } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
LazyColumn(
|
||||
Modifier.padding(pad),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Avatar(site, 64.dp)
|
||||
Column(Modifier.padding(start = 16.dp)) {
|
||||
Text(site, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
if (user.isNotEmpty()) Text(user, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { PasswordCard(session, current, highlight = true, onCopy = onCopy) }
|
||||
item {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = .55f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Rounded.WarningAmber, null, tint = MaterialTheme.colorScheme.onErrorContainer)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text("Data breach?", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
Text("Get a brand new password, the old one is archived.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
FilledTonalButton(onClick = { confirmRotate = true }) { Text("v${current.version + 1}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
val old = history.filter { !it.current }
|
||||
if (old.isNotEmpty()) {
|
||||
item { SectionTitle("Archive") }
|
||||
items(old, key = { it.version }) { PasswordCard(session, it, highlight = false, onCopy = onCopy, onDelete = { deleteVersion = it }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmRotate) {
|
||||
var mode by remember { mutableStateOf(current.mode) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmRotate = false },
|
||||
icon = { Icon(Icons.Rounded.Autorenew, null) },
|
||||
title = { Text("Switch to version ${current.version + 1}?") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Version ${current.version} goes to the archive. Then change the password on the site to the new one.")
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ModePicker(mode) { mode = it }
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { session.rotate(current, mode); confirmRotate = false }) { Text("Switch") } },
|
||||
dismissButton = { TextButton(onClick = { confirmRotate = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
deleteVersion?.let { r ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteVersion = null },
|
||||
icon = { Icon(Icons.Rounded.Delete, null) },
|
||||
title = { Text("Delete v${r.version} from the archive?") },
|
||||
text = { Text("Only this old version disappears from the list. The current password doesn't change.") },
|
||||
confirmButton = { TextButton(onClick = { session.removeVersion(r); deleteVersion = null }) { Text("Delete") } },
|
||||
dismissButton = { TextButton(onClick = { deleteVersion = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
if (confirmRemove) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmRemove = false },
|
||||
icon = { Icon(Icons.Rounded.Delete, null) },
|
||||
title = { Text("Remove ${current.label}?") },
|
||||
text = { Text("Removes the site and its archive from the list. The passwords themselves don't change, you can add the site again any time.") },
|
||||
confirmButton = { TextButton(onClick = { session.remove(current); confirmRemove = false; onBack() }) { Text("Remove") } },
|
||||
dismissButton = { TextButton(onClick = { confirmRemove = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordCard(session: Session, r: Rec, highlight: Boolean, onCopy: (String) -> Unit, onDelete: (() -> Unit)? = null) {
|
||||
var pw by remember(r) { mutableStateOf<String?>(null) }
|
||||
var revealed by remember(r) { mutableStateOf(false) }
|
||||
LaunchedEffect(r) { pw = withContext(Dispatchers.Default) { session.password(r) } }
|
||||
|
||||
val border = if (highlight) Modifier.border(1.5.dp, BrandBrush, MaterialTheme.shapes.large) else Modifier
|
||||
GlassCard(border, alpha = if (highlight) .85f else .55f) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Tag("v${r.version}", if (highlight) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
modeLabel(r.mode) + " · " + if (r.current) "since ${r.since}" else "${r.since} → ${r.until}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AnimatedContent(
|
||||
targetState = if (pw == null) 0 else if (revealed) 2 else 1,
|
||||
transitionSpec = { fadeIn(tween(250)) togetherWith fadeOut(tween(150)) },
|
||||
label = "pw",
|
||||
) { state ->
|
||||
when (state) {
|
||||
0 -> LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 12.dp))
|
||||
2 -> Text(pw.orEmpty(), fontFamily = FontFamily.Monospace, fontSize = if (highlight) 19.sp else 16.sp, lineHeight = 26.sp)
|
||||
else -> Text("•".repeat(16), fontFamily = FontFamily.Monospace, fontSize = if (highlight) 19.sp else 16.sp, letterSpacing = 2.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalIconButton(enabled = pw != null, onClick = { revealed = !revealed }) {
|
||||
Icon(if (revealed) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, if (revealed) "Hide" else "Show")
|
||||
}
|
||||
if (highlight) {
|
||||
Button(enabled = pw != null, onClick = { onCopy(pw!!) }, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Rounded.ContentCopy, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Copy password")
|
||||
}
|
||||
} else {
|
||||
FilledTonalIconButton(enabled = pw != null, onClick = { onCopy(pw!!) }) { Icon(Icons.Rounded.ContentCopy, "Copy") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (onDelete != null) IconButton(onClick = onDelete) { Icon(Icons.Rounded.DeleteOutline, "Delete this version") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
app/src/main/java/com/nexoniarz/nexopass/Theme.kt
Normal file
92
app/src/main/java/com/nexoniarz/nexopass/Theme.kt
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package com.nexoniarz.nexopass
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Violet = Color(0xFF8B5CF6)
|
||||
val Cyan = Color(0xFF22D3EE)
|
||||
val Pink = Color(0xFFF472B6)
|
||||
|
||||
private val Dark = darkColorScheme(
|
||||
primary = Color(0xFFB69CFF),
|
||||
onPrimary = Color(0xFF24005A),
|
||||
primaryContainer = Color(0xFF4B2A9E),
|
||||
onPrimaryContainer = Color(0xFFEADDFF),
|
||||
secondary = Cyan,
|
||||
onSecondary = Color(0xFF00363F),
|
||||
secondaryContainer = Color(0xFF0E4E5A),
|
||||
onSecondaryContainer = Color(0xFFB8F2FF),
|
||||
tertiary = Pink,
|
||||
onTertiary = Color(0xFF4A0029),
|
||||
background = Color(0xFF07080F),
|
||||
onBackground = Color(0xFFE6E1F2),
|
||||
surface = Color(0xFF0D0F1C),
|
||||
onSurface = Color(0xFFE6E1F2),
|
||||
surfaceVariant = Color(0xFF1E2135),
|
||||
onSurfaceVariant = Color(0xFFC6C2DA),
|
||||
surfaceContainerLow = Color(0xFF12142A),
|
||||
surfaceContainer = Color(0xFF171A31),
|
||||
surfaceContainerHigh = Color(0xFF1E2139),
|
||||
surfaceContainerHighest = Color(0xFF262A44),
|
||||
outline = Color(0xFF6E6A86),
|
||||
outlineVariant = Color(0xFF34364E),
|
||||
error = Color(0xFFFF8A9A),
|
||||
errorContainer = Color(0xFF5C1020),
|
||||
onErrorContainer = Color(0xFFFFDADF),
|
||||
)
|
||||
|
||||
private val Light = lightColorScheme(
|
||||
primary = Color(0xFF6236D9),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFE9DDFF),
|
||||
onPrimaryContainer = Color(0xFF22005D),
|
||||
secondary = Color(0xFF00788C),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFC2F1FA),
|
||||
onSecondaryContainer = Color(0xFF001F25),
|
||||
tertiary = Color(0xFFB0306E),
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFF7F4FF),
|
||||
onBackground = Color(0xFF1B1A24),
|
||||
surface = Color(0xFFFBF9FF),
|
||||
onSurface = Color(0xFF1B1A24),
|
||||
surfaceVariant = Color(0xFFE6E0F4),
|
||||
onSurfaceVariant = Color(0xFF48455A),
|
||||
surfaceContainerLow = Color(0xFFF3EFFC),
|
||||
surfaceContainer = Color(0xFFEEE9F9),
|
||||
surfaceContainerHigh = Color(0xFFE8E3F5),
|
||||
surfaceContainerHighest = Color(0xFFE2DCF0),
|
||||
outline = Color(0xFF79758C),
|
||||
outlineVariant = Color(0xFFCAC4DC),
|
||||
)
|
||||
|
||||
private val NexoShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(12.dp),
|
||||
medium = RoundedCornerShape(20.dp),
|
||||
large = RoundedCornerShape(28.dp),
|
||||
extraLarge = RoundedCornerShape(36.dp),
|
||||
)
|
||||
|
||||
/** Violet to cyan, used for the logo, headline and main buttons. */
|
||||
val BrandBrush = Brush.linearGradient(listOf(Violet, Cyan))
|
||||
|
||||
@Composable
|
||||
fun NexoTheme(dark: Boolean, content: @Composable () -> Unit) {
|
||||
val scheme = if (dark) Dark else Light
|
||||
MaterialTheme(colorScheme = scheme, shapes = NexoShapes) {
|
||||
// Screens sit on transparent backgrounds (so the particles show) and
|
||||
// translucent cards; Material can't pick a text color for those and
|
||||
// would fall back to black, so set it here for the whole app.
|
||||
CompositionLocalProvider(LocalContentColor provides scheme.onBackground, content = content)
|
||||
}
|
||||
}
|
||||
21
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
21
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:endX="108"
|
||||
android:endY="108">
|
||||
<item android:offset="0" android:color="#FF8B5CF6" />
|
||||
<item android:offset="1" android:color="#FF22D3EE" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
18
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
18
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:pivotX="54" android:pivotY="54" android:rotation="-45">
|
||||
<!-- Key bow: a ring. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M42,40a14,14 0,1 1,0 28a14,14 0,1 1,0 -28zM42,48.5a5.5,5.5 0,1 0,0 11a5.5,5.5 0,1 0,0 -11z" />
|
||||
<!-- Shaft with two teeth. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M54,50.5h26v7h-4v7h-7v-7h-3v5h-6v-5h-6z" />
|
||||
</group>
|
||||
</vector>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
4
app/src/main/res/values-night/colors.xml
Normal file
4
app/src/main/res/values-night/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="nexo_bg">#FF07080F</color>
|
||||
</resources>
|
||||
8
app/src/main/res/values-night/themes.xml
Normal file
8
app/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.NexoPass" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/nexo_bg</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
4
app/src/main/res/values/colors.xml
Normal file
4
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="nexo_bg">#FFF7F4FF</color>
|
||||
</resources>
|
||||
8
app/src/main/res/values/themes.xml
Normal file
8
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.NexoPass" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@color/nexo_bg</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
10
app/src/main/res/xml/data_extraction_rules.xml
Normal file
10
app/src/main/res/xml/data_extraction_rules.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Keep the vault out of cloud backups and device-to-device transfers. -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="file" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="file" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
5
build.gradle.kts
Normal file
5
build.gradle.kts
Normal 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
4
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
gradlew
vendored
Executable 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
94
gradlew.bat
vendored
Normal 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
16
settings.gradle.kts
Normal 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")
|
||||
Loading…
Reference in a new issue