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

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

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

77
android/build.gradle.kts Normal file
View file

@ -0,0 +1,77 @@
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
val v = rootProject.extra["appVersion"] as String
versionName = v
// 1.3.0 -> 10300
versionCode = v.split(".").map { it.toInt() }.let { it[0] * 10000 + it[1] * 100 + it[2] }
}
sourceSets["main"].apply {
java.srcDirs("src/main/java", "../shared/src")
assets.srcDirs("../shared/resources")
}
signingConfigs {
// CI signs with the same key as local builds (from secrets), so the
// app updates in place. Without it, the debug key is used.
create("release") {
val ks = System.getenv("NEXOPASS_KEYSTORE")
if (ks != null) {
storeFile = file(ks)
storePassword = System.getenv("NEXOPASS_KEYSTORE_PASSWORD")
keyAlias = System.getenv("NEXOPASS_KEY_ALIAS")
keyPassword = System.getenv("NEXOPASS_KEY_PASSWORD")
} else {
initWith(getByName("debug"))
}
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
}
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
android/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,4 @@
# Argon2 from BouncyCastle is used directly, keep it intact.
-keep class org.bouncycastle.crypto.generators.Argon2BytesGenerator { *; }
-keep class org.bouncycastle.crypto.params.Argon2Parameters** { *; }
-dontwarn org.bouncycastle.**

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- No INTERNET permission: the app cannot send anything anywhere. -->
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="NexoPass"
android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/Theme.NexoPass">
<activity
android:name=".android.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>

View file

@ -0,0 +1,166 @@
package com.nexoniarz.nexopass.android
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PersistableBundle
import android.os.SystemClock
import android.provider.Settings
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.nexoniarz.nexopass.app.AppState
import com.nexoniarz.nexopass.app.KeyValue
import com.nexoniarz.nexopass.app.LocalPlatform
import com.nexoniarz.nexopass.app.Platform
import com.nexoniarz.nexopass.app.Prefs
import com.nexoniarz.nexopass.core.AesVaultCodec
import com.nexoniarz.nexopass.core.NexoPass
import com.nexoniarz.nexopass.ui.App
import com.nexoniarz.nexopass.ui.NexoTheme
import com.nexoniarz.nexopass.ui.ParticleBackground
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
// 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 platform = AndroidPlatform(this)
val app = AppState(platform, NexoPass.loadWords(platform.resource("eff_large_wordlist.txt")))
setContent {
val dark = when (platform.prefs.theme) {
"dark" -> true
"light" -> false
else -> isSystemInDarkTheme()
}
CompositionLocalProvider(LocalPlatform provides platform) {
NexoTheme(dark) {
ParticleBackground(dark = dark, animated = platform.prefs.particles) { App(app) }
}
}
}
}
}
private class SharedPrefsKV(private val sp: SharedPreferences) : KeyValue {
override fun getInt(key: String, def: Int) = sp.getInt(key, def)
override fun putInt(key: String, value: Int) = sp.edit().putInt(key, value).apply()
override fun getString(key: String, def: String) = sp.getString(key, def) ?: def
override fun putString(key: String, value: String) = sp.edit().putString(key, value).apply()
override fun getBool(key: String, def: Boolean) = sp.getBoolean(key, def)
override fun putBool(key: String, value: Boolean) = sp.edit().putBoolean(key, value).apply()
}
class AndroidPlatform(private val activity: FragmentActivity) : Platform {
override val prefs = Prefs(SharedPrefsKV(activity.getSharedPreferences("nexopass", Context.MODE_PRIVATE)))
override val accountsDir = File(activity.filesDir, "accounts")
override val codec = AesVaultCodec
override val lock = AndroidLock(activity, activity.filesDir, prefs)
override val migrated = migrate()
override val aboutText = "Same passwords as nexopass on the PC. Nothing leaves this phone: the app has no internet permission."
/** 1.0/1.1 kept one vault in filesDir; it becomes the account "main". */
private fun migrate(): Boolean {
val old = File(activity.filesDir, "vault.bin")
if (!old.exists() || File(accountsDir, "main").exists()) return false
File(accountsDir, "main").mkdirs()
old.renameTo(File(accountsDir, "main/vault.bin"))
File(activity.filesDir, "vault.bin.bak").takeIf { it.exists() }?.renameTo(File(accountsDir, "main/vault.bin.bak"))
lock.wipe() // the old format held a single master
prefs.currentAccount = "main"
return true
}
override fun resource(name: String) = activity.assets.open(name).use { it.readBytes() }
override val animationsEnabled =
Settings.Global.getFloat(activity.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
/** Copies without a clipboard preview or keyboard history, and clears it after [seconds]. */
override fun copySecret(text: String, seconds: Int) {
val cm = activity.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)
}
@Composable
override fun BackHandler(enabled: Boolean, onBack: () -> Unit) =
androidx.activity.compose.BackHandler(enabled, onBack)
@Composable
override fun AutoLock(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) }
}
}
@Composable
override fun rememberExport(content: () -> ByteArray, onDone: (Boolean) -> Unit): (String) -> Unit {
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pgp-encrypted")) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
val ok = withContext(Dispatchers.IO) {
runCatching { activity.contentResolver.openOutputStream(uri)!!.use { it.write(content()) } }.isSuccess
}
onDone(ok)
}
}
return { launcher.launch(it) }
}
@Composable
override fun rememberImport(onBytes: (ByteArray?) -> Unit): () -> Unit {
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
onBytes(runCatching { activity.contentResolver.openInputStream(uri)!!.use { it.readBytes() } }.getOrNull())
}
return { launcher.launch(arrayOf("*/*")) }
}
}

View file

@ -0,0 +1,272 @@
package com.nexoniarz.nexopass.android
import com.nexoniarz.nexopass.app.AppLock
import com.nexoniarz.nexopass.app.Prefs
import com.nexoniarz.nexopass.app.UnlockResult
import com.nexoniarz.nexopass.app.Unlocked
import com.nexoniarz.nexopass.core.NexoPass
import com.nexoniarz.nexopass.ui.methodName
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
/** 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())
}
/**
* Phone 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 AndroidLock(private val activity: FragmentActivity, dir: File, private val prefs: Prefs) : AppLock {
private val quickFile = File(dir, "quick.bin")
private val bioFile = File(dir, "bio.bin")
override val isSet get() = quickFile.exists()
override val method: String get() = if (isSet) prefs.quickMethod else "none"
override val bioMode: String get() = if (isSet && bioFile.exists()) prefs.bioMode else "off"
override val methods = listOf("pin", "password", "pattern")
override fun problem(method: String, secret: String) = when (method) {
"pin" -> if (secret.length < 4) "Use at least 4 digits (6 is better)." else null
"pattern" -> if (secret.length < 4) "Connect at least 4 dots." else null
else -> if (secret.length < 6) "Use at least 6 characters." else null
}
/** New secret: new salt and key. Returns the inner key. Slow (Argon2). */
override 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. */
override fun save(innerKey: ByteArray, keyring: Map<String, Unlocked>) {
write(quickFile.readBytes().copyOfRange(0, 16), innerKey, keyring)
}
/** Slow (Argon2), run off the main thread. */
override fun unlock(secret: String): UnlockResult {
val data = quickFile.readBytes()
val inner = outer(data) ?: run { wipe(); return UnlockResult.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 UnlockResult.Wiped
}
return UnlockResult.Wrong(left)
}
prefs.failedAttempts = 0
return UnlockResult.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 }
}
override 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)
}
}
override 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. */
override fun enableBio(mode: String, innerKey: ByteArray, done: (String?) -> Unit) {
disableBio()
val cipher = Cipher.getInstance(GCM).apply { init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_BIO, mode)) }
prompt(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. */
override fun unlockBio(cancelText: String, onOk: (UnlockResult.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(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(UnlockResult.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(
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()
}
/** Which biometric options this phone supports. */
override fun bioOptions(): 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")
}
}
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"
}
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="0"
android:startY="0"
android:endX="108"
android:endY="108">
<item android:offset="0" android:color="#FF8B5CF6" />
<item android:offset="1" android:color="#FF22D3EE" />
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:pivotX="54" android:pivotY="54" android:rotation="-45">
<!-- Key bow: a ring. -->
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M42,40a14,14 0,1 1,0 28a14,14 0,1 1,0 -28zM42,48.5a5.5,5.5 0,1 0,0 11a5.5,5.5 0,1 0,0 -11z" />
<!-- Shaft with two teeth. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M54,50.5h26v7h-4v7h-7v-7h-3v5h-6v-5h-6z" />
</group>
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

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

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.NexoPass" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/nexo_bg</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View file

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

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.NexoPass" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/nexo_bg</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Keep the vault out of cloud backups and device-to-device transfers. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="." />
</device-transfer>
</data-extraction-rules>