Compare commits

...
27 Commits
Author SHA1 Message Date
hugo bf83a0ba41 Prepare Android release artifacts and listing (#112)
Android ARM verification / rust (push) Failing after 49m41s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 22s
Add Rust distribution auditing, pinned post-build AAB signing, complete offline third-party notices, an Android privacy policy and reviewable Play listing assets. Preserve the application ID and audit both ARM ABIs, native symbols, baseline profiles and R8 mapping.

Fix dialog focus ownership, navigation-triggered cleanup loops and stale authentication errors after first-run setup, with native and real-activity regressions.

Google Play publication is deferred by the user. Issue #112 remains open for production signing, Console declarations and actual Play install/update verification.
2026-10-15 16:16:01 +02:00
hugo b54636b7bc Complete Android parity and security audit (#111)
Android ARM verification / rust (push) Failing after 50m14s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 23s
2026-10-15 13:40:50 +02:00
hugo 45d78aa5b3 Complete Android adaptive and accessibility hardening (#105)
Android ARM verification / rust (push) Failing after 50m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Failing after 22s
2026-10-15 11:44:31 +02:00
hugo f4a6edbc68 Implement Android TOTP cached discovery and search (#110)
Android ARM verification / rust (push) Failing after 49m58s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 22s
2026-10-12 20:38:33 +02:00
hugo 0c8366d34f Implement Android Credential Manager and Autofill providers (#109)
Android ARM verification / rust (push) Failing after 49m12s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 21s
2026-10-12 19:18:10 +02:00
hugo 316a875d63 Implement confirmed Android tree mutation workflows (#108)
Android ARM verification / rust (push) Failing after 49m2s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 2m40s
2026-10-12 13:06:22 +02:00
hugo 6a864a8d7f Implement Android TOTP and confirmed HOTP presentation (#107)
Android ARM verification / rust (push) Failing after 49m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 21s
2026-10-12 11:43:23 +02:00
hugo d466b170ab Implement CameraX GPG QR transfer on Android (#106)
Android ARM verification / rust (push) Failing after 47m42s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s
2026-10-12 09:49:38 +02:00
hugo 6669b77f4e Harden Android accessibility, localization and startup (#105)
Android ARM verification / rust (push) Failing after 46m41s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s
Implement independent hardening and regression coverage. Keep #105 open for final feature and environment coverage after #106–#110; document the completion boundary and resume order.
2026-10-11 15:56:33 +02:00
hugo 43f660ace3 Implement complete Android settings and recovery controls (#104)
Android ARM verification / rust (push) Failing after 46m46s
Android ARM verification / android (push) Failing after 7s
Dependency security audit / rustsec (push) Successful in 22s
2026-10-10 12:07:28 +02:00
hugo 06575363a2 feat(android): add structured entry editor and encrypted draft recovery (#103)
Android ARM verification / rust (push) Failing after 46m30s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s
2026-10-09 22:11:37 +02:00
hugo d22d71eb5e Implement adaptive Android shell and safe navigation (#102)
Android ARM verification / rust (push) Failing after 47m11s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 25s
2026-10-09 20:33:31 +02:00
hugo c7703d8efd Implement Rust-backed Android name search (#101)
Android ARM verification / rust (push) Failing after 47m11s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 23s
2026-10-09 19:30:15 +02:00
hugo a06f39af23 Enforce Android lifecycle and presentation security (#100)
Android ARM verification / rust (push) Failing after 47m6s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s
2026-10-09 18:59:18 +02:00
hugo c0ba6d43d8 Implement structured Android entry viewer (#99)
Android ARM verification / rust (push) Failing after 47m18s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s
2026-10-09 17:36:38 +02:00
hugo 647dc1bb4b Integrate Android biometrics with the shared Rust lease (#98)
Android ARM verification / rust (push) Failing after 47m23s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s
2026-10-09 16:32:01 +02:00
hugo 5c933a99a3 Implement Android Home activity and HTTPS synchronization (#97)
Android ARM verification / rust (push) Failing after 46m50s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s
2026-10-09 14:45:57 +02:00
hugo 8c9341f214 Implement Rust-owned adaptive Android password navigation (#96)
Android ARM verification / rust (push) Failing after 46m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 22s
2026-10-09 12:25:02 +02:00
hugo 36e5f55283 Implement Rust-owned Android local and HTTPS onboarding (#95)
Android ARM verification / rust (push) Failing after 46m9s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 21s
2026-10-09 11:31:50 +02:00
hugo 66b1d4a4c4 Establish native Linux ARM64 Android CI and verification gates (#94)
Android ARM verification / rust (push) Successful in 50m6s
Android ARM verification / android (push) Successful in 1h33m57s
Dependency security audit / rustsec (push) Successful in 21s
2026-10-09 05:02:59 +02:00
hugo 1abd75282e Implement Rust-owned Android Keystore secure storage (#93)
Dependency security audit / rustsec (push) Failing after 23s
2026-10-08 16:22:57 +02:00
hugo 874c8139a4 feat(android): implement private Rust storage runtime (#90)
Dependency security audit / rustsec (push) Failing after 23s
2026-10-08 14:36:05 +02:00
hugo 180851087b Share the platform-neutral Rust mobile contract (#91)
Dependency security audit / rustsec (push) Failing after 22s
2026-10-08 13:25:09 +02:00
hugo fcb36a0684 Package the Rust mobile core and generated Kotlin API for Android (#92)
Dependency security audit / rustsec (push) Failing after 24s
2026-10-08 12:34:09 +02:00
hugo e079b82b15 Scaffold native Android build and enforce Rust boundaries (#89)
Dependency security audit / rustsec (push) Failing after 22s
2026-10-08 10:16:51 +02:00
hugo 30d9119302 Define native Android architecture and parity contract (#88)
Dependency security audit / rustsec (push) Failing after 26s
2026-10-08 09:35:03 +02:00
Georg Bauer 71a4f289cf Fix nested runtime panic during SSH key authentication
Dependency security audit / rustsec (push) Successful in 23s
2026-09-12 18:08:23 +02:00
330 changed files with 83585 additions and 4547 deletions

No files matched your search

+731
View File
@@ -0,0 +1,731 @@
#!/usr/bin/env python3
"""Build-only orchestration; never shipped or invoked by the Android application."""
import argparse
import hashlib
import json
import math
import os
from pathlib import Path
import platform
import re
import runpy
import shutil
import tarfile
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
import subprocess
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
ABIS = ("arm64-v8a", "armeabi-v7a")
MODULES = ("app", "core", "features", "platform", "rust-bridge", "provider-client")
SDK = Path(os.environ.get("ANDROID_HOME", os.environ.get("ANDROID_SDK_ROOT", "")))
CARGO = os.environ.get("CARGO", "cargo")
def run(*args, capture=False, **kwargs):
return subprocess.run([str(a) for a in args], check=True, text=True,
stdout=subprocess.PIPE if capture else None, **kwargs)
def tool(*args):
return run(CARGO, "+1.97.1", "run", "--locked", "-p", "ironstorage-android-build", "--", *args)
def download(spec, path):
if not path.exists():
with urllib.request.urlopen(spec["url"], timeout=60) as response, path.open("wb") as output:
shutil.copyfileobj(response, output)
if hashlib.sha256(path.read_bytes()).hexdigest() != spec["sha256"]:
raise RuntimeError("CI toolchain checksum mismatch")
def prepare_linux():
if platform.system() != "Linux" or platform.machine() not in ("aarch64", "arm64"):
raise RuntimeError("Native CI toolchains require Linux ARM64")
SDK.mkdir(parents=True, exist_ok=True)
policy = json.loads((ROOT / "android/ci-toolchains.json").read_text())
with tempfile.TemporaryDirectory(prefix="ironstorage-tools-") as directory:
directory = Path(directory)
for key, spec in policy.items():
download(spec, directory / key)
cli = SDK / "cmdline-tools/latest"
if not cli.exists():
destination = SDK / "cmdline-tools"
with zipfile.ZipFile(directory / "sdk_cli") as archive:
archive.extractall(destination)
(destination / "cmdline-tools").rename(cli)
for path in (cli / "bin").iterdir():
path.chmod(0o755)
ndk = SDK / "ndk/30.0.16248370"
if not ndk.exists():
destination = SDK / "ndk"
destination.mkdir(parents=True, exist_ok=True)
with tarfile.open(directory / "ndk") as archive:
archive.extractall(destination, filter="data")
(destination / "android-ndk-r30").rename(ndk)
prebuilt = ndk / "toolchains/llvm/prebuilt"
alias = prebuilt / "linux-x86_64"
# cargo-ndk 4.1.2 hardcodes this directory name on Linux; binaries remain native ARM64.
if not alias.exists():
alias.symlink_to("linux-arm64", target_is_directory=True)
native = SDK / "native-arm64"
if not native.exists():
with zipfile.ZipFile(directory / "sdk_native") as archive:
archive.extractall(native)
for path in native.rglob("*"):
if path.is_file():
path.chmod(0o755)
run(cli / "bin/sdkmanager", "--licenses", input="y\n" * 100)
run(cli / "bin/sdkmanager", "platforms;android-37.0", "build-tools;36.0.0")
run(prebuilt / "linux-arm64/bin/clang", "--version")
run(prebuilt / "linux-arm64/bin/ld.lld", "--version")
run(native / "build-tools/aapt2", "version")
def gradle(*args):
options = [ROOT / "android/gradlew", "-p", ROOT / "android", "--no-configuration-cache", "--console=plain"]
if os.environ.get("CI") == "true":
options.extend(("--no-daemon", "-Pkotlin.compiler.execution.strategy=in-process"))
if platform.system() == "Linux" and platform.machine() in ("aarch64", "arm64"):
options.append("-Pandroid.aapt2FromMavenOverride=" + str(ROOT / ".gitea/scripts/native-arm64/aapt2"))
return run(*options, *args, cwd=ROOT)
def verification_phases():
return (
tuple(f":{module}:testDebugUnitTest" for module in MODULES),
(":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease"),
*((f":{module}:assembleDebugAndroidTest",) for module in ("app", "core", "rust-bridge", "platform")),
*((f":{module}:ktlintCheck", f":{module}:lintDebug") for module in MODULES),
(":app:assembleBenchmark", ":app:assembleProfile", ":performance:assembleBenchmark", ":performance:ktlintCheck"),
("androidVerification",),
)
def resources():
facts = {"available_disk_bytes": shutil.disk_usage(ROOT).free}
memory = Path("/proc/meminfo")
if memory.exists():
facts["memory_kib"] = {key: int(value.split()[0]) for line in memory.read_text().splitlines()
for key, value in [line.split(":", 1)]
if key in ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree")}
for name in ("memory.current", "memory.max", "memory.swap.max", "memory.events"):
path = Path("/sys/fs/cgroup") / name
if path.exists():
facts[name] = path.read_text().strip()
print("CI resource facts " + json.dumps(facts, sort_keys=True), flush=True)
def select_symbols(symbols, output):
allowed = {f"{abi}/{lib}.dbg" for abi in ABIS for lib in ("libironstorage_android.so", "libjnidispatch.so")}
seen = set()
with zipfile.ZipFile(symbols) as source, zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as destination:
for name in source.namelist():
if name.split("/", 1)[0] in ABIS:
if name not in allowed or name in seen:
raise RuntimeError("Unreviewed or duplicate native debug symbol path")
seen.add(name)
with source.open(name) as original, destination.open(name, "w", force_zip64=True) as target:
shutil.copyfileobj(original, target)
if seen != allowed:
raise RuntimeError("Supported ARM debug symbols missing")
def checksum_artifacts(output):
checksums = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(output.iterdir()) if path.is_file() and path.name != "sha256.json"}
(output / "sha256.json").write_text(json.dumps(checksums, indent=2) + "\n")
def upload_signing():
names = ("STORE_FILE", "STORE_PASSWORD", "KEY_ALIAS", "KEY_PASSWORD", "CERT_SHA256")
signing = {name: os.environ.get("IRONSTORAGE_UPLOAD_" + name, "") for name in names}
if not all(signing.values()) or not re.fullmatch(r"[0-9a-fA-F]{64}", signing["CERT_SHA256"]):
raise RuntimeError("Distribution requires all five IRONSTORAGE_UPLOAD variables and a pinned SHA-256 certificate")
store = Path(signing["STORE_FILE"]).resolve(strict=True)
if store.is_relative_to(ROOT) or not store.is_file():
raise RuntimeError("Upload keystore must be a file outside the repository")
certificate = subprocess.run(["keytool", "-exportcert", "-keystore", str(store),
"-alias", signing["KEY_ALIAS"], "-storepass:env", "IRONSTORAGE_UPLOAD_STORE_PASSWORD"],
check=True, stdout=subprocess.PIPE).stdout
if hashlib.sha256(certificate).hexdigest() != signing["CERT_SHA256"].lower():
raise RuntimeError("Upload certificate differs from the independently pinned certificate")
return signing, store
def distribution():
"""Build/sign/audit locally or on a trusted runner; never publishes to Play."""
signing, store = upload_signing()
public_env = {key: value for key, value in os.environ.items() if not key.startswith("IRONSTORAGE_UPLOAD_")}
run("python3", ROOT / ".gitea/scripts/android_ci.py", "build", env=public_env)
source = ROOT / "android/build/ci-artifacts"
# Only distribution artifacts go to the release archive, never debug/test APKs.
output = ROOT / "android/build/distribution"
output.mkdir(parents=True, exist_ok=False)
try:
for name in ("mapping.txt", "native-debug-symbols.zip", "sbom.cdx.json", "third-party-notices.txt"):
shutil.copy2(source / name, output / name)
bundle = output / "app-release.aab"
run("jarsigner", "-keystore", store, "-storepass:env", "IRONSTORAGE_UPLOAD_STORE_PASSWORD",
"-keypass:env", "IRONSTORAGE_UPLOAD_KEY_PASSWORD", "-digestalg", "SHA-256",
"-signedjar", bundle, source / "app-release.aab", signing["KEY_ALIAS"])
run("jarsigner", "-verify", "-strict", "-keystore", store,
"-storepass:env", "IRONSTORAGE_UPLOAD_STORE_PASSWORD", bundle, signing["KEY_ALIAS"])
jar = ROOT / "android/build/bundletool.jar"
download(json.loads((ROOT / "android/bundletool.json").read_text()), jar)
run("java", "-jar", jar, "validate", "--bundle=" + str(bundle), env=public_env)
manifest = output / "bundle-manifest.xml"
manifest.write_text(run("java", "-jar", jar, "dump", "manifest", "--bundle=" + str(bundle), capture=True, env=public_env).stdout)
run(CARGO, "+1.97.1", "run", "--locked", "-p", "ironstorage-android-build", "--", "audit-distribution",
ROOT / "android", manifest, bundle, output / "mapping.txt",
output / "native-debug-symbols.zip", output / "third-party-notices.txt", output / "distribution.json", env=public_env)
(output / "upload-certificate-sha256.txt").write_text(signing["CERT_SHA256"].lower() + "\n")
checksum_artifacts(output)
except BaseException:
shutil.rmtree(output)
raise
def backup_references(manifest, resource_table):
application = ET.fromstring(manifest).find("application")
if application is None:
raise RuntimeError("Packaged application missing")
ids = []
for attribute, name in (("fullBackupContent", "backup_rules"), ("dataExtractionRules", "data_extraction_rules")):
values = re.findall(r"(?m)^\s*resource (0x[0-9a-f]{8}) xml/" + name + r"(?:\s|$)", resource_table)
if len(values) != 1:
raise RuntimeError("Backup resource symbol missing or ambiguous")
if application.get("{http://schemas.android.com/apk/res/android}" + attribute) != "@ref/" + values[0]:
raise RuntimeError("Packaged manifest selects an unreviewed backup resource")
ids.extend(values)
if len(set(ids)) != 2:
raise RuntimeError("Backup resource symbols overlap")
def clear_native_intermediates(target):
paths = [target / triple for triple in ("aarch64-linux-android", "armv7-linux-androideabi")]
if target.resolve() != target.absolute() or any(path.is_symlink() or (path.exists() and not path.is_dir()) for path in paths):
raise RuntimeError("Native scratch path must not contain symlinks or unexpected files")
for path in paths:
if path.exists():
shutil.rmtree(path)
def clean_ci(root=ROOT):
if os.environ.get("CI") != "true":
raise RuntimeError("Build cleanup is restricted to CI checkouts")
paths = [root / "target", root / "android/build"]
paths.extend(root / "android" / module / "build" for module in (*MODULES, "performance"))
if any(path.resolve() != path or (path.exists() and not path.is_dir()) for path in paths):
raise RuntimeError("CI build directories must not contain symlinks or unexpected files")
for path in paths:
if path.exists():
shutil.rmtree(path)
def build():
ci = os.environ.get("CI") == "true"
scratch = ROOT / "android/build/native-cargo"
if ci:
resources()
if scratch.resolve() != scratch:
raise RuntimeError("CI Cargo scratch directory must not contain symlinks")
os.environ["CARGO_TARGET_DIR"] = str(scratch)
# Resolve/check the distribution before spending time compiling the host tool.
gradle("--version")
# UniFFI generation is memory-heavy; compile host tools before Gradle build JVMs.
run(CARGO, "+1.97.1", "build", "--locked", "-p", "ironstorage-android-build", cwd=ROOT)
if ci:
for variant in ("Debug", "Release"):
gradle(f":rust-bridge:prepare{variant}RustBindings")
clear_native_intermediates(scratch)
if ci:
# Release retained JVM/compiler state between memory-heavy build phases.
for phase in verification_phases():
print("CI Gradle phase " + " ".join(phase), flush=True)
try:
gradle(*phase)
finally:
resources()
else:
gradle("androidVerification")
output = ROOT / "android/build/ci-artifacts"
output.mkdir(parents=True, exist_ok=True)
apk = ROOT / "android/app/build/outputs/apk/release/app-release-unsigned.apk"
symbols = ROOT / "android/app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip"
selected_symbols = output / "native-debug-symbols.zip"
select_symbols(symbols, selected_symbols)
tool("audit-release", apk, selected_symbols)
analyzer = SDK / "cmdline-tools/latest/bin/apkanalyzer"
aapt2 = ROOT / ".gitea/scripts/native-arm64/aapt2" if platform.system() == "Linux" else SDK / "build-tools/36.0.0/aapt2"
for variant, path in [("debug", ROOT / "android/app/build/outputs/apk/debug/app-debug.apk"), ("release", apk)]:
manifest = output / (variant + "-manifest.xml")
manifest.write_text(run(analyzer, "manifest", "print", path, capture=True).stdout)
backup_references(manifest.read_text(), run(aapt2, "dump", "resources", path, capture=True).stdout)
rules = []
for name in ("backup_rules", "data_extraction_rules"):
resource = run(analyzer, "resources", "value", "--type", "xml", "--config", "default", "--name", name, path, capture=True).stdout.strip()
decoded = output / f"{variant}-{name}.xml"
decoded.write_text(run(analyzer, "resources", "xml", "--file", resource, path, capture=True).stdout)
rules.append(decoded)
tool("audit-manifest", manifest, *rules)
if variant == "release":
tool("audit-release-manifest", manifest)
trees = []
for target in ("aarch64-linux-android", "armv7-linux-androideabi"):
trees.append(run(CARGO, "+1.97.1", "tree", "--locked", "-p", "ironstorage-android", "--target", target, "--edges", "normal", "--prefix", "none", "--format", "{p}|{l}|{r}", capture=True, cwd=ROOT).stdout)
tree = ROOT / "android/build/rust-dependencies.txt"
tree.write_text("\n".join(trees))
inventory = ROOT / "android/build/reports/android-dependencies.json"
rows = []
for module in MODULES:
rows.extend(json.loads((ROOT / "android" / module / "build/reports/android-dependencies.json").read_text()))
inventory.parent.mkdir(parents=True, exist_ok=True)
inventory.write_text(json.dumps(rows, sort_keys=True) + "\n")
tool("sbom", inventory, tree, ROOT / "Cargo.lock", ROOT / "android/dependency-policy.json", output / "sbom.cdx.json")
for module in MODULES:
reports = ROOT / "android" / module / "build/test-results"
if module in ("app", "core") or list(reports.rglob("TEST-*.xml")):
tool("safe-reports", reports, output / (module + "-unit-tests.json"))
files = [ROOT / "android/app/build/outputs/apk/debug/app-debug.apk", apk,
ROOT / "android/app/build/outputs/bundle/release/app-release.aab",
ROOT / "android/app/build/outputs/mapping/release/mapping.txt",
ROOT / "android/app/build/generated/notices/assets/third-party-notices.txt"]
for path in files:
shutil.copy2(path, output / path.name)
checksum_artifacts(output)
PERFORMANCE_BUDGETS = {
"editor_100_updates_ms": 500,
"editor_update_p95_us": 32000,
"cache_rebuild_ms": 15000,
"qr_analysis_ms": 5000,
"totp_cached_open_p95_us": 1000000,
"totp_warm_refresh_p95_us": 3000000,
"provider_prepare_p95_us": 1000000,
"provider_authenticated_fill_ms": 5000,
"tree_10000_navigation_ms": 3000,
"tree_10000_search_ms": 5000,
}
def performance_metrics(result):
values = {}
for name, number in re.findall(r"^INSTRUMENTATION_STATUS: ironstorage_([a-z0-9_]+)=([0-9]{1,12})$", result, re.MULTILINE):
if name in PERFORMANCE_BUDGETS:
if name in values:
raise RuntimeError("Duplicate performance measurement")
values[name] = int(number)
if values.keys() != PERFORMANCE_BUDGETS.keys():
raise RuntimeError("Missing required journey measurements")
if any(values[name] >= bound for name, bound in PERFORMANCE_BUDGETS.items()):
raise RuntimeError("Native journey exceeded its performance budget")
return values
def emulator_ready(user_state, power_state, trust_state):
return (user_state == "RUNNING_UNLOCKED" and
re.search(r"^\s*mWakefulness=Awake$", power_state, re.MULTILINE) is not None and
re.search(r'^\s*User .*\(id=0,.*deviceLocked=(?:0|false)\b', trust_state, re.MULTILINE) is not None)
def instrumentation(command, package, arguments=(), target=None):
try:
return command("shell", "am", "instrument", "-w", "-r", *arguments,
package + "/androidx.test.runner.AndroidJUnitRunner")
finally:
try:
command("shell", "am", "force-stop", package)
finally:
if target is not None and target != package:
command("shell", "am", "force-stop", target)
def emulator(serial):
adb = SDK / "platform-tools/adb"
def command(*args):
return run(adb, "-s", serial, *args, capture=True, timeout=900).stdout.strip()
abi = command("shell", "getprop", "ro.product.cpu.abi")
if abi not in ABIS:
raise RuntimeError("Only ARM emulators are in scope")
if command("shell", "getprop", "ro.kernel.qemu") != "1":
raise RuntimeError("Physical devices are outside validation scope")
if not emulator_ready(command("shell", "am", "get-started-user-state", "0"),
command("shell", "dumpsys", "power"),
command("shell", "dumpsys", "trust")):
raise RuntimeError("Wake and unlock the Android emulator before starting instrumentation")
api = int(command("shell", "getprop", "ro.build.version.sdk"))
try:
pages = int(command("shell", "getconf", "PAGE_SIZE"))
except subprocess.CalledProcessError:
sizes = {int(line.split()[1]) * 1024 for line in command("shell", "cat", "/proc/self/smaps").splitlines() if line.startswith("KernelPageSize:")}
if len(sizes) != 1:
raise RuntimeError("Cannot determine emulator kernel page size")
pages = sizes.pop()
if pages == 16384:
command("shell", "setprop", "bionic.linker.16kb.app_compat.enabled", "false")
command("shell", "setprop", "pm.16kb.app_compat.disabled", "true")
apk = ROOT / "android/app/build/outputs/apk/debug/app-debug.apk"
command("install", "-r", apk)
# Android's test runner and Rust fixtures cover typed errors, callbacks and cancellation.
suites = [("app", "de.rfc1437.ironstorage.test", None),
("core", "de.rfc1437.ironstorage.core.test", None),
("rust-bridge", "de.rfc1437.ironstorage.rustbridge.test", None),
("platform", "de.rfc1437.ironstorage.platform.test",
"de.rfc1437.ironstorage.platform.AndroidQrCameraTest,"
"de.rfc1437.ironstorage.platform.AndroidKeystoreTest#deviceUnlockedRoundtripReplaceDeleteAndTamper,"
"de.rfc1437.ironstorage.platform.AndroidKeystoreTest#unsupportedUnlockedDeviceFlagDoesNotCreateAKey")]
native_timings = []
journey_timings = {}
for module, package, classes in suites:
test_apk = next((ROOT / "android" / module / "build/outputs/apk/androidTest/debug").glob("*.apk"))
command("install", "-r", test_apk)
if module == "rust-bridge":
for _ in range(5):
command("shell", "am", "force-stop", package)
measured = instrumentation(command, package, ("-e", "class", "de.rfc1437.ironstorage.bridge.NativeLoadPerformanceTest"))
samples = [int(line.split("=", 1)[1]) for line in measured.splitlines() if line.startswith("INSTRUMENTATION_STATUS: ironstorage_native_load_ns=")]
if len(samples) != 1 or "OK (1 test)" not in measured:
raise RuntimeError("Native cold-load measurement failed")
native_timings.extend(samples)
camera_granted = None
if module == "platform":
camera_granted = "android.permission.CAMERA: granted=true" in command("shell", "dumpsys", "package", package)
if not camera_granted:
command("shell", "pm", "grant", package, "android.permission.CAMERA")
arguments = ["-e", "class", classes] if classes else []
print(f"ARM API {api}, {pages}-byte pages: invoking {module} instrumentation", flush=True)
try:
result = instrumentation(command, package, arguments,
"de.rfc1437.ironstorage" if module == "app" else package)
finally:
if camera_granted is False:
command("shell", "pm", "revoke", package, "android.permission.CAMERA")
if "FAILURES!!!" in result or "INSTRUMENTATION_FAILED" in result or "OK (" not in result:
local = ROOT / "android/build/local-validation"
local.mkdir(parents=True, exist_ok=True)
local.chmod(0o700)
raw = local / f"{serial}-{module}-failure.txt"
raw.write_text(result)
raw.chmod(0o600)
raise RuntimeError("ARM emulator instrumentation failed (raw output retained locally only)")
if module == "core":
journey_timings = performance_metrics(result)
print(f"ARM API {api}, {pages}-byte pages: {module} instrumentation passed", flush=True)
# No activity state or application data is deleted between process recreation runs.
timings = []
for _ in range(5):
command("shell", "am", "force-stop", "de.rfc1437.ironstorage")
result = command("shell", "am", "start", "-W", "-n", "de.rfc1437.ironstorage/.MainActivity")
values = dict(line.split(":", 1) for line in result.splitlines() if ":" in line)
if values.get("Status", "").strip() != "ok":
raise RuntimeError("Cold start failed")
timings.append(int(values["TotalTime"].strip()))
output = ROOT / "android/build/ci-artifacts"
output.mkdir(parents=True, exist_ok=True)
(output / f"emulator-api{api}-{pages}.json").write_text(json.dumps({"api": api, "abi": abi, "page_size": pages, "cold_start_ms": timings, "native_load_ns": native_timings, "journey_timings": journey_timings, "instrumentation": "passed"}, indent=2) + "\n")
checksum_artifacts(output)
print(f"ARM API {api}, {pages}-byte pages: instrumentation and five cold starts passed")
def startup_metrics(data):
values = {}
for row in data["benchmarks"]:
mode = {"coldStart": "cold", "warmStart": "warm"}.get(row["name"])
if mode is None:
continue
if mode in values or row["repeatIterations"] != 5:
raise RuntimeError("Startup measurements must have five isolated iterations")
samples = {}
for metric, key in [("timeToInitialDisplayMs", "initial_display_ms"),
("timeToFullDisplayMs", "full_display_ms")]:
runs = row["metrics"][metric]["runs"]
budget = 5000 if mode == "cold" else 3000
if len(runs) != 5 or any(type(value) not in (int, float) or
not math.isfinite(value) or value <= 0 or value >= budget for value in runs):
raise RuntimeError("Incomplete or over-budget startup measurement")
samples[key] = runs
values[mode] = samples
if values.keys() != {"cold", "warm"}:
raise RuntimeError("Both cold and warm startup measurements are required")
return values
def performance(serial, profiles=False):
adb = SDK / "platform-tools/adb"
def command(*args):
return run(adb, "-s", serial, *args, capture=True, timeout=900).stdout.strip()
abi = command("shell", "getprop", "ro.product.cpu.abi")
api = int(command("shell", "getprop", "ro.build.version.sdk"))
if abi not in ABIS or command("shell", "getprop", "ro.kernel.qemu") != "1":
raise RuntimeError("Performance validation requires an ARM emulator")
if not emulator_ready(command("shell", "am", "get-started-user-state", "0"),
command("shell", "dumpsys", "power"),
command("shell", "dumpsys", "trust")):
raise RuntimeError("Wake and unlock the Android emulator before performance validation")
if profiles and api < 34:
raise RuntimeError("Data-preserving baseline generation requires API 34 or newer")
variant = "profile" if profiles else "benchmark"
gradle(":app:assemble" + variant.capitalize(), ":performance:assembleBenchmark")
command("install", "-r", ROOT / f"android/app/build/outputs/apk/{variant}/app-{variant}.apk")
harness = "de.rfc1437.ironstorage.performance"
command("install", "-r", "-t", ROOT / "android/performance/build/outputs/apk/benchmark/performance-benchmark.apk")
directory = "/sdcard/Android/media/" + harness + "/additional_test_output"
command("shell", "mkdir", "-p", directory)
test = "BaselineProfile" if profiles else "StartupBenchmark"
print(f"ARM API {api}: invoking {test} instrumentation", flush=True)
result = instrumentation(command, harness, ("-e", "class", harness + "." + test,
"-e", "additionalTestOutputDir", directory,
"-e", "androidx.benchmark.suppressErrors", "EMULATOR"),
"de.rfc1437.ironstorage")
local = ROOT / "android/build/local-validation" / ("performance-" + serial)
local.mkdir(parents=True, exist_ok=True)
local.chmod(0o700)
(local / (test + ".txt")).write_text(result)
(local / (test + ".txt")).chmod(0o600)
if "FAILURES!!!" in result or "INSTRUMENTATION_FAILED" in result or "OK (" not in result:
raise RuntimeError("Performance instrumentation failed; raw output stays local")
command("pull", directory, local)
if not profiles:
data = json.loads((local / "additional_test_output" / (harness + "-benchmarkData.json")).read_text())
metrics = startup_metrics(data)
output = ROOT / "android/build/ci-artifacts"
output.mkdir(parents=True, exist_ok=True)
(output / f"startup-api{api}-{abi}.json").write_text(json.dumps({"api": api, "abi": abi, "startup": metrics}, allow_nan=False, indent=2) + "\n")
checksum_artifacts(output)
print(f"ARM API {api}: {test} passed; inspect local artifacts before promoting numeric metrics or generated profiles")
class SelfTest(unittest.TestCase):
def test_distribution_does_not_pass_upload_secrets_to_the_build(self):
with patch.dict(os.environ, {"IRONSTORAGE_UPLOAD_STORE_PASSWORD": "public-fixture-password"}), \
patch(__name__ + ".upload_signing", return_value=({}, Path("/tmp/fixture.p12"))), \
patch(__name__ + ".run", side_effect=RuntimeError("stop before build")) as build:
with self.assertRaisesRegex(RuntimeError, "stop before build"):
distribution()
build.assert_called_once()
self.assertFalse(any(key.startswith("IRONSTORAGE_UPLOAD_") for key in build.call_args.kwargs["env"]))
self.assertIn("PATH", build.call_args.kwargs["env"])
def test_notices_reject_uncovered_rust_crates_and_unreviewed_android_terms(self):
from android_notices import android_notices, rust_notices
data = {"licenses": [{"name": "MIT", "text": "public license fixture", "used_by": [
{"crate": {"name": "fixture", "version": "1", "authors": ["Public Author"], "repository": "https://example.test/source"}}
]}]}
text = rust_notices(data, {("fixture", "1")})
self.assertIn("Public Author", text)
self.assertIn("https://crates.io/crates/fixture/1", text)
self.assertNotIn("source_path", text)
with self.assertRaisesRegex(RuntimeError, "missing a license notice"):
rust_notices(data, {("other", "1")})
data["licenses"][0]["text"] = ""
with self.assertRaisesRegex(RuntimeError, "Empty Rust license"):
rust_notices(data, set())
for policy in ({}, {"fixture:library:1": "MIT"}):
with self.assertRaisesRegex(RuntimeError, "needs review"):
android_notices([{"component": "fixture:library:1", "path": "must-not-be-read"}], policy)
def test_distribution_refuses_missing_credentials_and_unpinned_signers(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(RuntimeError, "all five"):
upload_signing()
with tempfile.TemporaryDirectory() as directory:
store = Path(directory) / "upload.p12"
store.write_bytes(b"public fixture")
signing = {"IRONSTORAGE_UPLOAD_" + key: value for key, value in {
"STORE_FILE": str(store), "STORE_PASSWORD": "public", "KEY_ALIAS": "fixture",
"KEY_PASSWORD": "public", "CERT_SHA256": "0" * 64,
}.items()}
with patch.dict(os.environ, signing, clear=True), patch.object(subprocess, "run") as keytool:
keytool.return_value.stdout = b"public fixture certificate"
with self.assertRaisesRegex(RuntimeError, "independently pinned"):
upload_signing()
keytool.assert_called_once()
signing["IRONSTORAGE_UPLOAD_STORE_FILE"] = str(Path(__file__).resolve())
with patch.dict(os.environ, signing, clear=True):
with self.assertRaisesRegex(RuntimeError, "outside the repository"):
upload_signing()
def test_instrumentation_stops_harness_and_target_on_failure(self):
calls = []
def command(*args):
calls.append(args)
if args[2] == "instrument":
raise RuntimeError("disconnected emulator")
with self.assertRaisesRegex(RuntimeError, "disconnected emulator"):
instrumentation(command, "fixture.test", target="fixture")
self.assertEqual(calls[-2:], [("shell", "am", "force-stop", "fixture.test"),
("shell", "am", "force-stop", "fixture")])
def test_emulator_preflight_rejects_post_reboot_lock_and_sleep(self):
unlocked = ' User "Owner" (id=0, flags=0x13) (current): deviceLocked=0, strongAuthRequired=0x0'
self.assertTrue(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Awake\n", unlocked))
self.assertFalse(emulator_ready("RUNNING_LOCKED", " mWakefulness=Awake\n", unlocked))
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Asleep\n", unlocked))
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", "", unlocked))
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Awake\n", unlocked.replace("deviceLocked=0", "deviceLocked=1")))
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Awake\n", ""))
def test_startup_requires_complete_bounded_numeric_samples(self):
def data():
return {"benchmarks": [{"name": name, "repeatIterations": 5, "metrics": {
metric: {"runs": [1, 2, 3, 4, 5]} for metric in ("timeToInitialDisplayMs", "timeToFullDisplayMs")
}} for name in ("coldStart", "warmStart")]}
self.assertEqual(set(startup_metrics(data())), {"cold", "warm"})
for samples in ([1, 2], [1, 2, 3, 4, float("nan")], [1, 2, 3, 4, 5000], [1, 2, 3, 4, "secret"], [1, 2, 3, 4, True]):
invalid = data()
invalid["benchmarks"][0]["metrics"]["timeToFullDisplayMs"]["runs"] = samples
with self.assertRaises(RuntimeError):
startup_metrics(invalid)
invalid = data()
invalid["benchmarks"].append(invalid["benchmarks"][0])
with self.assertRaises(RuntimeError):
startup_metrics(invalid)
def test_performance_metrics_retain_only_reviewed_numbers_and_enforce_budgets(self):
valid = "\n".join(f"INSTRUMENTATION_STATUS: ironstorage_{name}=1" for name in PERFORMANCE_BUDGETS)
self.assertEqual(performance_metrics(valid + "\nINSTRUMENTATION_STATUS: ironstorage_secret=999"), dict.fromkeys(PERFORMANCE_BUDGETS, 1))
for invalid in (valid.replace("editor_100_updates_ms=1", "editor_100_updates_ms=500"), valid.replace("qr_analysis_ms=1", "qr_analysis_ms=NaN"), valid + "\nINSTRUMENTATION_STATUS: ironstorage_qr_analysis_ms=1"):
with self.assertRaises(RuntimeError):
performance_metrics(invalid)
def test_isolated_phases_preserve_all_verification_tasks(self):
phases = verification_phases()
expected = {f":{module}:{task}" for module in MODULES
for task in ("testDebugUnitTest", "ktlintCheck", "lintDebug")}
expected.update(f":{module}:assembleDebugAndroidTest" for module in ("app", "core", "rust-bridge", "platform"))
expected.update((":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease", ":app:assembleBenchmark", ":app:assembleProfile", ":performance:assembleBenchmark", ":performance:ktlintCheck", "androidVerification"))
self.assertEqual({task for phase in phases for task in phase}, expected)
self.assertEqual(phases[-1], ("androidVerification",))
self.assertTrue(all(len(phase) <= len(MODULES) for phase in phases))
def test_ci_cleanup_refuses_local_or_redirected_build_directories(self):
previous = os.environ.get("CI")
try:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
(root / "target").mkdir()
(root / "target/public-fixture").write_text("artifact")
os.environ.pop("CI", None)
with self.assertRaisesRegex(RuntimeError, "restricted to CI"):
clean_ci(root)
self.assertTrue((root / "target/public-fixture").exists())
os.environ["CI"] = "true"
(root / "android/app").mkdir(parents=True)
(root / "android/app/build").symlink_to(root / "target", target_is_directory=True)
with self.assertRaisesRegex(RuntimeError, "symlinks"):
clean_ci(root)
self.assertTrue((root / "target/public-fixture").exists())
(root / "android/app/build").unlink()
(root / "android/app/build").mkdir()
(root / "android/app/src").mkdir()
clean_ci(root)
self.assertFalse((root / "target").exists())
self.assertFalse((root / "android/app/build").exists())
self.assertTrue((root / "android/app/src").is_dir())
finally:
if previous is None:
os.environ.pop("CI", None)
else:
os.environ["CI"] = previous
def test_native_scratch_cleanup_keeps_host_tools_and_rejects_symlinks(self):
with tempfile.TemporaryDirectory() as directory:
target = Path(directory).resolve()
triples = ("aarch64-linux-android", "armv7-linux-androideabi")
for name in (*triples, "debug"):
(target / name).mkdir()
(target / name / "public-fixture").write_text("artifact")
clear_native_intermediates(target)
self.assertTrue((target / "debug/public-fixture").is_file())
self.assertTrue(all(not (target / name).exists() for name in triples))
(target / triples[0]).symlink_to(target / "debug", target_is_directory=True)
with self.assertRaisesRegex(RuntimeError, "symlinks"):
clear_native_intermediates(target)
self.assertTrue((target / "debug/public-fixture").is_file())
def test_manifest_must_select_the_audited_backup_resources(self):
manifest = '<manifest xmlns:android="http://schemas.android.com/apk/res/android"><application android:fullBackupContent="@ref/0x7f110000" android:dataExtractionRules="@ref/0x7f110001"/></manifest>'
symbols = " resource 0x7f110000 xml/backup_rules\n resource 0x7f110001 xml/data_extraction_rules\n"
backup_references(manifest, symbols)
for wrong in (manifest.replace("0x7f110000", "0x7f110002"), manifest.replace("@ref/0x7f110001", "false")):
with self.assertRaisesRegex(RuntimeError, "unreviewed backup resource"):
backup_references(wrong, symbols)
for wrong in (symbols.replace("backup_rules", "other_rules"), symbols + symbols, symbols.replace("0x7f110001", "0x7f110000")):
with self.assertRaises(RuntimeError):
backup_references(manifest, wrong)
with self.assertRaisesRegex(RuntimeError, "symbols overlap"):
backup_references(manifest.replace("0x7f110001", "0x7f110000"), symbols.replace("0x7f110001", "0x7f110000"))
def test_aapt2_adapter_preserves_paths_and_other_options(self):
adapter = runpy.run_path(ROOT / ".gitea/scripts/native-arm64/aapt2")
self.assertEqual(adapter["arguments"](["optimize", "input.apk", "--resource-path-shortening-map=/tmp/map with spaces=ok", "--shorten-resource-paths", "-o", "output.apk"]),
["optimize", "input.apk", "--resource-path-shortening-map", "/tmp/map with spaces=ok", "--shorten-resource-paths", "-o", "output.apk"])
self.assertEqual(adapter["arguments"](["daemon"]), ["daemon"])
def test_toolchain_checksum_rejects_changed_archive(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "fixture"
path.write_bytes(b"public archive fixture")
spec = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
download(spec, path)
path.write_bytes(b"changed")
with self.assertRaisesRegex(RuntimeError, "checksum mismatch"):
download(spec, path)
def test_symbol_selection_and_repeatable_checksum_manifest(self):
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
source = directory / "source.zip"
output = directory / "output.zip"
names = [f"{abi}/{lib}.dbg" for abi in ABIS for lib in ("libironstorage_android.so", "libjnidispatch.so")]
with zipfile.ZipFile(source, "w") as archive:
for name in names + ["unused/libjnidispatch.so.dbg"]:
archive.writestr(name, b"public symbols")
select_symbols(source, output)
with zipfile.ZipFile(output) as archive:
self.assertEqual(set(archive.namelist()), set(names))
checksum_artifacts(directory)
first = (directory / "sha256.json").read_bytes()
checksum_artifacts(directory)
self.assertEqual(first, (directory / "sha256.json").read_bytes())
self.assertNotIn("sha256.json", json.loads(first))
with zipfile.ZipFile(source, "w") as archive:
archive.writestr(names[0], b"public symbols")
with self.assertRaisesRegex(RuntimeError, "missing"):
select_symbols(source, output)
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("arm64-v8a/unreviewed.dbg", b"public symbols")
with self.assertRaisesRegex(RuntimeError, "Unreviewed"):
select_symbols(source, output)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["prepare-linux", "build", "distribution", "emulator", "performance", "profiles", "test", "clean-ci"])
parser.add_argument("serial", nargs="?")
args = parser.parse_args()
if args.command not in ("test", "clean-ci") and not (os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")):
parser.error("ANDROID_HOME or ANDROID_SDK_ROOT is required")
if args.command == "test":
unittest.main(argv=[__file__])
elif args.command == "clean-ci":
clean_ci()
elif args.command == "prepare-linux":
prepare_linux()
elif args.command == "build":
build()
elif args.command == "distribution":
distribution()
elif args.serial:
if args.command in ("performance", "profiles"):
performance(args.serial, profiles=args.command == "profiles")
else:
emulator(args.serial)
else:
parser.error("emulator, performance and profiles require an ADB serial")
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Build-time license text; never reads app data or a password-store repository."""
import json
from pathlib import Path
import platform
import re
import subprocess
import sys
import tarfile
import zipfile
from android_ci import ROOT, CARGO, download
def rust_notices(data, expected):
covered = set()
sections = []
for license in data["licenses"]:
if not license["text"].strip():
raise RuntimeError("Empty Rust license text")
users = []
for used in license["used_by"]:
crate = used["crate"]
key = (crate["name"], crate["version"])
covered.add(key)
users.append(f"{key[0]} {key[1]} — {', '.join(crate['authors'])}\n"
f"https://crates.io/crates/{key[0]}/{key[1]} (source archive)\n"
f"{crate.get('repository') or ''}")
sections.append(license["name"] + "\n" + "\n".join(sorted(users)) + "\n\n" + license["text"])
if expected - covered:
raise RuntimeError("Rust normal dependency is missing a license notice: " + repr(sorted(expected - covered)))
return "\n\n".join(sorted(set(sections)))
def android_notices(rows, policy):
common = (ROOT / "android/licenses/Apache-2.0.txt").read_text()
extras = {
"androidx.camera:camera-core:1.6.2": ("Apache-2.0 AND BSD-3-Clause", ["libyuv-LICENSE.txt", "libyuv-PATENTS.txt"]),
"net.java.dev.jna:jna:5.19.1": ("Apache-2.0 OR LGPL-2.1-or-later", ["jna-LICENSE.txt", "libffi.txt"]),
"org.checkerframework:checker-qual:3.43.0": ("MIT", ["checker-qual.txt"]),
"rustls:rustls-platform-verifier:0.1.1": ("MIT OR Apache-2.0", []),
}
sections = ["Apache License 2.0 (selected where a dual license permits it)\n\n" + common]
for row in sorted(rows, key=lambda r: r["component"]):
component = row["component"]
license = policy.get(component)
if license is None or (license != "Apache-2.0" and extras.get(component, (None,))[0] != license):
raise RuntimeError("Android notice needs review: " + component)
notices = []
with zipfile.ZipFile(row["path"]) as archive:
for name in sorted(archive.namelist()):
if re.search(r"(?i)(?:^|/)(?:licen[cs]e|notice|copying|copyright|al2\.0)(?:[._-].*)?$", name):
entry = archive.getinfo(name)
if entry.is_dir() or entry.file_size > 1024 * 1024:
raise RuntimeError("Unexpected Android notice archive entry")
notices.append(archive.read(name).decode("utf-8"))
for name in extras.get(component, (None, []))[1]:
notices.append((ROOT / "android/licenses" / name).read_text())
sections.append(component + "\nLicense: " + license + "\n\n" + "\n\n".join(sorted(set(notices))))
return "\n\n".join(dict.fromkeys(sections))
def generate(inventory, output):
if platform.machine() not in ("arm64", "aarch64"):
raise RuntimeError("License tools require the supported native ARM build host")
scratch = ROOT / "android/build/license-tools"
scratch.mkdir(parents=True, exist_ok=True)
archive_path = scratch / "cargo-about.tar.gz"
spec = json.loads((ROOT / "android/license-tool.json").read_text())[platform.system()]
download(spec, archive_path)
with tarfile.open(archive_path) as archive:
members = [m for m in archive.getmembers() if Path(m.name).name == "cargo-about" and m.isfile()]
if len(members) != 1 or members[0].size > 100 * 1024 * 1024:
raise RuntimeError("Unexpected cargo-about package")
binary = scratch / "cargo-about"
binary.write_bytes(archive.extractfile(members[0]).read())
binary.chmod(0o755)
data = json.loads(subprocess.check_output([str(binary), "generate", "--locked", "--fail", "--manifest-path",
str(ROOT / "crates/android/Cargo.toml"), "--config",
str(ROOT / "android/about.toml"), "--format", "json"]))
expected = set()
workspace = json.loads(subprocess.check_output([CARGO, "metadata", "--locked", "--no-deps", "--format-version", "1"]))
local_names = {package["name"] for package in workspace["packages"] if package["source"] is None}
for triple in ("aarch64-linux-android", "armv7-linux-androideabi"):
tree = subprocess.check_output([CARGO, "tree", "--locked", "-p", "ironstorage-android", "--target", triple,
"--edges", "normal", "--prefix", "none", "--format", "{p}"], text=True)
for line in tree.splitlines():
match = re.match(r"([^ ]+) v([^ ]+)", line)
if match and match[1] not in local_names:
expected.add((match[1], match[2]))
rows = json.loads(inventory.read_text())
if not rows:
raise RuntimeError("Android release notice inventory is empty")
text = "IronStorage — Third-party notices\n\nAndroid release libraries\n\n"
text += android_notices(rows, json.loads((ROOT / "android/dependency-policy.json").read_text())["android"])
text += "\n\nRust ARM libraries\n\n" + rust_notices(data, expected)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(text, encoding="utf-8")
print(f"Packaged full notices for {len({r['component'] for r in rows})} Android and {len(expected)} Rust components")
if __name__ == "__main__":
generate(Path(sys.argv[1]), Path(sys.argv[2]))
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Adapt AGP's map argument to the pinned native ARM64 AAPT2 syntax."""
import os
from pathlib import Path
import sys
def arguments(args):
result = []
for arg in args:
if arg.startswith("--resource-path-shortening-map="):
option, value = arg.split("=", 1)
result.extend((option, value))
else:
result.append(arg)
return result
if __name__ == "__main__":
binary = Path(os.environ["ANDROID_HOME"]) / "native-arm64/build-tools/aapt2"
os.execv(binary, [str(binary), *arguments(sys.argv[1:])])
+109
View File
@@ -0,0 +1,109 @@
name: Android ARM verification
on:
push:
branches:
- main
- 'codex/**'
pull_request:
workflow_dispatch:
permissions:
code: read
actions: write
env:
CARGO_TERM_COLOR: always
CARGO_BUILD_JOBS: '1'
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=lld
jobs:
rust:
runs-on: linux-arm64
timeout-minutes: 90
env:
CI: 'true'
CARGO_INCREMENTAL: '0'
CARGO_PROFILE_TEST_DEBUG: line-tables-only
steps:
- uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install workspace system libraries
run: |
df -h .
df -i .
available=$(df --output=avail -B1 . | tail -n 1)
if [ "$available" -lt 17179869184 ]; then
echo 'Rust CI needs at least 16 GiB free in its checkout filesystem.' >&2
exit 1
fi
apt-get update
apt-get install -y --no-install-recommends python3 lld pkg-config libfontconfig1-dev libasound2-dev libudev-dev libxkbcommon-dev libwayland-dev
- name: Install pinned Rust
uses: https://github.com/taiki-e/install-action@9534c84618278caac52cb373bb164ed464dbd8af
with:
tool: rust@1.97.1
fallback: none
- name: Install formatting and Clippy components
run: rustup component add --toolchain 1.97.1 rustfmt clippy
- name: Formatting
run: cargo +1.97.1 fmt --all -- --check
- name: Warnings as errors
run: RUSTFLAGS="-D warnings" cargo +1.97.1 check --locked --workspace --all-targets
- name: Clippy
run: cargo +1.97.1 clippy --locked --workspace --all-targets -- -D warnings
- name: Workspace tests including deterministic clocks and secret backends
run: |
cargo +1.97.1 test --locked --workspace
du -sh target
- name: Reclaim this checkout's generated build directories
if: always()
run: python3 .gitea/scripts/android_ci.py clean-ci
android:
runs-on: linux-arm64
timeout-minutes: 150
env:
ANDROID_HOME: /opt/ironstorage-android-sdk
JAVA_HOME: /usr/lib/jvm/java-25-openjdk-arm64
CI: 'true'
CARGO_INCREMENTAL: '0'
CARGO_PROFILE_DEV_DEBUG: '0'
GRADLE_OPTS: -Dorg.gradle.workers.max=1 -Dorg.gradle.jvmargs=-Xmx1g
steps:
- uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install native ARM64 Java and build prerequisites
run: |
df -h .
df -i .
available=$(df --output=avail -B1 . | tail -n 1)
if [ "$available" -lt 21474836480 ]; then
echo 'Android CI needs at least 20 GiB free in its checkout filesystem.' >&2
exit 1
fi
apt-get update
apt-get install -y --no-install-recommends openjdk-25-jdk python3 ca-certificates curl unzip xz-utils gcc lld pkg-config libfontconfig1-dev
- name: Install pinned Rust
uses: https://github.com/taiki-e/install-action@9534c84618278caac52cb373bb164ed464dbd8af
with:
tool: rust@1.97.1
fallback: none
- name: Install selected Android targets and cargo-ndk
run: |
rustup target add --toolchain 1.97.1 aarch64-linux-android armv7-linux-androideabi
cargo +1.97.1 install cargo-ndk --version 4.1.2 --locked
- name: Check build orchestration
run: python3 .gitea/scripts/android_ci.py test
- name: Install checksummed native Linux ARM64 SDK and NDK tools
run: python3 .gitea/scripts/android_ci.py prepare-linux
- name: Compile, test, lint and audit unsigned ARM packages
run: python3 .gitea/scripts/android_ci.py build
- name: Upload reviewed packages, matching symbols, safe test summaries and SBOM
uses: https://github.com/actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-arm-verification
path: android/build/ci-artifacts/
if-no-files-found: error
retention-days: 14
- name: Reclaim this checkout's generated build directories after upload
if: always()
run: python3 .gitea/scripts/android_ci.py clean-ci
+9
View File
@@ -3,3 +3,12 @@
/distribution/ /distribution/
.env .env
.DS_Store .DS_Store
/android/.gradle/
/android/.kotlin/
/android/**/build/
/android/local.properties
/android/.idea/
*.jks
*.keystore
/.gitea/scripts/__pycache__/
Generated
+68 -22
View File
@@ -1439,9 +1439,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.1" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher 0.5.2", "cipher 0.5.2",
@@ -2271,6 +2271,15 @@ dependencies = [
"generic-array 0.14.7", "generic-array 0.14.7",
] ]
[[package]]
name = "defmt"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
dependencies = [
"defmt 1.1.1",
]
[[package]] [[package]]
name = "defmt" name = "defmt"
version = "1.1.1" version = "1.1.1"
@@ -2883,10 +2892,12 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
[[package]] [[package]]
name = "faster-hex" name = "faster-hex"
version = "0.10.0" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed"
dependencies = [ dependencies = [
"autocfg",
"defmt 0.3.100",
"heapless", "heapless",
"serde", "serde",
] ]
@@ -3844,7 +3855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
dependencies = [ dependencies = [
"bstr", "bstr",
"hashbrown 0.17.1", "hashbrown 0.15.5",
] ]
[[package]] [[package]]
@@ -5390,6 +5401,7 @@ checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
name = "ironstorage" name = "ironstorage"
version = "1.0.1" version = "1.0.1"
dependencies = [ dependencies = [
"aes-gcm 0.11.1",
"apple-native-keyring-store", "apple-native-keyring-store",
"arboard", "arboard",
"cap-std", "cap-std",
@@ -5415,6 +5427,7 @@ dependencies = [
"reqwest 0.13.4", "reqwest 0.13.4",
"rqrr", "rqrr",
"russh", "russh",
"rustix 1.1.4",
"rustls", "rustls",
"secret-service", "secret-service",
"security-framework 3.7.0", "security-framework 3.7.0",
@@ -5433,12 +5446,38 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "ironstorage-android"
version = "1.0.1"
dependencies = [
"ironstorage-mobile",
"jni",
"rustls-platform-verifier",
]
[[package]]
name = "ironstorage-android-build"
version = "1.0.1"
dependencies = [
"camino",
"ironstorage",
"object",
"regex",
"roxmltree",
"serde",
"serde_json",
"sha2 0.10.9",
"toml 0.9.12+spec-1.1.0",
"uniffi",
"uniffi_bindgen",
"zip",
]
[[package]] [[package]]
name = "ironstorage-apple" name = "ironstorage-apple"
version = "1.0.1" version = "1.0.1"
dependencies = [ dependencies = [
"ironstorage", "ironstorage-mobile",
"uniffi",
] ]
[[package]] [[package]]
@@ -5488,6 +5527,14 @@ dependencies = [
"simple-file-manifest", "simple-file-manifest",
] ]
[[package]]
name = "ironstorage-mobile"
version = "1.0.1"
dependencies = [
"ironstorage",
"uniffi",
]
[[package]] [[package]]
name = "ironstorage-tui" name = "ironstorage-tui"
version = "1.0.1" version = "1.0.1"
@@ -5552,7 +5599,7 @@ version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [ dependencies = [
"defmt", "defmt 1.1.1",
"jiff-core", "jiff-core",
"jiff-static", "jiff-static",
"jiff-tzdb-platform", "jiff-tzdb-platform",
@@ -5569,7 +5616,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [ dependencies = [
"defmt", "defmt 1.1.1",
] ]
[[package]] [[package]]
@@ -5969,15 +6016,12 @@ name = "lru"
version = "0.16.4" version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
dependencies = [
"hashbrown 0.16.1",
]
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.18.1" version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
dependencies = [ dependencies = [
"hashbrown 0.17.1", "hashbrown 0.17.1",
] ]
@@ -8111,7 +8155,7 @@ dependencies = [
"hashbrown 0.17.1", "hashbrown 0.17.1",
"itertools 0.14.0", "itertools 0.14.0",
"kasuari", "kasuari",
"lru 0.18.1", "lru 0.18.2",
"palette", "palette",
"serde", "serde",
"strum", "strum",
@@ -8487,12 +8531,12 @@ dependencies = [
[[package]] [[package]]
name = "rqrr" name = "rqrr"
version = "0.10.1" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffbe87d9e8db95652c25ded2418150e00b08c2fde09e23ec15896d2c470c6631" checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b"
dependencies = [ dependencies = [
"g2p", "g2p",
"lru 0.16.4", "lru 0.18.2",
] ]
[[package]] [[package]]
@@ -8719,9 +8763,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.43" version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"once_cell", "once_cell",
@@ -8805,9 +8849,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.13" version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"ring", "ring",
@@ -10542,7 +10586,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"camino",
"cargo_metadata", "cargo_metadata",
"clap",
"uniffi_bindgen", "uniffi_bindgen",
"uniffi_core", "uniffi_core",
"uniffi_macros", "uniffi_macros",
+6 -2
View File
@@ -4,8 +4,11 @@ members = [
"apps/desktop", "apps/desktop",
"apps/tui", "apps/tui",
"crates/apple", "crates/apple",
"crates/android",
"crates/mobile-bridge",
"crates/storage", "crates/storage",
"crates/watch-apple", "crates/watch-apple",
"tools/android-build",
"tools/apple-release", "tools/apple-release",
"tools/macos-packager", "tools/macos-packager",
] ]
@@ -27,6 +30,7 @@ arboard = { version = "3.6", default-features = false, features = ["wayland-data
ashpd = { version = "0.13", default-features = false, features = ["file_chooser", "tokio"] } ashpd = { version = "0.13", default-features = false, features = ["file_chooser", "tokio"] }
cap-std = "4.0" cap-std = "4.0"
cap-tempfile = "4.0" cap-tempfile = "4.0"
rustix = { version = "1.1", features = ["fs"] }
clap = { version = "4.6", features = ["derive"] } clap = { version = "4.6", features = ["derive"] }
clap_complete = "4.6" clap_complete = "4.6"
crossterm = "0.29" crossterm = "0.29"
@@ -50,9 +54,9 @@ qrcode = { version = "0.14", default-features = false }
rand = "0.8" rand = "0.8"
regex = "1.13" regex = "1.13"
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
rustls = { version = "0.23", default-features = false } rustls = { version = "0.23.46", default-features = false }
rfd = { version = "0.17", default-features = false } rfd = { version = "0.17", default-features = false }
rqrr = { version = "0.10", default-features = false } rqrr = { version = "0.11", default-features = false }
rpassword = "7.5" rpassword = "7.5"
russh = { version = "=0.63.1", default-features = false, features = ["ring", "rsa"] } russh = { version = "=0.63.1", default-features = false, features = ["ring", "rsa"] }
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
+29 -1
View File
@@ -31,7 +31,7 @@ The current direct dependencies are:
| [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 | | [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 |
| [tempfile 3](https://crates.io/crates/tempfile) | Permission-restricted CLI editor session directories and cleanup | MIT OR Apache-2.0 | | [tempfile 3](https://crates.io/crates/tempfile) | Permission-restricted CLI editor session directories and cleanup | MIT OR Apache-2.0 |
| [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 | | [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 |
| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 | | [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift and Kotlin bridge | MPL-2.0 |
| [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 | | [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 |
The activated transitive graph has no dependency that forces a GPL or LGPL The activated transitive graph has no dependency that forces a GPL or LGPL
@@ -100,3 +100,31 @@ The checked-in compatibility suite completes the earlier OpenPGP backend gate:
`pgp` imports protected armored and binary exports, decrypts every GnuPG-audited `pgp` imports protected armored and binary exports, decrypts every GnuPG-audited
fixture, emits independently decryptable single- and multi-recipient messages, fixture, emits independently decryptable single- and multi-recipient messages,
and verifies the detached recipient signatures. and verifies the detached recipient signatures.
## Android dependency policy
The approved Android library/API purposes, license review, maintenance checks,
version pinning and update gates are recorded in
[`docs/android/architecture.md`](docs/android/architecture.md#approved-dependencies-and-review-policy).
The Gradle catalog and resolved dependency/license records must identify actual
shipped versions before release; this approval matrix does not replace that audit.
The #89 scaffold records exact resolved build/test artifacts in
`android/gradle/verification-metadata.xml` and module Gradle lockfiles. The
version catalog pins stable AndroidX, Kotlin, Hilt/KSP and formatting tools.
Espresso 3.7.0 is pinned explicitly because Compose's older transitive Espresso
cannot initialize input injection on API 37. AndroidX Test is Apache-2.0;
JUnit 4.13.2 is EPL-1.0 and test-only. ktlint and its Gradle plugin are MIT,
build-only tools.
#92 packages [JNA 5.19.1](https://repo.maven.apache.org/maven2/net/java/dev/jna/jna/5.19.1/jna-5.19.1.pom)
as the Android AAR under its Apache-2.0 option (dual-licensed with LGPL-2.1-or-later).
It is the OS FFI runtime required by generated UniFFI Kotlin, not a competing
storage implementation. Its Android native libraries are subject to the same
three-ABI, ELF and 16 KB APK alignment checks as Rust. Exact resolved AAR/POM
checksums and locks are committed. cargo-ndk 4.1.2 (MIT OR Apache-2.0) is build-only;
NDK 30.0.16248370 and Rust 1.97.1 are pinned and checked. The build-only ELF/ZIP
auditor reuses locked object 0.36.7 (Apache-2.0 OR MIT) and zip 2.4.2 (MIT)
versions already present in the workspace. UniFFI's MPL-2.0 source remains available
at its recorded upstream crate version. Release attribution assembly is owned by
#112; the dependency policy and runtime matrix are in android/README.md.
+21
View File
@@ -75,6 +75,22 @@ Rollback-safe remove, move, and copy transactions, destination rules, and
selective subtree reencryption are documented in selective subtree reencryption are documented in
[`docs/tree-mutations.md`](docs/tree-mutations.md). [`docs/tree-mutations.md`](docs/tree-mutations.md).
The native Android architecture, support policy and complete mobile parity/issue
map are recorded in [`docs/android/architecture.md`](docs/android/architecture.md)
and [`docs/android/parity.md`](docs/android/parity.md). Android implementation
belongs to milestone 06; the decision record does not claim a shipped client.
The structured Android viewer and its validation are documented in
[`docs/android/entry-viewer.md`](docs/android/entry-viewer.md).
Lifecycle, capture, input, and clipboard protections and platform limits are
documented in [`docs/android/presentation-security.md`](docs/android/presentation-security.md).
Name-only Android search and its state/security policy are documented in
[`docs/android/name-search.md`](docs/android/name-search.md).
Adaptive navigation, appearance, safe restoration and shell validation are documented in
[`docs/android/adaptive-shell.md`](docs/android/adaptive-shell.md).
Native entry creation, editing and encrypted draft recovery are documented in
[`docs/android/entry-editor.md`](docs/android/entry-editor.md).
## Project layout ## Project layout
```text ```text
@@ -84,6 +100,8 @@ apps/cli pass-compatible command-line frontend
apps/tui Ratatui frontend apps/tui Ratatui frontend
apps/desktop Iced desktop frontend for macOS, Windows, and Linux apps/desktop Iced desktop frontend for macOS, Windows, and Linux
apple/ iPhone, AutoFill, and watchOS presentation targets apple/ iPhone, AutoFill, and watchOS presentation targets
android/ native Android Compose project
tools/android-build Rust-owned Android fixtures and architecture checks
``` ```
The Apple project is generated with XcodeGen: The Apple project is generated with XcodeGen:
@@ -93,6 +111,9 @@ cd apple
xcodegen generate xcodegen generate
``` ```
Android toolchain setup, module boundaries, build gates and simulator updates are
documented in [`android/README.md`](android/README.md).
Apple App Store metadata, archive verification, submission, and paired iPhone Apple App Store metadata, archive verification, submission, and paired iPhone
and Watch release checks are documented in and Watch release checks are documented in
[`apple/AppStore/DISTRIBUTION.md`](apple/AppStore/DISTRIBUTION.md). [`apple/AppStore/DISTRIBUTION.md`](apple/AppStore/DISTRIBUTION.md).
+9
View File
@@ -0,0 +1,9 @@
root = true
[*.{kt,kts}]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_size = 4
ktlint_code_style = android_studio
ktlint_function_naming_ignore_when_annotated_with = Composable
+183
View File
@@ -0,0 +1,183 @@
# Native Android build
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
[parity matrix](../docs/android/parity.md). The native foundation, onboarding, navigation, Home and authentication implement #89–#98, including private storage, Keystore
callbacks and build gates. Both variants invoke Rust through the generated
UniFFI API. Local-first and HTTPS onboarding is described in
[onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned,
revision-checked [adaptive navigation](../docs/android/password-navigation.md);
Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android/home-sync.md).
Passwords and Preferences share [Rust-owned biometric authentication](../docs/android/authentication.md).
CameraX GPG scanning and QR export are described in
[key transfer](../docs/android/key-transfer.md).
The TOTP destination uses [cached discovery and search](../docs/android/totp-discovery.md)
while locked, with authenticated incremental refresh for new or changed entries.
Remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
ARM-only runner/toolchain policy and artifact audits.
## Reproducible setup
Use Rust 1.97.1 (pinned in rust-toolchain.toml), cargo-ndk 4.1.2, a JDK 25 installation
(Android Studio's JetBrains Runtime 25 is suitable), Android SDK Platform 37,
build-tools 36.0.0 and NDK 30.0.16248370. The app emits JVM 17-compatible code
and supports minSdk 26. Set JAVA_HOME and ANDROID_HOME/ANDROID_SDK_ROOT or create
an ignored local.properties containing sdk.dir. Cargo must be in the IDE/build
process PATH, or set CARGO to its absolute executable path. No machine paths are
committed.
The committed Gradle 9.8.1 wrapper verifies the distribution SHA-256. Its JAR
SHA-256 is 3b8a25775a69158b5ad2b1d17a80a88dc7b40a352a73eb27d06c612a7ce68e98,
verified against the official Gradle version metadata. AGP 9.4.1 includes Kotlin
support; the Kotlin JVM/Compose plugin pin upgrades its classpath consistently
to 2.4.21. Do not additionally apply kotlin-android with built-in Kotlin enabled.
The version catalog is the single source for Android dependency/SDK/NDK pins.
From this directory:
```sh
./gradlew auditNativePackages :core:testDebugUnitTest :app:testDebugUnitTest :app:lintDebug :app:lintRelease ktlintCheck
./gradlew :app:connectedDebugAndroidTest :rust-bridge:connectedDebugAndroidTest
```
Repeat the first command to verify configuration-cache reuse. Dependency
verification is strict by default; committed SHA-256 metadata covers resolved
build/test artifacts. Locks pin resolvable module configurations. To update an
approved version, edit the catalog, verify the upstream artifact/release/license,
then explicitly regenerate checksums/locks, inspect the diff and run the gates:
```sh
./gradlew :app:assembleDebug :app:assembleRelease :app:testDebugUnitTest :app:lintDebug ktlintCheck --write-verification-metadata sha256 --write-locks
```
## Boundaries and fixtures
The app composition root injects an Android-facing ShellRepository. Compose
features consume that contract through a Hilt ViewModel and collect immutable
StateFlow state with lifecycle awareness. Only core depends on rust-bridge;
platform implements core OS contracts. Gradle rejects forbidden module edges
and competing domain dependencies. The Rust android-build tool checks Kotlin
source/import boundaries and subprocess paths before every Android build.
These static checks accompany code review; they do not prove arbitrary Kotlin
code cannot conceal a domain implementation.
Debug controls invoke the storage-owned MobileShell::fixture through the native
API for Loading/Empty/Ready/Locked/Error. A build-generated fixture inventory is
used only as the test oracle. Release loads the real Rust private-storage state without debug controls.
An unconfigured runtime opens staged onboarding; Preferences can add HTTPS
synchronization or replace its protected token. Feature code consumes
only the facade, including typed native recovery states.
## Identity, signing and update
All app build types keep de.rfc1437.ironstorage; test IDs are derived by AGP.
`release.properties` supplies Android version code 2 and name 1.0.1. Debug signing
uses the standard local debug key. Ordinary release verification produces an
unsigned APK/AAB. The separate [distribution command](../docs/android/distribution.md)
signs the audited AAB with an external, certificate-pinned upload key after the
build; signing passwords are excluded from Gradle and Cargo child processes. Do not add
signing secrets or store passwords to the repository. Native symbol level FULL
and the selected two ARM ABIs are packaged. Native runtime packaging is described
below; no development network endpoint is included.
The manifest exports the launcher, the system-permission-protected Autofill and
Credential Manager services, and the reviewed AndroidX profile receiver
protected by the privileged DUMP permission. The credential picker is private.
Backup/cloud/device-transfer
exclusions and cleartext denial are explicit. Feature-specific services/permissions
are audited by the artifact gates. Resources use values directories for future
localization; vector/adaptive/monochrome icons and R8 consumer rules are present.
The launcher uses an unchanged copy of the iPhone vault artwork from
apple/Assets.xcassets/AppIcon.appiconset/AppIcon.png, inset for Android's adaptive
mask. Its themed monochrome layer keeps the round vault, wheel and hinge motif.
Keep the two platforms' launcher branding aligned when updating that artwork.
Install updates over the existing simulator app without uninstall or data reset:
```sh
adb -s DEVICE install -r app/build/outputs/apk/debug/app-debug.apk
```
Keep the same AVD and application ID. Never wipe its data/keychain or seed a test
app-data package as an update. After closing an issue, clean its project build
outputs and Cargo target; preserve the emulator and shared SDK/toolchain caches.
AGP's leaveApksInstalledAfterRun option is enabled so instrumented checks do not
uninstall the app after running. Keep it enabled for this persistent test device.
## Rust packaging and integrity
Install the pinned build tools once:
```sh
rustup toolchain install 1.97.1 --profile minimal
rustup target add --toolchain 1.97.1 aarch64-linux-android armv7-linux-androideabi
cargo install cargo-ndk --version 4.1.2 --locked
```
Gradle checks the actual Cargo/cargo-ndk/NDK revisions before building. Each
variant compiles crates/android with cargo-ndk into its own generated jniLibs,
then verifies ELF architecture, 16 KB LOAD alignment, and byte-identical UniFFI
metadata across both ARM ABIs. A packaging step selects only the Android library
and rejects accidental inclusion of the dependency Apple cdylib. The thin Android crate re-exports the existing
UniFFI component; it duplicates no storage rules or converters. #91 owns the
platform-neutral contract extraction. No handwritten JNI or unsafe Rust is added.
The workspace's UniFFI 0.32.0 CLI generates Android-mode immutable Kotlin
records from the arm64 library, using the global defaults in uniffi.toml.
A version-checked preparation step redacts record toString, typed/internal
exception diagnostics and unexpected callback stack traces, mechanically renames
Rust error message fields to rustMessage in Kotlin,
and translates every integrity guard to a typed failure. Checksums remain enabled
and retain every native function and expected value. UniFFI checksums are u16:
JNA's generated Int return is normalized to its lower 16 bits before comparison;
AArch64 may leave unrelated upper bits in the return register. Contract versions
are compared at their full width. A failed initializer remains failed until an
app restart/update, rather than reporting a different error on each retry.
NativeRuntime performs OS library/ABI preflight, loads the library and initializes
UniFFI before entering Rust. Missing/unsupported libraries, broken APKs, contract
or checksum mismatch, and load failures yield typed, non-secret recovery states.
It rejects main-thread calls. The core facade schedules blocking native calls on
Dispatchers.IO; cancellation propagates and a superseded result cannot overwrite
newer state. Blocking fixture calls finish in Rust when a coroutine is cancelled;
no thread is forcibly stopped. Long storage operations must use their Rust-owned
cancellation methods when their feature is integrated.
#93 installs Rust-to-Kotlin Keystore callbacks through generated UniFFI/JNA
adapters: JNA attaches a calling native
thread to the JVM; retain the callback for the full Rust owner lifetime, release
it after unregistering, never assume the callback runs on the Android main thread,
and marshal UI/Activity work onto Main. Keep storage decisions in Rust and avoid
blocking the main thread or calling back into a held Rust lock.
JNA 5.19.1 is packaged as its Android AAR, filtered to arm64-v8a, armeabi-v7a only. The release set is arm64-v8a and armeabi-v7a under the user amendment to
#88, not by silently omitting an ABI. All source/lock/config files are task inputs;
outputs live only under build/. Debug and release compile independently, and
unchanged builds reuse their outputs. R8 keeps generated/JNA reflection entry
points. AGP strips release libraries and exports FULL native symbols to
app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip; retain
that file and outputs/mapping/release/mapping.txt with the matching release.
AAB base/lib entries let Play produce per-ABI splits.
The auditNativePackages gate inspects every native dependency in both APKs and
the AAB, checks both ARM ABI directories and both Rust/JNA libraries, and rejects
ELF segments below 16 KB alignment or APK native entries that are compressed or
not ZIP-aligned to 16 KB. Follow the official [16 KB runtime validation](https://developer.android.com/guide/practices/page-sizes):
use an actual 16 KB system image, check adb shell getconf PAGE_SIZE is 16384,
disable page-size compatibility mode, and run both instrumented test suites.
An ELF/ZIP audit is not a substitute for that runtime test. Milestone validation
is emulator-only per the user instruction of 2026-10-08; physical devices are
outside the test scope for every issue. The user removed x86_64 Android from the current support scope on 2026-10-08.
Historical validation recorded on 2026-10-08 for #92 (before the ARM-only scope update):
| Environment | Verified |
| --- | --- |
| API 37 arm64 emulator, 4 KB | Generated API fixture/error/redaction tests; 25 UI states; release APK and AAB split launch; update-state preservation |
| API 37 arm64 emulator, 16 KB, compatibility disabled | Same native/UI tests, release APK/AAB launch and update-state preservation |
| Fresh source export | Three ABI debug builds with pinned tools, locked dependencies and strict artifact verification |
| All three ABI release outputs | ELF alignment and identical UniFFI metadata; stripped APK libraries and separate FULL Rust symbols; nine bundletool ABI split APKs audited |
| Physical devices | Excluded from milestone testing by user instruction |
| x86_64 runtime | System image installed; QEMU2 rejects x86_64 on this aarch64 Mac, including with acceleration disabled |
The historical x86_64 runtime gap is no longer a required check under the current
ARM-only support decision.
+5
View File
@@ -0,0 +1,5 @@
accepted = ["Apache-2.0", "MIT", "ISC", "BSD-3-Clause", "BSD-2-Clause", "MPL-2.0", "Unicode-3.0", "Zlib", "CC0-1.0", "Unlicense", "MIT-0", "CDLA-Permissive-2.0"]
targets = ["aarch64-linux-android", "armv7-linux-androideabi"]
ignore-build-dependencies = true
ignore-dev-dependencies = true
workarounds = ["ring", "rustls"]
+140
View File
@@ -0,0 +1,140 @@
import groovy.json.JsonOutput
import java.util.Properties
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
val releaseVersion = Properties().apply {
rootProject.file("release.properties").inputStream().use(::load)
}
val generateThirdPartyNotices = tasks.register<Exec>("generateThirdPartyNotices") {
val inventory = layout.buildDirectory.file("reports/license-artifacts.json")
val output = layout.buildDirectory.file("generated/notices/assets/third-party-notices.txt")
outputs.file(output)
outputs.upToDateWhen { false }
workingDir = rootProject.projectDir.parentFile
doFirst {
val rows = configurations.getByName("releaseRuntimeClasspath").incoming.artifactView {
componentFilter { it is ModuleComponentIdentifier }
}.artifacts.artifacts.map { artifact ->
val id = artifact.id.componentIdentifier as ModuleComponentIdentifier
mapOf(
"component" to "${id.group}:${id.module}:${id.version}",
"path" to artifact.file.absolutePath
)
}
inventory.get().asFile.apply {
parentFile.mkdirs()
writeText(JsonOutput.toJson(rows))
}
commandLine(
"python3",
".gitea/scripts/android_notices.py",
inventory.get().asFile,
output.get().asFile
)
}
notCompatibleWithConfigurationCache("Resolves release license files under the project lock")
}
android {
namespace = "de.rfc1437.ironstorage"
compileSdk = libs.versions.compileSdk.get().toInt()
ndkVersion = libs.versions.ndk.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
applicationId = "de.rfc1437.ironstorage"
targetSdk = libs.versions.compileSdk.get().toInt()
versionCode = releaseVersion.getProperty("versionCode").toInt()
versionName = releaseVersion.getProperty("versionName")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
sourceSets.getByName(
"main"
).assets.srcDir(layout.buildDirectory.dir("generated/notices/assets").get().asFile)
buildTypes {
debug { isDebuggable = true }
release {
isMinifyEnabled = true
isShrinkResources = true
ndk { debugSymbolLevel = "FULL" }
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildTypes.create("benchmark") {
initWith(buildTypes.getByName("release"))
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += "release"
}
sourceSets.getByName("benchmark").kotlin.srcDir("src/release/kotlin")
buildTypes.create("profile") {
initWith(buildTypes.getByName("benchmark"))
isMinifyEnabled = false
isShrinkResources = false
}
sourceSets.getByName("profile").apply {
kotlin.srcDir("src/release/kotlin")
manifest.srcFile("src/benchmark/AndroidManifest.xml")
}
packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" }
lint {
abortOnError = true
warningsAsErrors = true
// The user scoped this milestone to ARM Android; x86 ChromeOS is excluded.
disable += "ChromeOsAbiSupport"
}
}
tasks.named("preBuild") { dependsOn(generateThirdPartyNotices) }
kotlin {
jvmToolchain(25)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
implementation(libs.credentials)
implementation(libs.autofill)
implementation(project(":features"))
implementation(project(":core"))
implementation(project(":platform"))
implementation(libs.fragment)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.compose.adaptive)
implementation(libs.lifecycle.compose)
implementation(libs.lifecycle.viewmodel)
implementation(libs.navigation.runtime)
implementation(libs.navigation.ui)
implementation(libs.activity.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.test)
androidTestImplementation(libs.compose.ui.test.accessibility)
androidTestImplementation(libs.android.test.runner)
androidTestImplementation(libs.android.test.espresso)
implementation(libs.coroutines.android)
testImplementation(libs.coroutines.test)
testImplementation(libs.junit)
}
+353
View File
@@ -0,0 +1,353 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
androidx.activity:activity-compose:1.13.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.activity:activity-ktx:1.13.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.activity:activity:1.13.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-experimental:1.5.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-jvm:1.11.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-jvm:1.9.1=benchmarkCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,profileCompileClasspath,releaseCompileClasspath
androidx.annotation:annotation:1.11.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation:1.9.1=benchmarkCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,profileCompileClasspath,releaseCompileClasspath
androidx.appcompat:appcompat-resources:1.2.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.appcompat:appcompat:1.2.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-common:2.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-runtime:2.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.autofill:autofill:1.3.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.biometric:biometric:1.1.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.featurecombinationquery:featurecombinationquery:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.viewfinder:viewfinder-core:1.5.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-camera2-pipe:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-camera2:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-core:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-lifecycle:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-video:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-view:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.cardview:cardview:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.collection:collection-jvm:1.5.0=benchmarkCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,profileCompileClasspath,releaseCompileClasspath
androidx.collection:collection-jvm:1.6.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection-ktx:1.6.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection:1.5.0=benchmarkCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,profileCompileClasspath,releaseCompileClasspath
androidx.collection:collection:1.6.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-core-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-core:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-layout-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-layout:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3.adaptive:adaptive-android:1.3.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3.adaptive:adaptive-layout:1.3.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3.adaptive:adaptive-navigation:1.3.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3.adaptive:adaptive:1.3.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3:material3-android:1.4.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3:material3:1.4.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-ripple-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-ripple:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-annotation-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-annotation:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-retain-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-retain:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-saveable-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-saveable:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-geometry-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-geometry:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-graphics-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-graphics:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-test-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-accessibility-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-accessibility:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-text-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-text:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-unit-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-unit:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-util-android:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-util:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose:compose-bom:2026.09.00=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath
androidx.concurrent:concurrent-futures:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath
androidx.coordinatorlayout:coordinatorlayout:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.core:core-backported-fixes:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-ktx:1.18.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-viewtree:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core:1.18.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.credentials:credentials:1.6.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.cursoradapter:cursoradapter:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.customview:customview-poolingcontainer:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.customview:customview:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.drawerlayout:drawerlayout:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.emoji2:emoji2:1.4.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.exifinterface:exifinterface:1.4.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.fragment:fragment:1.9.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.graphics:graphics-path:1.0.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.interpolator:interpolator:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common-java8:2.11.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common-jvm:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata-core:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-process:2.11.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-android:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-compose:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-ktx:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.loader:loader:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.media3:media3-common:1.9.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.media3:media3-container:1.9.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.media3:media3-muxer:1.9.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation3:navigation3-runtime-android:1.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation3:navigation3-runtime:1.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation3:navigation3-ui-android:1.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation3:navigation3-ui:1.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-android:1.1.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose-android:1.1.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose:1.1.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent:1.1.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.profileinstaller:profileinstaller:1.4.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.recyclerview:recyclerview:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.savedstate:savedstate-android:1.4.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-compose-android:1.4.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-compose:1.4.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-ktx:1.4.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate:1.4.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.startup:startup-runtime:1.1.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.test.espresso:espresso-core:3.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.ext:junit:1.1.5=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.services:storage:1.6.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:rules:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.tracing:tracing-android:2.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.tracing:tracing-ktx:2.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath
androidx.tracing:tracing:2.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.transition:transition:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.vectordrawable:vectordrawable-animated:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.vectordrawable:vectordrawable:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.versionedparcelable:versionedparcelable:1.1.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.viewpager2:viewpager2:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.viewpager:viewpager:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.window:window-core-android:1.5.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.window:window-core:1.5.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.window:window:1.5.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
ch.qos.logback:logback-classic:1.3.15=ktlint
ch.qos.logback:logback-core:1.3.15=ktlint
com.android.tools.analytics-library:protos:32.4.1=androidLintTool
com.android.tools.analytics-library:shared:32.4.1=androidLintTool
com.android.tools.analytics-library:tracker:32.4.1=androidLintTool
com.android.tools.androidtest:android-test-engine-result-listener:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.androidtest:android-test-engine:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.androidtest:test-result-event-proto:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.androidtest:test-result-proto:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.build:aapt2-proto:9.4.1-15978811=androidLintTool
com.android.tools.build:builder-model:9.4.1=androidLintTool
com.android.tools.build:manifest-merger:32.4.1=androidLintTool
com.android.tools.ddms:ddmlib:32.4.1=androidLintTool
com.android.tools.external.com-intellij:intellij-core:32.4.1=androidLintTool
com.android.tools.external.com-intellij:kotlin-compiler:32.4.1=androidLintTool
com.android.tools.external.org-jetbrains:uast:32.4.1=androidLintTool
com.android.tools.layoutlib:layoutlib-api:32.4.1=androidLintTool
com.android.tools.lint:lint-api:32.4.1=androidLintTool
com.android.tools.lint:lint-checks:32.4.1=androidLintTool
com.android.tools.lint:lint-gradle:32.4.1=androidLintTool
com.android.tools.lint:lint-model:32.4.1=androidLintTool
com.android.tools.lint:lint-typedef-remover:32.4.1=androidLintTool
com.android.tools.lint:lint:32.4.1=androidLintTool
com.android.tools.utp:gradle-work-action:32.4.1=unified-test-platform-gradle-work-action
com.android.tools:annotations:32.4.1=androidLintTool
com.android.tools:common:32.4.1=androidLintTool
com.android.tools:dvlib:32.4.1=androidLintTool
com.android.tools:play-sdk-proto:32.4.1=androidLintTool
com.android.tools:repository:32.4.1=androidLintTool
com.android.tools:sdk-common:32.4.1=androidLintTool
com.android.tools:sdklib:32.4.1=androidLintTool
com.github.ajalt.clikt:clikt-jvm:5.0.3=ktlint
com.github.ajalt.clikt:clikt:5.0.3=ktlint
com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:4.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.google.android.material:material:1.2.0-rc01=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.google.auto.value:auto-value-annotations:1.6.3=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.code.findbugs:jsr305:3.0.2=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,androidLintTool,benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.code.gson:gson:2.11.0=androidLintTool
com.google.dagger:dagger-compiler:2.60.1=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.dagger:dagger-lint-aar:2.60.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.dagger:dagger-spi:2.60.1=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.dagger:dagger:2.60.1=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.dagger:hilt-android:2.60.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.dagger:hilt-compiler:2.60.1=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.dagger:hilt-core:2.60.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.devtools.ksp:symbol-processing-api:2.3.12=kotlinCompilerPluginClasspathBenchmark,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathProfile,kotlinCompilerPluginClasspathRelease,kspPluginClasspath,kspPluginClasspathNonEmbeddable
com.google.devtools.ksp:symbol-processing-api:2.3.7=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.errorprone:error_prone_annotations:2.28.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.30.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool
com.google.errorprone:error_prone_annotations:2.47.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.googlejavaformat:google-java-format:1.33.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.guava:failureaccess:1.0.2=androidLintTool,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.guava:failureaccess:1.0.3=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.guava:guava:33.3.1-android=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.guava:guava:33.4.0-jre=androidLintTool
com.google.guava:guava:33.6.0-jre=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.guava:listenablefuture:1.0=benchmarkCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,profileCompileClasspath,releaseCompileClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,androidLintTool,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.j2objc:j2objc-annotations:3.1=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.google.jimfs:jimfs:1.1=androidLintTool
com.google.protobuf:protobuf-java:3.25.5=androidLintTool,unified-test-platform-gradle-work-action
com.google.protobuf:protobuf-javalite:3.19.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.pinterest.ktlint:ktlint-cli-reporter-baseline:1.7.1=ktlint,ktlintBaselineReporter
com.pinterest.ktlint:ktlint-cli-reporter-checkstyle:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-core:1.7.1=ktlint,ktlintBaselineReporter
com.pinterest.ktlint:ktlint-cli-reporter-format:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-html:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-json:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-plain-summary:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-plain:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-sarif:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-ruleset-core:1.7.1=ktlint,ktlintRuleset
com.pinterest.ktlint:ktlint-cli:1.7.1=ktlint
com.pinterest.ktlint:ktlint-logger:1.7.1=ktlint,ktlintBaselineReporter,ktlintRuleset
com.pinterest.ktlint:ktlint-rule-engine-core:1.7.1=ktlint,ktlintBaselineReporter,ktlintRuleset
com.pinterest.ktlint:ktlint-rule-engine:1.7.1=ktlint
com.pinterest.ktlint:ktlint-ruleset-standard:1.7.1=ktlint,ktlintRuleset
com.squareup:javapoet:1.13.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.squareup:kotlinpoet:1.11.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool
commons-codec:commons-codec:1.17.1=androidLintTool
commons-io:commons-io:2.16.1=androidLintTool
commons-logging:commons-logging:1.2=androidLintTool
dev.drewhamilton.poko:poko-annotations-jvm:0.19.1=ktlint,ktlintBaselineReporter,ktlintRuleset
dev.drewhamilton.poko:poko-annotations:0.19.1=ktlint,ktlintBaselineReporter,ktlintRuleset
io.github.detekt.sarif4k:sarif4k-jvm:0.6.0=ktlint,ktlintReporter
io.github.detekt.sarif4k:sarif4k:0.6.0=ktlint,ktlintReporter
io.github.oshai:kotlin-logging-jvm:7.0.7=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset
io.github.oshai:kotlin-logging:5.1.0=ktlint,ktlintBaselineReporter,ktlintReporter
jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool
jakarta.inject:jakarta.inject-api:2.0.1=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool
javax.inject:javax.inject:1=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,androidLintTool,benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.java.dev.jna:jna-platform:5.6.0=androidLintTool
net.java.dev.jna:jna:5.19.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
net.java.dev.jna:jna:5.6.0=androidLintTool
net.ltgt.gradle.incap:incap:0.2=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
net.sf.kxml:kxml2:2.3.0=androidLintTool
org.apache.commons:commons-compress:1.27.1=androidLintTool
org.apache.commons:commons-lang3:3.16.0=androidLintTool
org.apache.groovy:groovy-bom:4.0.29=androidLintTool
org.apache.groovy:groovy:4.0.29=androidLintTool
org.apache.httpcomponents:httpclient:4.5.6=androidLintTool
org.apache.httpcomponents:httpcore:4.4.16=androidLintTool
org.apache.httpcomponents:httpmime:4.5.6=androidLintTool
org.bouncycastle:bcpkix-jdk18on:1.80.2=androidLintTool
org.bouncycastle:bcprov-jdk18on:1.80.2=androidLintTool
org.bouncycastle:bcutil-jdk18on:1.80.2=androidLintTool
org.checkerframework:checker-compat-qual:2.5.3=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
org.checkerframework:checker-qual:3.43.0=androidLintTool,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool
org.glassfish.jaxb:txw2:2.3.2=androidLintTool
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
org.jetbrains.kotlin:compose-group-mapping:2.4.21=composeMappingProducerClasspath
org.jetbrains.kotlin:kotlin-bom:1.8.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.21=kotlin-extension,kotlinCompilerPluginClasspathBenchmark,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathProfile,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
org.jetbrains.kotlin:kotlin-reflect:1.6.10=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool
org.jetbrains.kotlin:kotlin-script-runtime:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=ktlintReporter
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,ktlintReporter
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,ktlintReporter
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:2.2.0=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=composeMappingProducerClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.3.20=kotlinCompilerPluginClasspathBenchmark,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathProfile,kotlinCompilerPluginClasspathRelease,kspPluginClasspath,kspPluginClasspathNonEmbeddable
org.jetbrains.kotlin:kotlin-stdlib:2.3.21=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.4.21=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlinx:atomicfu-jvm:0.28.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:atomicfu:0.28.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.4.1=ktlintReporter
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core:1.4.1=ktlintReporter
org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.4.1=ktlintReporter
org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1=ktlintReporter
org.jetbrains:annotations:13.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,composeMappingProducerClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathBenchmark,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathProfile,kotlinCompilerPluginClasspathRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset
org.jetbrains:annotations:23.0.0=androidLintTool,benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-gradle-work-action
org.jsoup:jsoup:1.15.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
org.jspecify:jspecify:1.0.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.junit.platform:junit-platform-commons:1.13.3=unified-test-platform-gradle-work-action
org.junit.platform:junit-platform-engine:1.13.3=unified-test-platform-gradle-work-action
org.junit.platform:junit-platform-launcher:1.13.3=unified-test-platform-gradle-work-action
org.junit:junit-bom:5.13.3=unified-test-platform-gradle-work-action
org.jvnet.staxex:stax-ex:1.8.1=androidLintTool
org.opentest4j:opentest4j:1.3.0=unified-test-platform-gradle-work-action
org.ow2.asm:asm-analysis:9.9=androidLintTool
org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath
org.ow2.asm:asm:9.9=androidLintTool
org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath
org.slf4j:slf4j-api:2.0.7=ktlint
rustls:rustls-platform-verifier:0.1.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,benchmarkAnnotationProcessorClasspath,benchmarkImplementationDependenciesMetadata,benchmarkReverseMetadataValues,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyBenchmark,hiltCompileOnlyDebug,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,hiltCompileOnlyProfile,hiltCompileOnlyRelease,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,profileAnnotationProcessorClasspath,profileImplementationDependenciesMetadata,profileReverseMetadataValues,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues
+1
View File
@@ -0,0 +1 @@
# Native/generated keep rules are added with the UniFFI packaging in #92.
@@ -0,0 +1,274 @@
package de.rfc1437.ironstorage
import android.content.pm.ActivityInfo
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelProvider
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.SetupStatus
import de.rfc1437.ironstorage.core.ShellFixtures
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellSnapshot
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class AdaptiveShellTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test fun independentPreferencesStackAndSafeRecreationUseCompactAndExpandedNavigation() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val repository = Fixture()
val saved = SavedStateHandle()
var setupCancellations = 0
val onboarding = object : OnboardingRepository by original.onboarding {
override fun cancel() {
setupCancellations += 1
original.onboarding.cancel()
}
}
val model =
ShellViewModel(
repository,
onboarding,
original.passwords,
original.home,
original.search,
original.authentication,
original.editor,
original.settings, original.totp,
saved
)
compose.activity.runOnUiThread {
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
compose.waitUntil(10000) {
compose.activity.resources.configuration.orientation ==
android.content.res.Configuration.ORIENTATION_PORTRAIT
}
lateinit var inputMode: InputModeManager
fun show(viewModel: ShellViewModel, largeFont: Boolean = false) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
inputMode = LocalInputModeManager.current
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(density.density, if (largeFont) 2f else 1f)
) {
ApplicationTheme(original.authentication) { ShellScreen(viewModel, setup) }
}
}
}
}
show(model)
compose.onNodeWithTag("shell-bottom-navigation").assertIsDisplayed()
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
compose.onNodeWithTag("shell-tab-Preferences")
.assertHasClickAction().assertHeightIsAtLeast(48.dp)
.performSemanticsAction(SemanticsActions.RequestFocus) { assertTrue(it()) }
.performKeyInput { pressKey(Key.Enter) }
compose.runOnIdle {
setup.passphrase = "public fixture passphrase"
setup.armor = "public fixture armor"
setup.token = "public fixture token"
}
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(hasTestTag("shell-open-appearance"))
compose.onNodeWithTag("shell-open-appearance").performClick()
compose.runOnIdle {
assertEquals("", setup.passphrase)
assertEquals("", setup.armor)
assertEquals("", setup.token)
assertEquals(1, setupCancellations)
}
compose.onNodeWithText("System default").assertIsDisplayed()
compose.runOnIdle { setup.token = "public fixture token" }
compose.onNodeWithTag("shell-tab-Search").performClick()
compose.runOnIdle {
assertEquals("", setup.token)
assertEquals(2, setupCancellations)
}
compose.onNodeWithTag("shell-tab-Preferences").performClick()
compose.onNodeWithText("System default").assertIsDisplayed()
compose.onNodeWithText("Back").performScrollTo().performClick()
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(hasTestTag("shell-open-appearance"))
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(hasTestTag("shell-open-appearance"))
compose.onNodeWithTag("shell-open-appearance").performClick()
compose.runOnIdle {
assertEquals("Preferences", saved.get<String>("shell.tab"))
assertEquals(true, saved.get<Boolean>("shell.appearance"))
assertTrue(
saved.keys().all {
saved.get<Any>(it) is String ||
saved.get<Any>(it) is Boolean
}
)
assertFalse(original.authentication.state.value.unlocked)
}
// A new ViewModel receives only primitive safe navigation values, as after process death.
val restored =
ShellViewModel(
repository,
onboarding,
original.passwords,
original.home,
original.search,
original.authentication,
original.editor,
original.settings, original.totp,
SavedStateHandle(
saved.keys().associateWith {
saved.get<Any>(it)
}
)
)
show(restored)
compose.onNodeWithText("System default").assertIsDisplayed()
compose.onNodeWithTag("shell-tab-Preferences").assertIsSelected()
compose.activity.runOnUiThread {
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
compose.waitUntil(10000) {
compose.activity.resources.configuration.orientation ==
android.content.res.Configuration.ORIENTATION_LANDSCAPE
}
show(restored, largeFont = true)
compose.onNodeWithTag("shell-navigation-rail").assertIsDisplayed()
compose.onNodeWithTag("shell-tab-Preferences").assertIsSelected()
compose.onNodeWithText("Dark").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Back").performScrollTo().performClick()
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(hasTestTag("shell-open-appearance"))
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
}
@Test fun processRecreationExplainsDiscardedDraftWithoutRestoringSecretState() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val saved = SavedStateHandle(mapOf("shell.tab" to "Passwords", "editor.wasOpen" to true))
val model = ShellViewModel(
Fixture(),
original.onboarding,
original.passwords,
original.home,
original.search,
original.authentication,
original.editor,
original.settings, original.totp,
saved
)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
ApplicationTheme(original.authentication) { ShellScreen(model, setup) }
}
}
compose.waitUntil(10000) { original.editor.state.value.error != null }
compose.onNodeWithText(
"An unsaved draft was discarded after the application process restarted. " +
"Unlock the entry to edit it again."
).assertIsDisplayed()
compose.runOnIdle {
assertFalse(original.editor.state.value.open)
assertTrue(original.editor.state.value.fields.isEmpty())
assertFalse(original.authentication.state.value.unlocked)
assertEquals(false, saved.get<Boolean>("editor.wasOpen"))
assertTrue(saved.keys().none { it.contains("draft") || it.contains("secret") })
}
}
@Test fun configuredEmptyStoreOffersEntryAndFolderCreation() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val repository = Fixture().apply {
snapshot.value = ShellSnapshot(
ShellState.Empty,
ShellFixtures.all.getValue(ShellState.Empty),
selectedTab = ShellTab.Passwords
)
}
val onboarding = object : OnboardingRepository by original.onboarding {
override val setup = MutableStateFlow(SetupStatus(configured = true))
}
val model = ShellViewModel(
repository,
onboarding,
original.passwords,
original.home,
original.search,
original.authentication,
original.editor,
original.settings, original.totp,
SavedStateHandle(mapOf("shell.tab" to "Passwords"))
)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
ApplicationTheme(original.authentication) { ShellScreen(model, setup) }
}
}
compose.onNodeWithText("New entry").assertIsDisplayed().performClick()
compose.onNodeWithText("Create").assertIsDisplayed()
compose.onNodeWithText("Cancel").performClick()
compose.onNodeWithText("New folder").assertIsDisplayed().performClick()
compose.onNodeWithText("Create").assertIsDisplayed()
compose.onNodeWithText("Cancel").performClick()
compose.runOnIdle { assertFalse(original.editor.state.value.open) }
}
internal class Fixture : ShellRepository {
override val snapshot = MutableStateFlow(
ShellSnapshot(
ShellState.Locked,
ShellFixtures.all.getValue(ShellState.Locked)
)
)
override fun selectTab(tab: ShellTab) {
snapshot.value =
snapshot.value.copy(selectedTab = tab)
}
}
}
@@ -0,0 +1,251 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.collectAsState
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AndroidKeystore
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
import de.rfc1437.ironstorage.core.KeystoreAuthorization
import de.rfc1437.ironstorage.core.KeystoreCiphertext
import de.rfc1437.ironstorage.core.KeystoreKeySpecification
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
import de.rfc1437.ironstorage.core.NativePrivateSession
import de.rfc1437.ironstorage.core.PrivateDirectories
import de.rfc1437.ironstorage.features.AuthenticationControls
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import java.io.File
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
/** Real OS/Rust integration using public fixtures and only production Kotlin facades. */
class AuthenticationLeaseTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.targetContext
@Test fun strongBiometricLeaseCancellationBackgroundAndManualRecovery() = runBlocking {
val securityOnly =
InstrumentationRegistry.getArguments().getString("biometricSecurity") == "true"
assumeTrue(
securityOnly ||
InstrumentationRegistry.getArguments().getString("biometricLease") == "true"
)
val os = AndroidKeystoreAdapter(context)
assertTrue(
"Enroll a synthetic strong fingerprint for this opt-in test",
os.deviceState().strongBiometric
)
val id = "auth-${UUID.randomUUID()}"
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, id).apply { mkdirs() }.canonicalPath
}
val isolated = FixtureKeystore(os, id)
val session =
NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, isolated)
// Select the isolated Rust context before any activity creates its default runtime.
withContext(Dispatchers.IO) {
session.initialize()
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val shell = NativeFixtureShellRepository(session, scope)
val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val setup = NativeOnboardingRepository(session, scope, shell, authentication)
val password = "public-test-passphrase"
suspend fun settled() = withTimeout(180000) {
authentication.state.first { !it.busy }
}
suspend fun request(action: () -> Unit) {
withContext(Dispatchers.Main.immediate) { action() }
}
val stage = MutableStateFlow("Authentication fixture")
val step = AtomicReference<CountDownLatch?>()
fun show(value: String) {
android.util.Log.i("IronStorage98", value)
stage.value = value
}
fun awaitStep(value: String): Boolean {
val latch = CountDownLatch(1)
step.set(latch)
show(value)
return try {
latch.await(180, TimeUnit.SECONDS)
} finally {
step.set(null)
}
}
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
lateinit var host: MainActivity
scenario.onActivity {
host = it
os.attach(it)
it.setProtectedTestContent {
MaterialTheme {
Column {
Text(stage.collectAsState().value)
TextButton(onClick = {
step.get()?.countDown()
}) { Text("Continue fixture") }
AuthenticationControls(authentication)
}
}
}
}
try {
withTimeout(20000) { setup.setup.first { !it.busy } }
request { setup.generate("Authentication fixture", password) }
val generated = withTimeout(20000) {
setup.setup.first {
!it.busy &&
it.selectedKey.isNotEmpty()
}
}
request { setup.createLocal(generated.selectedKey, password) }
withTimeout(20000) { setup.setup.first { !it.busy && it.configured } }
show("98:enroll")
request { authentication.enroll(password) }
assertTrue(settled().biometricEnabled)
request { authentication.lock() }
assertFalse(authentication.state.value.unlocked)
show("98:unlock")
request { authentication.unlock() }
assertTrue(settled().unlocked)
request { authentication.reload() }
assertTrue(settled().unlocked)
request { authentication.lock() }
show("98:cancel")
request { authentication.unlock() }
assertEquals(AuthenticationFailureKind.Cancelled, settled().failure?.kind)
assertFalse(authentication.state.value.unlocked)
assertTrue(awaitStep("98:background-start"))
request { authentication.unlock() }
instrumentation.waitForIdleSync()
scenario.moveToState(Lifecycle.State.CREATED)
request { authentication.lifecycle(AuthenticationLifecycle.Background) }
assertFalse(settled().unlocked)
scenario.moveToState(Lifecycle.State.RESUMED)
if (securityOnly) {
request { authentication.lifecycle(AuthenticationLifecycle.Foreground) }
assertFalse(settled().unlocked)
show("100:unlock-after-background")
request { authentication.unlock() }
assertTrue(settled().unlocked)
request {
authentication.lifecycle(AuthenticationLifecycle.MemoryPressure)
assertFalse(authentication.state.value.unlocked)
}
request { authentication.disable() }
assertFalse(settled().biometricEnabled)
show("100:security-complete")
return@use
}
request { authentication.disable() }
assertFalse(settled().biometricEnabled)
show("98:reenroll")
request { authentication.enroll(password) }
assertTrue(settled().unlocked)
request { authentication.lock() }
assertTrue(awaitStep("98:invalidate-now"))
request { authentication.unlock() }
val invalid = settled()
assertFalse(invalid.unlocked)
assertTrue(
invalid.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
invalid.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
)
request { authentication.disable() }
assertFalse(settled().biometricEnabled)
show("98:recover")
request { authentication.enroll(password) }
assertTrue(settled().unlocked)
request { authentication.lock() }
assertTrue(awaitStep("98:remove-lock"))
request { authentication.unlock() }
val removed = settled()
assertFalse(removed.unlocked)
assertTrue(
removed.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
removed.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
)
request { authentication.disable() }
assertFalse(settled().biometricEnabled)
request { authentication.enroll(password) }
val unsupported = settled()
assertFalse(unsupported.unlocked)
assertFalse(unsupported.biometricEnabled)
assertEquals(
AuthenticationFailureKind.BiometryUnavailable,
unsupported.failure?.kind
)
assertTrue(awaitStep("98:restore-lock"))
show("98:recover-lock")
request { authentication.enroll(password) }
assertTrue(settled().unlocked)
request { authentication.disable() }
assertFalse(settled().unlocked)
show("98:complete")
} finally {
authentication.lock()
os.cancelPending()
os.detach(host)
scope.cancel()
isolated.aliases().forEach { isolated.deleteKey(it) }
}
}
}
/** Only OS aliases are namespaced so orphan cleanup cannot touch the production store. */
private class FixtureKeystore(private val os: AndroidKeystore, id: String) :
AndroidKeystore by os {
private val prefix = "ironstorage.issue98.fixture.$id."
override fun createKey(specification: KeystoreKeySpecification) = os.createKey(
KeystoreKeySpecification(
prefix + specification.alias,
specification.authorization,
specification.unlockedDevice,
specification.invalidateOnEnrollment,
specification.strongbox
)
)
override fun encrypt(
alias: String,
authorization: KeystoreAuthorization,
aad: ByteArray,
value: ByteArray
): KeystoreCiphertext = os.encrypt(prefix + alias, authorization, aad, value)
override fun decrypt(
alias: String,
authorization: KeystoreAuthorization,
aad: ByteArray,
value: KeystoreCiphertext
): ByteArray = os.decrypt(prefix + alias, authorization, aad, value)
override fun deleteKey(alias: String) = os.deleteKey(prefix + alias)
override fun aliases(): List<String> = os.aliases().filter {
it.startsWith(prefix)
}.map { it.removePrefix(prefix) }
}
}
@@ -0,0 +1,134 @@
package de.rfc1437.ironstorage
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.Lifecycle
import de.rfc1437.ironstorage.core.AuthenticationFailure
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.features.AuthenticationControls
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class AuthenticationScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test fun enrollmentRequiresExplicitSubmissionAndTypedSuccessBeforeDisplayingEnabled() {
val repository = Fixture()
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { AuthenticationControls(repository) }
}
}
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
compose.onNodeWithText("Enable biometric unlock").performClick()
compose.onNodeWithText("Continue").assertIsNotEnabled()
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
compose.onNodeWithText("Continue").performClick()
assertEquals("public-test-passphrase", repository.submitted)
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
compose.runOnIdle {
repository.state.value = AuthenticationState(
failure = AuthenticationFailure(
AuthenticationFailureKind.Cancelled,
"Authentication cancelled",
"No protected content was unlocked."
)
)
}
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
compose.runOnIdle { repository.state.value = AuthenticationState(true, true, 30u) }
compose.onNodeWithText("Biometric unlock enabled").assertIsDisplayed()
compose.onNodeWithText("Lock").performClick()
assertTrue(repository.locked)
compose.onNodeWithText("Unlock").performClick()
assertEquals(1, repository.unlocks)
compose.onNodeWithText("Disable biometric unlock").performClick()
assertEquals(1, repository.disabled)
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
}
@Test fun cancellationBackgroundAndRemountNeverRestoreThePassphrase() {
val repository = Fixture()
fun mount() = compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { AuthenticationControls(repository) }
}
}
mount()
compose.onNodeWithText("Enable biometric unlock").performClick()
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
compose.onNodeWithText("Cancel").performClick()
compose.onNodeWithText("Enable biometric unlock").performClick()
compose.onNodeWithText("Continue").assertIsNotEnabled()
compose.onNodeWithText("GPG passphrase").performTextInput("another-public-fixture")
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.onNodeWithText("GPG passphrase").assertDoesNotExist()
compose.onNodeWithText("Enable biometric unlock").performClick()
compose.onNodeWithText("Continue").assertIsNotEnabled()
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent { MaterialTheme { } }
}
mount()
compose.onNodeWithText("Enable biometric unlock").performClick()
compose.onNodeWithText("Continue").assertIsNotEnabled()
assertEquals(null, repository.submitted)
assertTrue(repository.cancelled > 0)
}
private class Fixture : AuthenticationRepository {
override val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
override fun lifecycle(event: de.rfc1437.ironstorage.core.AuthenticationLifecycle) {
if (event !=
de.rfc1437.ironstorage.core.AuthenticationLifecycle.Foreground
) {
lock()
}
}
override val state = MutableStateFlow(AuthenticationState())
var submitted: String? = null
var locked = false
var unlocks = 0
var disabled = 0
var cancelled = 0
override fun setAppearance(appearance: de.rfc1437.ironstorage.core.Appearance) {
state.value = state.value.copy(appearance = appearance)
}
override fun reload() = Unit
override fun enroll(passphrase: String) {
submitted = passphrase
}
override fun unlock() {
unlocks++
}
override fun disable() {
disabled++
state.value = AuthenticationState()
}
override fun lock() {
locked = true
state.value = state.value.copy(unlocked = false)
}
override fun cancel() {
cancelled++
state.value = state.value.copy(unlocked = false)
}
override fun touch() = Unit
}
}
@@ -0,0 +1,42 @@
package de.rfc1437.ironstorage
import android.content.pm.ApplicationInfo
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.xmlpull.v1.XmlPullParser
class BackupPolicyTest {
@Test
fun installedResourcesExcludeCredentialAndDeviceProtectedDomains() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals(0, context.applicationInfo.flags and ApplicationInfo.FLAG_ALLOW_BACKUP)
val domains = setOf(
"root", "file", "database", "sharedpref", "external",
"device_root", "device_file", "device_database", "device_sharedpref"
)
for ((resource, sections) in mapOf(
R.xml.backup_rules to setOf("full-backup-content"),
R.xml.data_extraction_rules to setOf("cloud-backup", "device-transfer")
)) {
context.resources.getXml(resource).use { xml ->
val excluded = mutableMapOf<String, MutableSet<String>>()
var section = ""
while (xml.eventType != XmlPullParser.END_DOCUMENT) {
if (xml.eventType == XmlPullParser.START_TAG) {
if (xml.name in sections) section = xml.name
assertFalse(xml.name == "include")
if (xml.name == "exclude") {
assertEquals(".", xml.getAttributeValue(null, "path"))
excluded.getOrPut(section) { mutableSetOf() }
.add(xml.getAttributeValue(null, "domain"))
}
}
xml.next()
}
assertEquals(sections.associateWith { domains }, excluded)
}
}
}
}
@@ -0,0 +1,114 @@
package de.rfc1437.ironstorage
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import de.rfc1437.ironstorage.core.CredentialCandidate
import de.rfc1437.ironstorage.core.ProviderRequest
import de.rfc1437.ironstorage.features.CredentialSelectionScreen
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class CredentialSelectionScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private var selected by mutableStateOf<String?>(null)
private var destination by mutableStateOf("")
private var consent by mutableStateOf(false)
private var overwrite by mutableStateOf(false)
private var query by mutableStateOf("")
private var passphrase by mutableStateOf("")
private var confirmed = 0
private fun display(saving: Boolean) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
CredentialSelectionScreen(
ProviderRequest(
"public-test-token",
"example.client",
"revision",
listOf(
CredentialCandidate("example.client/alice", true)
)
),
saving,
selected,
query,
destination,
passphrase,
consent,
overwrite,
false,
null,
onSelect = {
selected = it
consent = false
},
onQuery = { query = it },
onDestination = {
destination = it
consent = false
overwrite = false
},
onPassphrase = { passphrase = it },
onConsent = { consent = it },
onOverwrite = { overwrite = it },
onConfirm = { confirmed++ },
onCancel = {
consent = false
passphrase = ""
}
)
}
}
}
}
@Test fun fillRequiresSelectionAndNamedCallerConsent() {
display(false)
compose.onNodeWithText("Unlock and fill").assertIsNotEnabled()
compose.onNodeWithText("example.client/alice").performClick()
compose.onNodeWithText("Unlock and fill").assertIsNotEnabled()
compose.onNodeWithText(
"Allow example.client to receive this entry's username and password"
).performClick()
compose.onNodeWithText("Unlock and fill").assertIsEnabled().performClick()
assertEquals(1, confirmed)
compose.onNodeWithText("Choose another entry").performClick()
assertEquals(false, consent)
compose.onNodeWithText("Unlock and fill").assertIsNotEnabled()
}
@Test fun saveRequiresDestinationAndChangingItClearsConsents() {
display(true)
compose.onNodeWithText("Confirm save").assertIsNotEnabled()
compose.onNodeWithText("Destination entry path").performTextInput("chosen-entry")
compose.onNodeWithText("Allow updating this destination if it exists").performClick()
compose.onNodeWithText(
"Save the supplied credential at the chosen destination"
).performClick()
compose.onNodeWithText("Confirm save").assertIsEnabled()
compose.onNodeWithText("Destination entry path").performTextInput("-other")
compose.runOnIdle {
assertEquals(false, consent)
assertEquals(false, overwrite)
}
compose.onNodeWithText("Confirm save").assertIsNotEnabled()
compose.onNodeWithText("Cancel").performClick()
assertEquals(0, confirmed)
}
}
@@ -0,0 +1,347 @@
package de.rfc1437.ironstorage
import android.os.Bundle
import android.os.Parcel
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.HingeInfo
import androidx.compose.material3.adaptive.Posture
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Density
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.EditorField
import de.rfc1437.ironstorage.core.EditorFieldKind
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.EditorState
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.EditorDiscardDialog
import de.rfc1437.ironstorage.features.EntryEditorScreen
import de.rfc1437.ironstorage.features.HingeSafeLayout
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class EntryEditorScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test fun tabletopGeneratorKeepsEveryControlReachableAtLargeFont() {
val repository = Fixture()
val authentication = object : AuthenticationRepository by compose.activity.authentication {
override val state = MutableStateFlow(AuthenticationState(unlocked = true))
}
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
BoxWithConstraints {
val density = LocalDensity.current
val width = with(density) { maxWidth.toPx() }
val height = with(density) { maxHeight.toPx() }
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
MaterialTheme {
HingeSafeLayout(
Posture(
true,
listOf(
HingeInfo(
Rect(
0f,
height * .5f,
width,
height * .5f + 12f
),
false,
false,
true,
true
)
)
)
) { EntryEditorScreen(repository, authentication) }
}
}
}
}
}
compose.onNodeWithTag("entry-editor")
.performScrollToNode(hasText("Generate password"))
compose.onNodeWithText("Generate password").performClick()
compose.onNodeWithText("Password length").performScrollTo().performTextReplacement("24")
compose.onNodeWithTag("hinge-dialog-content").performTouchInput { swipeUp() }
compose.onNodeWithText("Use letters and numbers only").performScrollTo()
compose.onNodeWithText("Use letters and numbers only").assertIsDisplayed()
compose.onNodeWithText("Generate replacement").performScrollTo().performClick()
assertEquals(1, repository.generations)
assertEquals(24u, repository.length)
}
@Test fun structuredInputsGenerationAndAccessibleReorderForwardTypedIds() {
val repository = Fixture()
val lease = MutableStateFlow(AuthenticationState(unlocked = true))
val presentation = ProtectedPresentation()
val authentication = object : AuthenticationRepository by compose.activity.authentication {
override val state = lease
override val presentation = presentation
override fun lock() {
lease.value = AuthenticationState()
presentation.clear()
}
}
lateinit var inputMode: InputModeManager
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent(presentation) {
inputMode = LocalInputModeManager.current
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
MaterialTheme {
EntryEditorScreen(repository, authentication)
EditorDiscardDialog(repository)
}
}
}
}
fun field(tag: String) = compose.onNodeWithTag("entry-editor")
.performScrollToNode(hasTestTag(tag)).let { compose.onNodeWithTag(tag) }
fun control(text: String) = compose.onNodeWithTag("entry-editor")
.performScrollToNode(hasText(text)).let { compose.onNodeWithText(text) }
val password = field("editor-value-41")
assertTrue(password.fetchSemanticsNode().config.contains(SemanticsProperties.Password))
password.performTextReplacement("public replacement secret")
assertEquals(41uL, repository.changedId)
assertEquals("public replacement secret", repository.state.value.fields.first().value)
field("editor-value-87").performTextReplacement("first\nsecond\n日本語")
assertEquals(87uL, repository.changedId)
field("editor-down-73").performClick()
assertEquals(73uL to 2u, repository.moved)
field("editor-remove-87").performClick()
assertEquals(87uL, repository.removed)
control("Generate password").performClick()
assertEquals(0, repository.generations)
compose.onNodeWithText("Password length").performTextReplacement("24")
compose.onNodeWithText("Generate replacement").performClick()
assertEquals(1, repository.generations)
assertEquals(24u, repository.length)
control("Add named field").performClick()
compose.onNodeWithTag("editor-add-name").performTextReplacement("custom")
compose.onNodeWithTag("editor-add-value").performTextReplacement("public value")
compose.onNodeWithText("Add field").performClick()
assertEquals(Triple(EditorFieldKind.Field, "custom", "public value"), repository.added)
control("Cancel").performClick()
compose.onNodeWithText("Discard unsaved changes?").assertIsDisplayed()
compose.onNodeWithText("Keep editing").performClick()
assertFalse(repository.state.value.discardRequested)
assertEquals("public replacement secret", repository.state.value.fields.first().value)
compose.activity.runOnUiThread {
compose.activity.onBackPressedDispatcher.onBackPressed()
}
compose.onNodeWithText("Discard unsaved changes?").assertIsDisplayed()
compose.onNodeWithText("Keep editing").performClick()
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
control("Save entry")
.performSemanticsAction(SemanticsActions.RequestFocus) { assertTrue(it()) }
.performKeyInput { pressKey(Key.Enter) }
assertEquals(1, repository.saves)
val draft = repository.state.value
compose.runOnIdle {
repository.state.value =
repository.state.value.copy(locked = true, fields = emptyList())
presentation.clear()
}
compose.onNodeWithTag("editor-value-41").assertDoesNotExist()
compose.onNodeWithText("Unlock").assertIsDisplayed()
compose.runOnIdle { repository.state.value = draft }
field("editor-value-41").assertIsDisplayed()
compose.runOnIdle {
val saved = Bundle()
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
compose.activity,
saved
)
val parcel = Parcel.obtain()
try {
parcel.writeBundle(saved)
val bytes = parcel.marshall()
for (marker in repository.state.value.fields.mapNotNull { it.value }) {
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
}
} finally {
parcel.recycle()
}
}
}
@Test fun hiddenEditorValuesAreAbsentFromAndroidAccessibilityNodes() {
val repository = Fixture()
val authentication = object : AuthenticationRepository by compose.activity.authentication {
override val state = MutableStateFlow(AuthenticationState(unlocked = true))
override val presentation = ProtectedPresentation()
}
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent(authentication.presentation) {
MaterialTheme { EntryEditorScreen(repository, authentication) }
}
}
compose.onNodeWithTag("editor-value-41").assertIsDisplayed()
val markers = repository.state.value.fields.mapNotNull { it.value }
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
var active: android.view.accessibility.AccessibilityNodeInfo? = null
compose.waitUntil(10000) {
active = automation.rootInActiveWindow
active != null
}
val root = requireNotNull(active)
@Suppress("DEPRECATION")
fun inspect(node: android.view.accessibility.AccessibilityNodeInfo) {
try {
val announced = listOf(
node.text,
node.contentDescription,
node.hintText
).mapNotNull {
it?.toString()
}
assertTrue(
"Hidden values must never be announced",
markers.none { marker ->
announced.any {
marker in
it
}
}
)
repeat(node.childCount) { index -> node.getChild(index)?.let(::inspect) }
} finally {
node.recycle()
}
}
inspect(root)
}
private class Fixture : EditorRepository {
override val state = MutableStateFlow(
EditorState(
open = true,
dirty = true,
defaultLength = 25u,
maximumLength = 4096u,
fields = listOf(
field(
41uL,
EditorFieldKind.Password,
null,
"Password",
"public fixture secret",
false
),
field(73uL, EditorFieldKind.Field, "custom", "Custom", "public field", true),
field(
87uL,
EditorFieldKind.Note,
null,
"Notes",
"first note\nsecond note",
true
)
)
)
)
var changedId: ULong? = null
var moved: Pair<ULong, UInt>? = null
var removed: ULong? = null
var added: Triple<EditorFieldKind, String?, String>? = null
var generations = 0
var length: UInt? = null
var saves = 0
override fun processRecreated() = Unit
override fun begin(path: String) = Unit
override fun create(parent: String, name: String, passphrase: String) = Unit
override fun createFolder(parent: String, name: String) = Unit
override fun change(id: ULong, name: String?, value: String?) {
changedId = id
state.value = state.value.copy(
fields = state.value.fields.map {
if (it.id == id) it.copy(name = name, value = value) else it
}
)
}
override fun add(kind: EditorFieldKind, name: String?, value: String) {
added = Triple(kind, name, value)
state.value =
state.value.copy(completedOperations = state.value.completedOperations + 1)
}
override fun remove(id: ULong) {
removed = id
}
override fun reorder(id: ULong, index: UInt) {
moved = id to index
}
override fun generate(length: UInt?, noSymbols: Boolean) {
generations += 1
this.length =
length
}
override fun save() {
saves += 1
}
override fun resume(passphrase: String) = Unit
override fun requestClose(after: () -> Unit) {
state.value =
state.value.copy(discardRequested = true)
}
override fun keepEditing() {
state.value = state.value.copy(discardRequested = false)
}
override fun confirmDiscard() {
state.value = EditorState()
}
companion object {
private fun field(
id: ULong,
kind: EditorFieldKind,
name: String?,
label: String,
value: String,
movable: Boolean
) = EditorField(
id, kind, name, label, value, "••••••", null,
sensitive = true, multiline = kind == EditorFieldKind.Note,
nameEditable = kind == EditorFieldKind.Field,
removable = movable, reorderable = movable
)
}
}
}
@@ -0,0 +1,377 @@
package de.rfc1437.ironstorage
import android.content.ClipboardManager
import android.view.WindowManager
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.unit.Density
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.PasswordDestination
import de.rfc1437.ironstorage.core.PasswordEdit
import de.rfc1437.ironstorage.core.PasswordField
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordMutationPlan
import de.rfc1437.ironstorage.core.PasswordOtp
import de.rfc1437.ironstorage.core.PasswordOtpKind
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.PasswordSection
import de.rfc1437.ironstorage.core.PasswordState
import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.features.PasswordScreen
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class EntryViewerTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test fun maskedFieldsExplicitActionsEditorDiagnosticsAndRevocation() {
val repository = Fixture()
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { PasswordScreen(repository, repository.authentication) }
}
}
compose.onNodeWithText("654321").assertDoesNotExist()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("alice 日本語"))
compose.onNodeWithText("alice 日本語").assertIsDisplayed()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("123 456"))
compose.onNodeWithText("123 456").assertIsDisplayed()
compose.onNodeWithTag(
"entry-detail"
).performScrollToNode(hasContentDescription("Reveal PIN"))
compose.onNodeWithContentDescription("Reveal PIN")
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
assertEquals(2uL, repository.lastId)
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("654321"))
compose.onNodeWithText("654321").assertIsDisplayed()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Hide PIN"))
compose.onNodeWithContentDescription("Hide PIN").performClick()
compose.onNodeWithText("654321").assertDoesNotExist()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Copy PIN"))
compose.onNodeWithContentDescription("Copy PIN")
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
assertEquals(2uL, repository.lastId)
compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Edit PIN"))
compose.onNodeWithContentDescription("Edit PIN").performClick()
compose.onNode(hasSetTextAction()).performTextReplacement("987654")
compose.onNodeWithText("Save").performClick()
assertEquals("987654", repository.saved)
compose.onNodeWithTag(
"entry-detail"
).performScrollToNode(hasText("First line\nSecond line 🌻"))
compose.onNodeWithText("First line\nSecond line 🌻").assertIsDisplayed()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("Invalid OTP URI"))
compose.onNodeWithText("Invalid OTP URI").assertIsDisplayed()
compose.runOnIdle {
repository.state.value =
repository.state.value.copy(edit = PasswordEdit(2u, "PIN", "987654", false, true))
}
compose.runOnIdle { repository.lease.value = AuthenticationState() }
compose.onNode(hasSetTextAction()).assertDoesNotExist()
compose.onNodeWithText("123 456").assertDoesNotExist()
compose.onNodeWithText("First line\nSecond line 🌻").assertDoesNotExist()
compose.onNodeWithText("Unlock").assertIsDisplayed()
}
@Test fun overflowRequiresDestinationAndExplicitMutationConfirmation() {
val repository = Fixture()
repository.unlock("")
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { PasswordScreen(repository, repository.authentication) }
}
}
compose.onNodeWithText("More").performScrollTo().performClick()
compose.onNodeWithText("Move entry").performClick()
compose.onNodeWithText("destination\nFolder").performClick()
compose.onNodeWithText("Confirm").performClick()
assertEquals("destination", repository.destination)
assertEquals(PasswordMutation.Move, repository.action)
}
@Test fun clipboardMarksExplicitCopiesSensitiveAndViewerPreventsScreenCapture() {
compose.runOnIdle {
val clipboard = AndroidSecretClipboard(compose.activity)
clipboard.write("public-copy-fixture".toByteArray())
val manager = compose.activity.getSystemService(ClipboardManager::class.java)
org.junit.Assert.assertTrue(
manager.primaryClipDescription!!.extras!!
.getBoolean("android.content.extra.IS_SENSITIVE")
)
assertEquals("public-copy-fixture", clipboard.read()!!.toString(Charsets.UTF_8))
clipboard.clear()
org.junit.Assert.assertTrue(
compose.activity.window.attributes.flags and
WindowManager.LayoutParams.FLAG_SECURE != 0
)
}
}
@Test fun inspectDisposableViewerWithComputerUse() {
val arguments = InstrumentationRegistry.getArguments()
assumeTrue(arguments.getString("viewerInspect") == "true")
val repository = Fixture()
val done = CountDownLatch(1)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(
density.density,
arguments.getString("viewerFont")?.toFloat() ?: 1f
)
) {
MaterialTheme {
Column(Modifier.fillMaxSize()) {
TextButton(onClick = {
done.countDown()
}) { Text("Finish viewer inspection") }
PasswordScreen(
repository,
repository.authentication,
Modifier.weight(1f)
)
}
}
}
}
}
compose.waitForIdle()
android.util.Log.i("IronStorage99", "viewer-inspection-ready")
val started = android.os.SystemClock.elapsedRealtime()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
org.junit.Assert.assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000)
}
}
internal class Fixture : PasswordRepository {
private val row =
PasswordRow(
"row",
"viewer",
"Viewer",
"Locked entry",
PasswordKind.Entry,
"r",
ShellIcon.Key
)
override val state =
MutableStateFlow(PasswordState(rows = listOf(row), selected = row, revision = "r"))
val lease = MutableStateFlow(AuthenticationState())
val authentication = object : AuthenticationRepository {
override val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
override fun lifecycle(event: de.rfc1437.ironstorage.core.AuthenticationLifecycle) {
if (event !=
de.rfc1437.ironstorage.core.AuthenticationLifecycle.Foreground
) {
lock()
}
}
override val state = lease
override fun setAppearance(appearance: de.rfc1437.ironstorage.core.Appearance) {
state.value = state.value.copy(appearance = appearance)
}
override fun reload() = Unit
override fun unlock() = Unit
override fun enroll(passphrase: String) = Unit
override fun disable() = Unit
override fun lock() {
lease.value = AuthenticationState()
}
override fun cancel() = lock()
override fun touch() = Unit
}
var lastId: ULong? = null
var saved: String? = null
var destination: String? = null
var action: PasswordMutation? = null
private val sections = listOf(
PasswordSection(
"Password",
listOf(
PasswordField(0u, "Password", "Hidden", null, sensitive = true, editable = true)
)
),
PasswordSection(
"Details",
listOf(
PasswordField(
1u,
"Username",
"alice 日本語",
null,
selectable = true,
editable = true
),
PasswordField(2u, "PIN", "Hidden", null, sensitive = true, editable = true),
PasswordField(3u, "custom", "Repeated one", null, selectable = true),
PasswordField(4u, "custom", "Repeated two", null, selectable = true)
)
),
PasswordSection(
"OTP",
listOf(
PasswordField(
5u,
"OTP URI",
"Hidden",
"Invalid OTP URI",
sensitive = true
)
)
),
PasswordSection(
"Notes",
listOf(
PasswordField(
6u,
"Notes",
"First line\nSecond line 🌻",
null,
selectable = true,
multiline = true
)
)
)
)
override fun openSearch(id: String, revision: String) {
error("Search is not used by this fixture")
}
override fun restore(path: String, selected: String?, revision: String?) = Unit
override fun refresh() = Unit
override fun activate(id: String, revision: String) = Unit
override fun inspect(id: String, revision: String) = Unit
override fun back() = lock()
override fun unlock(passphrase: String) {
lease.value = AuthenticationState(unlocked = true)
state.value =
state.value.copy(
sections = sections,
otp = PasswordOtp(
PasswordOtpKind.Totp, "123456", "123 456", null, "fixture",
17u, 30u, 30u, null, null
)
)
}
override fun lock() {
authentication.lock()
state.value =
state.value.copy(sections = null, otp = null, edit = null)
}
private fun field(id: ULong, reveal: Boolean) {
lastId = id
state.value = state.value.copy(
sections = state.value.sections?.map { section ->
section.copy(
fields = section.fields.map {
if (it.id ==
id
) {
it.copy(
value = if (reveal) "654321" else "Hidden",
revealed = reveal
)
} else {
it
}
}
)
}
)
}
override fun reveal(id: ULong) = field(id, true)
override fun hide(id: ULong) = field(id, false)
override fun copy(id: ULong) {
lastId = id
android.util.Log.i("IronStorage99", "copy-field-$id")
}
override fun refreshOtp() = Unit
override fun advanceHotp() = error("TOTP fixture cannot consume a HOTP counter")
override fun copyOtp() = Unit
override fun edit(id: ULong) {
lastId = id
state.value =
state.value.copy(edit = PasswordEdit(id, "PIN", "654321", false, true))
}
override fun dismissEdit() {
state.value = state.value.copy(edit = null)
}
override fun saveEdit(value: String) {
saved = value
dismissEdit()
}
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
inspect(id, revision)
prepareMutation(action)
}
override fun unlockMutation(passphrase: String) = unlock(passphrase)
override fun prepareMutation(action: PasswordMutation) {
this.action = action
state.value =
state.value.copy(
mutation = PasswordMutationPlan(
action,
"viewer",
"r",
listOf(
PasswordDestination(
"destination",
"destination",
"Folder",
false
)
),
false
)
)
}
override fun dismissMutation() {
state.value = state.value.copy(mutation = null)
}
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) {
this.destination =
destination
dismissMutation()
}
}
}
@@ -0,0 +1,98 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.HingeInfo
import androidx.compose.material3.adaptive.Posture
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import de.rfc1437.ironstorage.core.HomeActionKind
import de.rfc1437.ironstorage.features.HingeSafeLayout
import de.rfc1437.ironstorage.features.HomeScreen
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class HingeWindowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun livePostureChangesKeepContentAndProtectedDialogOutsideTheHinge() {
compose.enableAccessibilityChecks()
val repository = HomeScreenTest.Fixture()
var mode by mutableStateOf(0)
var hinge = Rect.Zero
var density = 1f
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
BoxWithConstraints(Modifier.fillMaxSize()) {
density = LocalDensity.current.density
val width = maxWidth.value * density
val height = maxHeight.value * density
hinge = if (mode == 1) {
Rect(width * .35f, 0f, width * .35f + 12f, height)
} else {
Rect(0f, height * .5f, width, height * .5f + 12f)
}
val posture = Posture(
isTabletop = mode == 2,
hingeList = if (mode == 0) {
emptyList()
} else {
listOf(
HingeInfo(hinge, false, mode == 1, true, true)
)
}
)
MaterialTheme {
HingeSafeLayout(posture) { HomeScreen(repository) }
}
}
}
}
compose.onAllNodesWithText("Commit")[0].performScrollTo().performClick()
compose.onNodeWithText("Commit message").performTextInput("Public posture fixture")
for (posture in listOf(1, 2, 0)) {
compose.runOnIdle { mode = posture }
if (posture != 0) {
compose.onNodeWithText("Create commit").performScrollTo()
}
val action = compose.onNodeWithText("Create commit")
action.assertIsDisplayed()
if (posture != 0) {
val bounds = action.getUnclippedBoundsInRoot()
val pixels = Rect(
bounds.left.value * density,
bounds.top.value * density,
bounds.right.value * density,
bounds.bottom.value * density
)
assertTrue("Dialog action must avoid the hinge", !pixels.overlaps(hinge))
if (posture == 2) assertTrue(pixels.bottom <= hinge.top)
compose.onNodeWithText("Public posture fixture").performScrollTo()
}
compose.onNodeWithText("Public posture fixture")
.assertTextContains("Public posture fixture")
}
compose.onNodeWithText("Create commit").performClick()
assertEquals(
HomeActionKind.Commit to "Public posture fixture",
repository.requests.single()
)
}
}
@@ -0,0 +1,192 @@
package de.rfc1437.ironstorage
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import de.rfc1437.ironstorage.core.HomeAction
import de.rfc1437.ironstorage.core.HomeActionKind
import de.rfc1437.ironstorage.core.HomeChange
import de.rfc1437.ironstorage.core.HomeCommit
import de.rfc1437.ironstorage.core.HomeFailure
import de.rfc1437.ironstorage.core.HomeFailureKind
import de.rfc1437.ironstorage.core.HomeFreshness
import de.rfc1437.ironstorage.core.HomeNotice
import de.rfc1437.ironstorage.core.HomePage
import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.HomeRow
import de.rfc1437.ironstorage.core.HomeState
import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.features.HomeScreen
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class HomeScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private fun show(repository: Fixture) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent { MaterialTheme { HomeScreen(repository) } }
}
}
@Test fun typedActionsUseTheConfiguredIdentityAndNeverReplayAfterReload() {
val repository = Fixture()
show(repository)
compose.onNodeWithText("Cached activity").assertIsDisplayed()
compose.onAllNodesWithText("Commit")[0].performScrollTo().performClick()
compose.onNodeWithText("Android fixture").assertIsDisplayed()
compose.onNodeWithText("fixture@example.test").assertIsDisplayed()
compose.onNodeWithText("Commit message").performTextInput("Commit encrypted objects")
compose.onNodeWithText("Create commit").performClick()
assertEquals(
HomeActionKind.Commit to "Commit encrypted objects",
repository.requests.single()
)
compose.onNodeWithText("Cancel").performClick()
for (action in listOf(HomeActionKind.Fetch, HomeActionKind.Pull, HomeActionKind.Push)) {
compose.onNodeWithText(action.name).performScrollTo().performClick()
assertEquals(action, repository.requests.last().first)
compose.onNodeWithText("Cancel").performClick()
compose.onNodeWithText("Reload status").performClick()
}
val before = repository.requests.size
show(repository)
compose.waitForIdle()
assertEquals(before, repository.requests.size)
assertTrue(repository.entries > 0)
}
@Test fun slowOperationsLeaveScrollingAndCancellationResponsiveWithLargeHistory() {
val repository = Fixture(1000)
show(repository)
compose.onNodeWithText("Fetch").performScrollTo().performClick()
compose.onNodeWithText("Receiving remote objects").assertIsDisplayed()
compose.onNodeWithText("Fetch").performScrollTo().assertIsNotEnabled()
compose.onNode(hasScrollAction()).performScrollToNode(hasText("object-999"))
compose.onNodeWithText("object-999").assertIsDisplayed()
compose.onNodeWithText("Cancel").performClick()
compose.onNode(hasScrollAction()).performScrollToNode(hasText("Refresh Interrupted"))
compose.onNodeWithText("Refresh Interrupted").assertIsDisplayed()
assertEquals(1, repository.cancellations)
}
@Test fun swipeRefreshRequestsTheRustRefreshWorkflow() {
val repository = Fixture()
show(repository)
compose.onNode(hasScrollAction()).performTouchInput { swipeDown() }
compose.waitUntil(5000) { repository.refreshes == 1 }
assertTrue(repository.requests.isEmpty())
compose.onNodeWithText("Cancel").performClick()
}
internal class Fixture(changes: Int = 1) : HomeRepository {
private val actions = HomeActionKind.entries.map {
HomeAction(
it,
it.name,
when (it) {
HomeActionKind.Commit -> ShellIcon.Success
HomeActionKind.Fetch -> ShellIcon.Refresh
HomeActionKind.Pull -> ShellIcon.Incoming
HomeActionKind.Push -> ShellIcon.Outgoing
}
)
}
override val state = MutableStateFlow(
HomeState(
page = HomePage(
HomeFreshness.Cached,
1234567890,
listOf(
HomeRow(
"tracked",
"origin/main",
"Tracked HTTPS branch",
ShellIcon.Branch,
actions
)
),
listOf(
HomeCommit(
"incoming-id",
"Incoming encrypted objects",
"Fixture author",
1234567890,
(0 until changes).map {
HomeChange(
"change-$it",
"object-$it",
"Added password entry",
ShellIcon.Key
)
},
emptyList()
)
),
emptyList(),
1u,
0u,
null
),
name = "Android fixture",
email = "fixture@example.test"
)
)
val requests = mutableListOf<Pair<HomeActionKind, String>>()
var cancellations = 0
var entries = 0
var refreshes = 0
override fun enter() {
entries++
}
override fun refresh() {
refreshes++
busy()
}
override fun perform(action: HomeActionKind, message: String) {
requests += action to message
busy()
}
private fun busy() {
state.value = state.value.copy(
busy = true,
failure = null,
progress = HomeNotice("Receiving remote objects", "Slow fixture network")
)
}
override fun reload() {
state.value = state.value.copy(failure = null)
}
override fun cancel() {
cancellations++
state.value = state.value.copy(
busy = false,
progress = null,
failure = HomeFailure(
HomeFailureKind.Interrupted,
"Refresh Interrupted",
"Reload cached activity before retrying."
)
)
}
override fun leave() = Unit
}
}
@@ -0,0 +1,238 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.swipeRight
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.PasswordDestination
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordMutationPlan
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.features.PasswordScreen
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class MutationWorkflowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private fun display(fixture: Fixture) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { PasswordScreen(fixture, fixture.base.authentication) }
}
}
}
@Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() {
val fixture = Fixture()
display(fixture)
val rtl = InstrumentationRegistry.getArguments().getString("hardeningRtl") == "true"
compose.onNodeWithText("Source").performTouchInput {
if (rtl) swipeRight() else swipeLeft()
}
compose.waitUntil(5000) { fixture.state.value.mutation != null }
assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action)
assertEquals(0, fixture.committed)
compose.onNodeWithText("Cancel").performClick()
assertEquals(0, fixture.committed)
compose.onNodeWithContentDescription("Actions for Source").performClick()
compose.onNodeWithText("Delete entry").performClick()
compose.onNodeWithText("Confirm").assertIsNotEnabled()
compose.onNode(
hasText("Unlock") and hasAnyAncestor(hasTestTag("mutation-destinations"))
).performClick()
compose.onNodeWithText("Confirm").performClick()
assertEquals(1, fixture.committed)
assertEquals("entry:source", fixture.lastId)
}
@Test fun overwriteAndDirtyEditorDiscardRequireSeparateConsent() {
val fixture = Fixture(dirty = true, collision = true)
fixture.base.lease.value = AuthenticationState(unlocked = true)
display(fixture)
compose.onNodeWithContentDescription("Actions for Source").performClick()
compose.onNodeWithText("Copy entry").performClick()
compose.onNodeWithTag(
"mutation-destinations"
).performScrollToNode(hasText("Target\nFolder"))
compose.onNodeWithText("Target\nFolder").performClick()
compose.onNodeWithText("Confirm").assertIsNotEnabled()
compose.onNodeWithTag(
"mutation-destinations"
).performScrollToNode(hasText("Discard its open editors and unsaved edits"))
compose.onNodeWithText("Discard its open editors and unsaved edits").performClick()
compose.onNodeWithText("Confirm").assertIsNotEnabled()
compose.onNodeWithTag(
"mutation-destinations"
).performScrollToNode(hasText("Allow replacing the existing entry"))
compose.onNodeWithText("Allow replacing the existing entry").performClick()
compose.onNodeWithText("Confirm").performClick()
assertEquals(1, fixture.committed)
assertTrue(fixture.overwrite)
assertTrue(fixture.discard)
assertEquals("target", fixture.destination)
}
@Test fun foldersHaveOverflowAndTalkBackActionsWithoutGestures() {
val fixture = Fixture()
fixture.base.lease.value = AuthenticationState(unlocked = true)
display(fixture)
val node = compose.onNodeWithText("Group").fetchSemanticsNode()
val actions = node.config[SemanticsActions.CustomActions]
assertTrue(actions.any { it.label == "Move folder Group" })
assertTrue(actions.any { it.label == "Copy folder Group" })
compose.runOnIdle {
assertTrue(actions.single { it.label == "Delete folder Group" }.action())
}
assertEquals("group/", fixture.state.value.mutation!!.source)
assertEquals(0, fixture.committed)
compose.onNodeWithText("Confirm").performClick()
assertEquals(1, fixture.committed)
assertEquals("directory:group", fixture.lastId)
}
@Test fun inspectDisposableMutationWithComputerUse() {
assumeTrue(InstrumentationRegistry.getArguments().getString("mutationInspect") == "true")
val fixture = Fixture(dirty = true, collision = true)
val done = CountDownLatch(1)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
Column(Modifier.fillMaxSize()) {
TextButton(onClick = {
done.countDown()
}) { Text("Finish mutation inspection") }
Text("Confirmed actions: ${fixture.committed}")
PasswordScreen(fixture, fixture.base.authentication, Modifier.weight(1f))
}
}
}
}
compose.waitForIdle()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
}
}
private class Fixture(
private val dirty: Boolean = false,
private val collision: Boolean = false,
val base: EntryViewerTest.Fixture = EntryViewerTest.Fixture()
) : PasswordRepository by base {
override val state = base.state
var committed by mutableStateOf(0)
var lastId: String? = null
var destination: String? = null
var overwrite = false
var discard = false
init {
state.value = state.value.copy(
selected = null,
rows = listOf(
PasswordRow(
"entry:source",
"source",
"Source",
"Locked entry",
PasswordKind.Entry,
"entry-r",
ShellIcon.Key
),
PasswordRow(
"directory:group",
"group",
"Group",
"Folder",
PasswordKind.Folder,
"folder-r",
ShellIcon.Folder
)
)
)
}
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
lastId = id
val folder = id == "directory:group"
state.value = state.value.copy(
mutation = PasswordMutationPlan(
action, if (folder) "group/" else "source", "plan-r",
listOf(
PasswordDestination(
"target",
"Target",
"Folder",
collision,
collisionTargets = if (collision) {
listOf(
"target/source"
)
} else {
emptyList()
}
)
),
dirty, dirty, if (folder) PasswordKind.Folder else PasswordKind.Entry,
if (folder) "Group" else "Source", if (folder) 2u else 1u
)
)
}
override fun prepareMutation(action: PasswordMutation) {
state.value.selected?.let { prepareRowMutation(it.id, it.revision, action) }
}
override fun unlockMutation(passphrase: String) {
base.lease.value =
AuthenticationState(unlocked = true)
}
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) {
this.destination = destination
this.overwrite = overwrite
discard = discardEditor
committed += 1
dismissMutation()
}
override fun dismissMutation() {
state.value = state.value.copy(mutation = null)
}
override fun inspect(id: String, revision: String) {
state.value =
state.value.copy(selected = state.value.rows.single { it.id == id })
}
override fun activate(id: String, revision: String) = inspect(id, revision)
}
}
@@ -0,0 +1,156 @@
package de.rfc1437.ironstorage
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.ViewModelProvider
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.SetupKey
import de.rfc1437.ironstorage.core.SetupStatus
import de.rfc1437.ironstorage.features.OnboardingScreen
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class OnboardingScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test fun localAndRemoteFormsUseTypedFacadeAndClearSubmittedSecrets() {
val repository = FixtureRepository()
val secrets = SetupFormViewModel()
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
OnboardingScreen(
repository,
secrets,
SettingsScreenTest.Fixture(),
androidx.lifecycle.ViewModelProvider(
compose.activity
)[de.rfc1437.ironstorage.features.ShellViewModel::class.java].authentication
)
}
}
}
compose.waitForIdle()
compose.onNodeWithText("Create local store").performClick()
compose.onNodeWithText("Generate GPG key").performScrollTo().performClick()
compose.onNodeWithText(
"Key name and email"
).performTextInput("Fixture <fixture@example.test>")
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
compose.onNodeWithText("Generate GPG key").performScrollTo().performClick()
compose.onNodeWithText("Create encrypted local store").performScrollTo().performClick()
compose.onNodeWithText("Add HTTPS synchronization").assertIsDisplayed().performClick()
compose.onNodeWithText("HTTPS server URL").performTextInput("https://example.test")
compose.onNodeWithText("Account").performTextInput("alice")
compose.onNodeWithText(
"Repository path (owner/repository)"
).performTextInput("team/passwords")
compose.onNodeWithText(
"Application token"
).performScrollTo().performTextInput("public-test-token")
compose.onNodeWithText("Connect local store").performScrollTo().performClick()
assertEquals(listOf("generate", "local", "connect"), repository.calls)
assertTrue(
secrets.passphrase.isEmpty() && secrets.token.isEmpty() && secrets.armor.isEmpty()
)
}
@Test fun closingQrReviewReloadsPreparedOnboardingKeysWithoutCreatingAStore() {
val original = FixtureRepository()
val settings = SettingsScreenTest.Fixture()
val secrets = SetupFormViewModel()
var staged = false
val repository = object : OnboardingRepository by original {
override fun refresh() {
if (staged) {
original.setup.value = original.setup.value.copy(
keys = listOf(
SetupKey("SCANNED-SETUP-KEY", listOf("Scanned setup fixture"))
),
selectedKey = "SCANNED-SETUP-KEY"
)
}
}
}
val model = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val authentication = model.authentication
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
OnboardingScreen(repository, secrets, settings, authentication)
}
}
}
compose.onNodeWithText("Create local store").performClick()
compose.onNodeWithText("Import private GPG key").performScrollTo().performClick()
compose.onNodeWithText("Scan GPG QR codes").performScrollTo().performClick()
compose.runOnIdle { staged = true }
compose.onNodeWithText("Done").performClick()
compose.runOnIdle { assertEquals("SCANNED-SETUP-KEY", original.setup.value.selectedKey) }
compose.onNodeWithText("Back to setup choices").performScrollTo().performClick()
compose.onNodeWithText("Create local store").performClick()
compose.onNodeWithText("SCANNED-SETUP-KEY").performScrollTo().assertIsDisplayed()
assertTrue(!original.setup.value.configured)
assertTrue(original.calls.isEmpty())
}
private class FixtureRepository : OnboardingRepository {
override val setup = MutableStateFlow(SetupStatus())
val calls = mutableListOf<String>()
override fun refresh() {
setup.value = setup.value.copy(branches = emptyList())
}
override fun generate(userId: String, passphrase: String) {
assertEquals("Fixture <fixture@example.test>", userId)
assertEquals("public-test-passphrase", passphrase)
calls.add("generate")
setup.value =
setup.value.copy(
keys = listOf(SetupKey("fixture-key", listOf(userId))),
selectedKey = "fixture-key"
)
}
override fun createLocal(fingerprint: String, passphrase: String) {
assertEquals("fixture-key", fingerprint)
assertEquals("public-test-passphrase", passphrase)
calls.add("local")
setup.value = setup.value.copy(configured = true)
}
override fun connect(server: String, account: String, repository: String, token: String) {
assertEquals("https://example.test", server)
assertEquals("alice", account)
assertEquals("team/passwords", repository)
assertEquals("public-test-token", token)
calls.add("connect")
setup.value = setup.value.copy(connected = true)
}
override fun importKey(armor: String, passphrase: String) = error("Unexpected import")
override fun discover(server: String, account: String, repository: String, token: String) =
error("Unexpected discovery")
override fun cloneStore(
branch: String,
fingerprint: String,
passphrase: String,
useExisting: Boolean
) = error("Unexpected clone")
override fun replaceToken(account: String, token: String) =
error("Unexpected token replacement")
override fun cancel() = Unit
}
}
@@ -0,0 +1,299 @@
package de.rfc1437.ironstorage
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performCustomAccessibilityActionWithLabel
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.unit.Density
import de.rfc1437.ironstorage.core.AuthenticationFailure
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.PasswordField
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.PasswordSection
import de.rfc1437.ironstorage.core.PasswordState
import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.features.PasswordScreen
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class PasswordScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test fun foldersActionsDetailAndStaleRefreshUseOnlyTypedIdentities() {
compose.activity.runOnUiThread {
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
compose.waitUntil(10000) {
compose.activity.resources.configuration.orientation ==
Configuration.ORIENTATION_PORTRAIT
}
val repository = Fixture()
lateinit var inputMode: InputModeManager
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
inputMode = LocalInputModeManager.current
MaterialTheme { PasswordScreen(repository, repository.authentication) }
}
}
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
compose.onNodeWithText("仕事").performSemanticsAction(SemanticsActions.RequestFocus) {
assertTrue(it())
}
compose.onNodeWithText("仕事").performKeyInput {
keyDown(Key.Enter)
keyUp(Key.Enter)
}
compose.waitUntil(5000) { repository.lastId == "folder-id" }
assertEquals("folder-id", repository.lastId)
compose.onNodeWithText("Back").performClick()
compose.onNodeWithText("仕事").performSemanticsAction(SemanticsActions.RequestFocus) {
assertTrue(it())
}
compose.onNodeWithText("仕事").performKeyInput {
keyDown(Key.DirectionCenter)
keyUp(Key.DirectionCenter)
}
compose.waitUntil(5000) { repository.state.value.path == "仕事" }
assertEquals("folder-id", repository.lastId)
compose.onNodeWithText("Long Unicode 密碼")
.performCustomAccessibilityActionWithLabel("Actions for Long Unicode 密碼")
compose.onNodeWithText("Close").performClick()
compose.onNodeWithText("Back").performClick()
compose.onAllNodesWithText("Actions")[0].performClick()
compose.onNodeWithText("Open").performClick()
compose.onNodeWithText("Unlock").assertIsDisplayed()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithText("GPG passphrase").performTextInput("incorrect")
compose.onNodeWithText("Continue").performClick()
compose.onNodeWithText("Incorrect passphrase. Retry unlocking.").assertIsDisplayed()
compose.onNodeWithText("Use GPG passphrase").performClick()
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
compose.onNodeWithText("Continue").performClick()
compose.onNodeWithText("Fixture account").assertIsDisplayed()
// Revocation must mask even a page result that has not been cleared yet.
compose.runOnIdle { repository.lease.value = AuthenticationState() }
compose.onNodeWithText("Fixture account").assertDoesNotExist()
compose.onNodeWithText("Unlock").assertIsDisplayed()
compose.runOnIdle { repository.lease.value = AuthenticationState(unlocked = true) }
compose.onNodeWithText("Lock").performClick()
compose.onNodeWithText("Unlock").assertIsDisplayed()
repository.replaceOnRefresh = true
compose.onNodeWithText("Refresh").performClick()
compose.onNodeWithText(
"The repository changed. Stale selections were cleared."
).assertIsDisplayed()
assertTrue(repository.state.value.selected == null)
compose.onNodeWithText("Back").performClick()
compose.onNodeWithText("仕事").assertIsDisplayed()
}
@Test fun resumingDoesNotReplaceAPendingSearchSelection() {
val repository = Fixture()
repository.state.value = repository.state.value.copy(loading = true)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { PasswordScreen(repository, repository.authentication) }
}
}
compose.waitForIdle()
assertEquals(0, repository.refreshes)
}
@Test fun expandedPanesAndLargeFontsKeepTheSelectedEntryAndControlsUsable() {
compose.activity.runOnUiThread {
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
compose.waitUntil(10000) {
compose.activity.resources.configuration.orientation ==
Configuration.ORIENTATION_LANDSCAPE
}
val repository = Fixture()
repository.activate("folder-id", "root")
repository.activate("entry-id", "folder")
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
MaterialTheme { PasswordScreen(repository, repository.authentication) }
}
}
}
compose.onNodeWithText("Unlock").performScrollTo().assertIsDisplayed()
assertEquals("entry-id", repository.state.value.selected?.id)
compose.onNodeWithText("Back").performClick()
compose.onNodeWithText("Long Unicode 密碼").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Back").performClick()
compose.onNodeWithText("仕事").performScrollTo().assertIsDisplayed()
}
private class Fixture : PasswordRepository {
private val folder =
PasswordRow(
"folder-id",
"仕事",
"仕事",
"1 entry",
PasswordKind.Folder,
"folder-revision",
ShellIcon.Folder
)
private val entry =
PasswordRow(
"entry-id",
"仕事/Long Unicode 密碼",
"Long Unicode 密碼",
"Locked password",
PasswordKind.Entry,
"entry-revision",
ShellIcon.Key
)
override val state =
MutableStateFlow(PasswordState(revision = "root", rows = listOf(folder)))
val lease = MutableStateFlow(AuthenticationState())
val authentication = object : AuthenticationRepository {
override val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
override fun lifecycle(event: de.rfc1437.ironstorage.core.AuthenticationLifecycle) {
if (event !=
de.rfc1437.ironstorage.core.AuthenticationLifecycle.Foreground
) {
lock()
}
}
override val state = lease
override fun setAppearance(appearance: de.rfc1437.ironstorage.core.Appearance) {
state.value = state.value.copy(appearance = appearance)
}
override fun reload() = Unit
override fun unlock() = error("Unexpected direct unlock")
override fun enroll(passphrase: String) = error("Unexpected enrollment")
override fun disable() = error("Unexpected disable")
override fun lock() {
state.value = state.value.copy(unlocked = false)
}
override fun cancel() = lock()
override fun touch() = Unit
}
var lastId: String? = null
var replaceOnRefresh = false
var refreshes = 0
override fun reveal(id: ULong) = Unit
override fun hide(id: ULong) = Unit
override fun copy(id: ULong) = Unit
override fun copyOtp() = Unit
override fun refreshOtp() = Unit
override fun advanceHotp() = Unit
override fun edit(id: ULong) = Unit
override fun dismissEdit() = Unit
override fun saveEdit(value: String) = Unit
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
inspect(id, revision)
prepareMutation(action)
}
override fun unlockMutation(passphrase: String) = unlock(passphrase)
override fun prepareMutation(action: de.rfc1437.ironstorage.core.PasswordMutation) = Unit
override fun dismissMutation() = Unit
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) = Unit
override fun openSearch(id: String, revision: String) {
error("Search is not used by this fixture")
}
override fun restore(path: String, selected: String?, revision: String?) = Unit
override fun refresh() {
refreshes++
if (!replaceOnRefresh) return
state.value =
state.value.copy(selected = null, sections = null, stale = true)
}
override fun activate(id: String, revision: String) {
assertEquals(state.value.revision, revision)
lastId = id
state.value =
if (id ==
folder.id
) {
PasswordState(
path = folder.path,
title = folder.title,
parent = "",
revision = "folder",
rows = listOf(entry)
)
} else {
state.value.copy(selected = entry)
}
}
override fun inspect(id: String, revision: String) {
assertEquals(entry.id, id)
assertEquals(state.value.revision, revision)
state.value = state.value.copy(selected = entry)
}
override fun back() {
state.value = if (state.value.selected != null) {
state.value.copy(selected = null, sections = null)
} else {
PasswordState(revision = "root", rows = listOf(folder))
}
}
override fun unlock(passphrase: String) {
if (passphrase.isEmpty()) {
lease.value = AuthenticationState(
failure = AuthenticationFailure(
AuthenticationFailureKind.PassphraseRequired,
"Passphrase Required",
"Enter the passphrase."
)
)
return
}
if (passphrase != "public-test-passphrase") {
state.value = state.value.copy(error = "Incorrect passphrase. Retry unlocking.")
return
}
lease.value = AuthenticationState(unlocked = true)
assertEquals("public-test-passphrase", passphrase)
state.value =
state.value.copy(
error = null,
sections = listOf(
PasswordSection(
"Fields",
listOf(PasswordField(1u, "Username", "Fixture account", null))
)
)
)
}
override fun lock() {
state.value = state.value.copy(sections = null)
}
}
}
@@ -0,0 +1,335 @@
package de.rfc1437.ironstorage
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ComponentCallbacks2
import android.os.Build
import android.os.Bundle
import android.os.Parcel
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.features.ProtectedContent
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.awaitCancellation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class PresentationSecurityTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test fun leavingProtectedDestinationsRevealsTheCleanForeground() {
val viewModel = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
compose.waitUntil(10000) {
!viewModel.onboarding.setup.value.busy &&
viewModel.snapshot.value.state != ShellState.Loading
}
assumeTrue(
"Requires a store created through the retained emulator's normal setup UI",
viewModel.onboarding.setup.value.configured ||
viewModel.snapshot.value.state in listOf(ShellState.Ready, ShellState.Locked)
)
for (tab in listOf("Home", "Passwords", "Totp", "Home", "Passwords", "Preferences")) {
compose.onNodeWithTag("shell-tab-$tab").performClick()
if (tab == "Passwords") compose.onNodeWithText("New entry").assertIsDisplayed()
compose.waitForIdle()
compose.runOnIdle {
assertTrue(compose.activity.window.decorView.hasWindowFocus())
assertFalse(
"$tab must not remain behind the protective cover",
compose.activity.window.decorView.importantForAccessibility ==
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
)
}
}
}
@Test fun stoppedProtectedDestinationsStayCoveredUntilResumed() {
val viewModel = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
compose.waitUntil(10000) {
!viewModel.onboarding.setup.value.busy &&
viewModel.snapshot.value.state != ShellState.Loading
}
assumeTrue(
"Requires a store created through the retained emulator's normal setup UI",
viewModel.onboarding.setup.value.configured ||
viewModel.snapshot.value.state in listOf(ShellState.Ready, ShellState.Locked)
)
for (tab in listOf("Passwords", "Totp")) {
compose.onNodeWithTag("shell-tab-$tab").performClick()
if (tab == "Passwords") compose.onNodeWithText("New entry").assertIsDisplayed()
val presentation = compose.activity.authentication.presentation
val generation = presentation.generation.value
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.onActivity {
assertTrue(presentation.generation.value > generation)
assertEquals(
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
it.window.decorView.importantForAccessibility
)
}
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.waitForIdle()
compose.runOnIdle {
assertTrue(compose.activity.window.decorView.hasWindowFocus())
assertFalse(
"$tab must reveal its cleaned surface after resume",
compose.activity.window.decorView.importantForAccessibility ==
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
)
}
}
}
@Test fun foregroundCleanupUnmasksOnlyAfterTheCleanGenerationIsComposed() {
val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
val dirty = mutableStateOf(true)
val registration = presentation.register { dirty.value = false }
try {
compose.runOnIdle {
compose.activity.setProtectedTestContent(presentation) {
MaterialTheme {
Text(
if (dirty.value) "Disposable protected fixture" else "Clean foreground"
)
}
}
}
compose.onNodeWithText("Disposable protected fixture").assertIsDisplayed()
compose.runOnIdle {
presentation.clear()
assertEquals(
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
compose.activity.window.decorView.importantForAccessibility
)
assertFalse(dirty.value)
}
compose.onNodeWithText("Clean foreground").assertIsDisplayed()
compose.onNodeWithText("Disposable protected fixture").assertDoesNotExist()
compose.runOnIdle {
assertFalse(
"The rebuilt foreground surface must be visible to accessibility",
compose.activity.window.decorView.importantForAccessibility ==
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
)
}
} finally {
registration.close()
}
}
@Test fun inspectTaskSwitchingWithDisposableProtectedInput() {
assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true")
val done = CountDownLatch(1)
lateinit var form: SetupFormViewModel
compose.runOnIdle {
form = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
form.passphrase = "public-task-switch-marker-100"
compose.activity.setProtectedTestContent {
MaterialTheme {
Column {
Text("Task switching security fixture")
OutlinedTextField(
form.passphrase,
{ form.passphrase = it },
label = { Text("Disposable protected input") }
)
if (form.passphrase.isEmpty()) Text("Protected value cleared")
TextButton(onClick = {
done.countDown()
}) { Text("Finish security inspection") }
}
}
}
}
val started = android.os.SystemClock.elapsedRealtime()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000)
}
compose.runOnIdle {
assertEquals("", form.passphrase)
assertFalse(
compose.activity.window.decorView.importantForAccessibility ==
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
)
}
}
@Test fun inputSessionsRequestNoLearningAndNoKeyboardExtractWindow() {
val attributes = AtomicReference<EditorInfo?>()
compose.runOnIdle {
compose.activity.setContent {
InterceptPlatformTextInput(interceptor = { request, _ ->
val info = EditorInfo()
val connection = request.createInputConnection(info)
attributes.set(info)
try {
awaitCancellation()
} finally {
connection.closeConnection()
}
}) {
ProtectedContent(compose.activity.authentication.presentation) {
MaterialTheme {
OutlinedTextField("", {}, label = { Text("Public input") })
}
}
}
}
}
compose.onNode(hasSetTextAction()).performClick()
compose.waitUntil(10000) { attributes.get() != null }
val flags = attributes.get()!!.imeOptions
assertTrue(flags and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING != 0)
assertTrue(flags and EditorInfo.IME_FLAG_NO_FULLSCREEN != 0)
assertTrue(flags and EditorInfo.IME_FLAG_NO_EXTRACT_UI != 0)
}
@Test fun clipboardOwnershipPreservesIdenticalNewerCopiesAndUserContent() {
compose.runOnIdle {
val manager = compose.activity.getSystemService(ClipboardManager::class.java)
val first = AndroidSecretClipboard(compose.activity).beginCopy()
val second = AndroidSecretClipboard(compose.activity).beginCopy()
first.write("public-same-value".toByteArray())
second.write("public-same-value".toByteArray())
assertNull(first.read())
first.clear()
assertEquals("public-same-value", second.read()!!.toString(Charsets.UTF_8))
manager.setPrimaryClip(ClipData.newPlainText("User", "public-same-value"))
second.clear()
assertEquals("public-same-value", manager.primaryClip!!.getItemAt(0).text.toString())
assertNull(second.read())
first.write("public-restored-value".toByteArray())
val restored = ClipData(manager.primaryClip!!)
android.os.SystemClock.sleep(2)
manager.setPrimaryClip(restored)
assertNull(first.read())
first.clear()
assertEquals(
"public-restored-value",
manager.primaryClip!!.getItemAt(0).text.toString()
)
second.write("owned public value".toByteArray())
second.clear()
assertTrue(
manager.primaryClip == null ||
manager.primaryClip!!.getItemAt(0).text.isNullOrEmpty()
)
}
}
@Suppress("DEPRECATION")
@Test
fun memoryPressureMasksImmediatelyAndRecreationNeverSavesSetupSecrets() {
lateinit var form: SetupFormViewModel
compose.runOnIdle {
form = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
compose.activity.setProtectedTestContent {
MaterialTheme { Text("Public security fixture") }
}
}
compose.onNodeWithText("Public security fixture").assertIsDisplayed()
fun populate() {
form.passphrase = "public-passphrase-marker-100"
form.armor = "public-armor-marker-100"
form.token = "public-token-marker-100"
}
fun assertCleared() {
assertEquals("", form.passphrase)
assertEquals("", form.armor)
assertEquals("", form.token)
}
compose.runOnIdle {
populate()
compose.activity.onWindowFocusChanged(false)
assertCleared()
compose.activity.onWindowFocusChanged(true)
}
compose.runOnIdle {
val window = compose.activity.window
populate()
(compose.activity.application as IronStorageApplication).onTrimMemory(
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW
)
assertCleared()
assertEquals(
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
window.decorView.importantForAccessibility
)
assertTrue(
window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE !=
0
)
assertEquals(
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS,
window.decorView.importantForAutofill
)
if (Build.VERSION.SDK_INT >= 30) {
assertEquals(
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS,
window.decorView.importantForContentCapture
)
}
}
compose.onNodeWithText("Public security fixture").assertIsDisplayed()
compose.runOnIdle {
populate()
val saved = Bundle()
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
compose.activity,
saved
)
val parcel = Parcel.obtain()
try {
parcel.writeBundle(saved)
val bytes = parcel.marshall()
for (marker in listOf(form.passphrase, form.armor, form.token)) {
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
}
} finally {
parcel.recycle()
}
}
compose.activityRule.scenario.recreate()
compose.runOnIdle { assertCleared() }
}
}
@@ -0,0 +1,26 @@
package de.rfc1437.ironstorage
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.ProtectedContent
/** Keep the production surface and IME protections around substituted test screens. */
internal fun MainActivity.setProtectedTestContent(
presentation: ProtectedPresentation = authentication.presentation,
content: @Composable () -> Unit
) {
val rtl = InstrumentationRegistry.getArguments().getString("hardeningRtl") == "true"
setContent {
CompositionLocalProvider(
LocalLayoutDirection provides
if (rtl) LayoutDirection.Rtl else LocalLayoutDirection.current
) {
ProtectedContent(presentation, content)
}
}
}
@@ -0,0 +1,51 @@
package de.rfc1437.ironstorage
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.EntryPoints
import de.rfc1437.ironstorage.core.CredentialCaller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assume.assumeTrue
import org.junit.Test
class ProviderFixtureSetupTest {
/** Opt-in only. Never replaces a configured store or key on the retained emulator. */
@Test fun seedEmptyEmulatorForComputerUse(): Unit = runBlocking {
assumeTrue(InstrumentationRegistry.getArguments().getString("providerFixture") == "true")
val context = InstrumentationRegistry.getInstrumentation().targetContext
val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java)
val passphrase = "public-provider-fixture-passphrase"
val setup = access.onboarding()
withContext(Dispatchers.Main.immediate) { setup.refresh() }
val before = withTimeout(30000) { setup.setup.first { !it.busy } }
check(!before.configured && !before.existingClone && before.keys.isEmpty())
withContext(Dispatchers.Main.immediate) {
setup.generate("Provider emulator fixture", passphrase)
}
val generated = withTimeout(30000) {
setup.setup.first {
!it.busy &&
it.selectedKey.isNotEmpty()
}
}
withContext(Dispatchers.Main.immediate) {
setup.createLocal(generated.selectedKey, passphrase)
}
withTimeout(30000) { setup.setup.first { !it.busy && it.configured } }
// Exercise exactly the explicit storage save contract used by both OS services.
val credentials = access.credentials()
val caller = CredentialCaller("de.rfc1437.ironstorage.test", listOf("AA".repeat(32)), null)
val request = credentials.begin(caller)
credentials.stage(
request.token,
caller,
"fixture-alice",
"public-provider-fixture-password"
)
credentials.save(request.token, caller, "provider.example", true, false, passphrase)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
}
}
@@ -0,0 +1,108 @@
package de.rfc1437.ironstorage
import android.content.ComponentCallbacks2
import android.content.Intent
import android.os.Build
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.EntryPoints
import de.rfc1437.ironstorage.core.CredentialCaller
import de.rfc1437.ironstorage.providers.ProviderContext
import de.rfc1437.ironstorage.providers.ProviderRequests
import de.rfc1437.ironstorage.providers.installedCaller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
class ProviderIntentSecurityTest {
@Test
fun retainedSystemSaveContainsSubmittedFields(): Unit = runBlocking {
assumeTrue(
InstrumentationRegistry.getArguments().getString("providerSavedFixtureValidation") ==
"true"
)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val caller = context.installedCaller("de.rfc1437.ironstorage.providerclient")
val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
for (path in listOf("sacrd", "afsave")) {
val request = access.credentials().begin(caller)
try {
val value = access.credentials().fill(
request.token,
caller,
path,
true,
"public-provider-fixture-passphrase",
emptyList()
)
assertEquals("fixture-bob", value.username)
assertEquals("public-provider-new-password", value.password)
} finally {
access.credentials().cancel(request.token)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
}
}
}
@Suppress("DEPRECATION")
@Test
fun retainedFixtureCallerCanPrepareMetadata(): Unit = runBlocking {
assumeTrue(
InstrumentationRegistry.getArguments().getString("providerFixtureValidation") == "true"
)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val caller = context.installedCaller("de.rfc1437.ironstorage.providerclient")
val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
val request = access.credentials().begin(caller)
try {
assertTrue(request.candidates.any { it.path == "provider.example" })
withContext(Dispatchers.Main.immediate) {
val app = context.applicationContext as IronStorageApplication
app.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN)
app.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND)
}
assertTrue(access.credentials().request(request.token, caller).candidates.isNotEmpty())
withContext(Dispatchers.Main.immediate) {
(context.applicationContext as IronStorageApplication).onTrimMemory(
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW
)
}
assertTrue(
runCatching {
access.credentials().request(request.token, caller)
}.isFailure
)
} finally {
access.credentials().cancel(request.token)
}
}
@Test fun inlineAttributionCannotInvokeThePrivateAuthenticationActivity() {
assumeTrue(Build.VERSION.SDK_INT >= 31)
val context = InstrumentationRegistry.getInstrumentation().targetContext
assertNull(ProviderRequests.get(Intent()))
val token = "public-pending-intent-fixture"
val caller = CredentialCaller("example.client", listOf("AA".repeat(32)), null)
try {
val authentication = ProviderRequests.put(
context,
ProviderContext(token, caller, "autofill"),
"fixture"
)
val attribution = ProviderRequests.attribution(context)
assertTrue(attribution.isImmutable)
assertFalse(authentication.isImmutable)
assertNotEquals(authentication, attribution)
} finally {
ProviderRequests.remove(token)
}
}
}
@@ -0,0 +1,205 @@
package de.rfc1437.ironstorage
import android.content.pm.ActivityInfo
import android.os.Bundle
import android.os.Parcel
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.ViewModelProvider
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.SearchRepository
import de.rfc1437.ironstorage.core.SearchState
import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.features.SearchScreen
import de.rfc1437.ironstorage.features.ShellViewModel
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class SearchScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test fun inspectSearchWithComputerUse() {
assumeTrue(InstrumentationRegistry.getArguments().getString("searchInspect") == "true")
val repository = Fixture()
val done = CountDownLatch(1)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
Column {
Text("Public name search fixture")
TextButton(onClick = {
repository.complete()
}) { Text("Load public matches") }
TextButton(onClick = {
done.countDown()
}) { Text("Finish search inspection") }
SearchScreen(repository, { opened ->
assertEquals("entry:仕事/銀行/login", opened.id)
}, androidx.compose.ui.Modifier.weight(1f))
}
}
}
}
val start = android.os.SystemClock.elapsedRealtime()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
org.junit.Assert.assertTrue(android.os.SystemClock.elapsedRealtime() - start < 600000)
}
}
@Test fun queryKeyboardActionsCanonicalLocationsAndRecoveryRemainAccessible() {
compose.activity.runOnUiThread {
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
compose.waitUntil(10000) {
compose.activity.resources.configuration.orientation ==
android.content.res.Configuration.ORIENTATION_PORTRAIT
}
val repository = Fixture()
var opened: PasswordRow? = null
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { SearchScreen(repository, { opened = it }) }
}
}
compose.onNodeWithText("Enter an entry name, folder, or path.").assertIsDisplayed()
compose.onNodeWithText("Entry name or path").performTextInput("login")
compose.onNodeWithText("Searching names…").assertIsDisplayed()
compose.onNodeWithText("Cancel search").performClick()
assertEquals("login", repository.state.value.query)
compose.onNodeWithText("Search cancelled. Refresh to try again.").assertIsDisplayed()
compose.onNodeWithText("Entry name or path").performImeAction()
compose.runOnIdle { repository.complete() }
compose.onNodeWithText("仕事/銀行").assertIsDisplayed()
compose.onNodeWithText("仕事/銀行").performSemanticsAction(SemanticsActions.OnClick) { it() }
assertEquals("entry:仕事/銀行/login", opened?.id)
assertEquals("public-revision", opened?.revision)
compose.runOnIdle { repository.state.value = SearchState("none") }
compose.onNodeWithText("No matching entries.").assertIsDisplayed()
compose.runOnIdle {
repository.state.value =
SearchState("none", error = "Public typed error")
}
compose.onNodeWithText("Public typed error").assertIsDisplayed()
compose.onNodeWithText("Refresh results").performClick()
compose.onNodeWithText("Searching names…").assertIsDisplayed()
compose.onNodeWithText("Clear search").performClick()
compose.onNodeWithText("Enter an entry name, folder, or path.").assertIsDisplayed()
}
@Test fun nativeQuerySurvivesRotationWithoutEnteringSavedStateAndClearsOnManualLock() {
val marker = "public-name-query-marker-101"
val repository = ViewModelProvider(compose.activity)[ShellViewModel::class.java].search
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { SearchScreen(repository, {}) }
}
}
compose.onNodeWithText("Entry name or path").performTextInput(marker)
compose.runOnIdle {
val saved = Bundle()
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
compose.activity,
saved
)
val parcel = Parcel.obtain()
try {
parcel.writeBundle(saved)
val bytes = parcel.marshall()
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
} finally {
parcel.recycle()
}
}
compose.activityRule.scenario.recreate()
compose.runOnIdle {
val current = ViewModelProvider(compose.activity)[ShellViewModel::class.java].search
assertEquals(marker, current.state.value.query)
compose.activity.authentication.lock()
assertEquals("", current.state.value.query)
assertEquals(emptyList<PasswordRow>(), current.state.value.results)
}
}
@Test fun expandedWindowKeepsQueryAndCanonicalResultUsable() {
val repository = Fixture()
repository.complete()
compose.activity.runOnUiThread {
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
compose.waitUntil(10000) {
compose.activity.resources.configuration.orientation ==
android.content.res.Configuration.ORIENTATION_LANDSCAPE
}
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { SearchScreen(repository, {}) }
}
}
compose.runOnIdle { repository.complete() }
compose.onNodeWithText("Entry name or path").assertIsDisplayed()
compose.onNodeWithText("仕事/銀行").assertIsDisplayed()
compose.onNodeWithText("Clear search").assertIsDisplayed()
}
private class Fixture : SearchRepository {
override val state = MutableStateFlow(SearchState())
override fun query(value: String) {
state.value = SearchState(value, loading = true)
}
override fun refresh() {
if (state.value.query.isNotBlank()) {
state.value =
SearchState(state.value.query, loading = true)
}
}
override fun cancel() {
state.value = state.value.copy(loading = false, cancelled = state.value.loading)
}
override fun clear() {
state.value = SearchState()
}
fun complete() {
state.value =
SearchState(
"login",
results = listOf(
PasswordRow(
"entry:仕事/銀行/login",
"仕事/銀行/login",
"login",
"仕事/銀行",
PasswordKind.Entry,
"public-revision",
ShellIcon.Key
)
)
)
}
}
}
@@ -0,0 +1,384 @@
package de.rfc1437.ironstorage
import android.os.Bundle
import android.os.Parcel
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.CameraPreview
import de.rfc1437.ironstorage.core.KeyTransferState
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.core.ProviderStatus
import de.rfc1437.ironstorage.core.SettingsPage
import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.SettingsState
import de.rfc1437.ironstorage.core.TransferFrame
import de.rfc1437.ironstorage.core.TransferKey
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.R as FeatureR
import de.rfc1437.ironstorage.features.SettingsScreen
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class SettingsScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test fun publicDocumentsRetainTheRealActivityFocusOwnership() {
val authentication = ViewModelProvider(
compose.activity
)[ShellViewModel::class.java].authentication
compose.onNodeWithTag("shell-tab-Preferences").performClick()
compose.onNodeWithTag("settings-search").performTextReplacement("privacy")
for ((button, content) in listOf(
"Privacy policy" to "IronStorage for Android",
"Third-party notices" to "IronStorage — Third-party notices"
)) {
val generation = authentication.presentation.generation.value
compose.onNodeWithTag("settings-list").performScrollToNode(hasText(button))
compose.onNodeWithText(button).performClick()
compose.waitUntil(5000) {
compose.onAllNodes(
hasText(content, substring = true)
).fetchSemanticsNodes().isNotEmpty()
}
compose.runOnIdle {
assertTrue(authentication.presentation.hasOwnedWindow)
assertEquals(generation, authentication.presentation.generation.value)
}
compose.onNodeWithText("Done").performClick()
compose.runOnIdle { assertFalse(authentication.presentation.hasOwnedWindow) }
compose.onNodeWithTag("settings-search").assertTextContains("privacy")
}
compose.onNodeWithTag("settings-list").performScrollToNode(hasText("Privacy policy"))
compose.onNodeWithText("Privacy policy").performClick()
val generation = authentication.presentation.generation.value
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.runOnIdle {
assertTrue(authentication.presentation.generation.value > generation)
assertFalse(authentication.presentation.hasOwnedWindow)
}
compose.onNodeWithTag("settings-search").assert(hasText("privacy").not())
}
@Test fun searchableSettingsKeepEditsAndUseExplicitSecretAndProviderActionsAtLargeFont() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val repository = Fixture()
val authentication = object : AuthenticationRepository by original.authentication {
override val state = MutableStateFlow(AuthenticationState())
override val presentation = ProtectedPresentation()
override fun reload() = Unit
override fun lock() {
state.value = AuthenticationState()
}
}
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent(authentication.presentation) {
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
ApplicationTheme(authentication) {
SettingsScreen(repository, authentication, original.onboarding, setup) {}
}
}
}
}
fun search(text: String) {
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(androidx.compose.ui.test.hasTestTag("settings-search"))
compose.onNodeWithTag("settings-search").performTextReplacement(text)
}
fun button(text: String) {
compose.onNodeWithTag("settings-list").performScrollToNode(hasText(text))
compose.onNodeWithText(text).assertIsDisplayed().performClick()
}
search("Git")
button("Edit Git author")
compose.onNodeWithTag("settings-git-name").performTextReplacement("New public author")
compose.onNodeWithTag("settings-git-email").performTextReplacement("public@example.test")
compose.onNodeWithText("Save").performClick()
assertEquals(listOf("New public author", "public@example.test"), repository.identity)
compose.runOnIdle {
repository.state.value =
repository.state.value.copy(error = "Typed validation failure")
}
compose.onNodeWithTag(
"settings-dialog-error"
).performScrollTo().assertTextContains("Typed validation failure").assertIsDisplayed()
compose.onNodeWithTag("settings-git-name").assertTextContains("New public author")
compose.onNodeWithText("Cancel").performClick()
search("timeout")
button("Edit inactivity timeout")
compose.onNodeWithTag("settings-timeout").performTextReplacement("invalid candidate")
compose.onNodeWithText("Save").performClick()
assertEquals("invalid candidate", repository.timeoutInput)
compose.onNodeWithText("Cancel").performClick()
search("token")
button("Remove application token")
assertEquals(0, repository.removals)
compose.onNodeWithText("Save").performClick()
assertEquals(1, repository.removals)
compose.onNodeWithText("Cancel").performClick()
search("Autofill")
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(
hasText("The IronStorage provider service is not included in this build")
)
compose.onAllNodes(
hasText("The IronStorage provider service is not included in this build")
)[0].assertIsDisplayed()
assertFalse(repository.state.value.page!!.autofill.enabled)
search("privacy")
button("Privacy policy")
compose.waitUntil(5000) {
compose.onAllNodes(
hasText("IronStorage for Android", substring = true)
).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("IronStorage for Android", substring = true)
.assertTextContains(
"Android cloud backup and device-transfer backup are disabled",
substring = true
)
compose.onNodeWithText("Done").performClick()
button("MIT license")
compose.onNodeWithText("Done").assertIsDisplayed().performClick()
button("Third-party notices")
compose.waitUntil(5000) {
compose.onAllNodes(
hasText("IronStorage — Third-party notices")
).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Android release libraries").assertIsDisplayed()
compose.onNodeWithText("Done").performClick()
assertTrue(repository.reloads > 0)
search("GPG")
button("Import or export GPG QR codes")
compose.runOnIdle {
repository.state.value = repository.state.value.copy(
transfer = repository.state.value.transfer.copy(
frames = listOf(TransferFrame(1u, 1u, 21u, ByteArray(21 * 21) { 1 }))
)
)
}
compose.onNodeWithContentDescription("QR frame 1 of 1")
.performScrollTo().assertWidthIsAtLeast(280.dp).assertHeightIsAtLeast(280.dp)
.assertIsDisplayed()
compose.onNodeWithText("Choose QR images").performScrollTo().performClick()
assertTrue(repository.choseImages)
val markers = listOf("private-export-passphrase-marker", "private-qr-payload-marker")
compose.onNodeWithText(
"GPG passphrase"
).performScrollTo().performTextReplacement(markers[0])
compose.onNodeWithText(
"QR payload or armored GPG key"
).performScrollTo().performTextReplacement(markers[1])
compose.runOnIdle {
val saved = Bundle()
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
compose.activity,
saved
)
val parcel = Parcel.obtain()
try {
parcel.writeBundle(saved)
val bytes = parcel.marshall()
for (marker in markers) {
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
}
} finally {
parcel.recycle()
}
authentication.presentation.clear()
}
}
@Test fun privateQrActionsRequireFingerprintConfirmationAndExplicitClosing() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val repository = Fixture()
val authentication = object : AuthenticationRepository by original.authentication {
override val presentation = ProtectedPresentation()
}
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent(authentication.presentation) {
ApplicationTheme(authentication) {
de.rfc1437.ironstorage.features.SettingsScreen(
repository,
authentication,
original.onboarding,
ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
) {}
}
}
}
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(hasText("Import or export GPG QR codes"))
compose.onNodeWithText("Import or export GPG QR codes").performClick()
compose.onNodeWithText("Export private key").performScrollTo().performClick()
compose.onNodeWithText(
"Confirm and export selected key"
).performScrollTo().assertIsNotEnabled()
compose.onNodeWithText(
"I checked this fingerprint and authorize displaying its private QR codes."
)
.performScrollTo().performClick()
compose.onNodeWithText(
"Confirm and export selected key"
).performScrollTo().assertIsEnabled().performClick()
assertEquals(1, repository.exports)
compose.runOnIdle {
repository.state.value = repository.state.value.copy(
transfer = repository.state.value.transfer.copy(
ready = TransferKey(
"SCANNED-FINGERPRINT",
"Scanned fixture",
true
),
received = 2u,
total = 2u
)
)
}
compose.onNodeWithText(
"Confirm and import reconstructed key"
).performScrollTo().assertIsNotEnabled()
compose.onNodeWithText("SCANNED-FINGERPRINT").performScrollTo().assertIsDisplayed()
compose.onNodeWithText(
"I checked this fingerprint and authorize importing its private key material."
)
.performScrollTo().performClick()
compose.onNodeWithText(
"Confirm and import reconstructed key"
).performScrollTo().assertIsEnabled().performClick()
assertEquals(1, repository.imports)
compose.onNodeWithText("Done").performClick()
assertFalse(repository.state.value.transfer.open)
}
internal class Fixture : SettingsRepository {
override val state = MutableStateFlow(
SettingsState(
page = SettingsPage(
"Public fixture store",
"https://example.test/store.git",
"example.test",
"server-fixture",
"public-account",
"Fixture GPG key",
"PUBLIC-FINGERPRINT",
"Original author",
"original@example.test",
300u,
"No TOTP discovery cache.", false,
ProviderStatus(
true,
false,
false
),
ProviderStatus(true, false, false), "fixture-version"
)
)
)
var identity = emptyList<String>()
var timeoutInput = ""
var removals = 0
var reloads = 0
var choseImages = false
var exports = 0
var imports = 0
override fun reload() {
reloads += 1
}
override fun setDefaultKey(fingerprint: String) = Unit
override fun setGitIdentity(name: String, email: String) {
identity = listOf(name, email)
}
override fun setTimeout(seconds: String) {
timeoutInput = seconds
}
override fun removeToken() {
removals += 1
}
override fun rebuildCache(passphrase: String) = Unit
override fun openAutofill() = Unit
override fun openCredentials() = Unit
override fun beginTransfer() {
state.value =
state.value.copy(
error = null,
transfer = KeyTransferState(
open = true,
keys = listOf(
TransferKey(
"PUBLIC-FINGERPRINT",
"Fixture GPG key",
true
)
)
)
)
}
override fun beginSetupTransfer() {
beginTransfer()
state.value =
state.value.copy(
transfer = state.value.transfer.copy(setup = true, keys = emptyList())
)
}
override fun cameraPreview(): CameraPreview? = null
override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) {
exports +=
1
}
override fun chooseQrImages() {
choseImages = true
}
override fun addQrImages(uris: List<String>) = Unit
override fun addQrPayload(payload: String) = Unit
override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) {
imports +=
1
}
override fun closeTransfer() {
state.value = state.value.copy(transfer = KeyTransferState())
}
override fun cancel() = Unit
}
}
@@ -0,0 +1,41 @@
package de.rfc1437.ironstorage
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isHeading
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import de.rfc1437.ironstorage.core.ShellFixtures
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class ShellScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
@Test
fun allRustFixtureStatesRender() {
ShellState.entries.forEach { state ->
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
ShellTab.entries.forEach { tab ->
val page = ShellFixtures.all.getValue(state).first { it.tab == tab }
compose.onNodeWithTag("shell-tab-${tab.name}").performClick()
compose.waitUntil(timeoutMillis = 10_000) {
compose.onAllNodes(hasText(page.stateTitle) and isHeading())
.fetchSemanticsNodes().size == 1
}
compose.onNode(hasText(page.stateTitle) and isHeading()).assertIsDisplayed()
}
}
}
}
@@ -0,0 +1,137 @@
package de.rfc1437.ironstorage
import android.app.KeyguardManager
import android.os.Build
import android.os.Bundle
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelProvider
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.SearchScreen
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** Run on each actual resized window/density/font configuration, without unlocking data. */
class Tier2WindowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun settingsAndKeyboardNavigationFitTheCurrentWindow() {
compose.runOnIdle {
if (Build.VERSION.SDK_INT >= 27) compose.activity.setTurnScreenOn(true)
val keyguard = compose.activity.getSystemService(KeyguardManager::class.java)
if (!keyguard.isDeviceSecure) keyguard.requestDismissKeyguard(compose.activity, null)
}
compose.enableAccessibilityChecks()
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
if (InstrumentationRegistry.getArguments().getString("hardeningResize") == "true") {
val initialWidth = compose.activity.window.decorView.width
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { SearchScreen(original.search, {}) }
}
}
compose.onNodeWithText("Entry name or path").performTextReplacement("appearance")
InstrumentationRegistry.getInstrumentation().sendStatus(
0,
Bundle().apply { putBoolean("ironstorage_resize_ready", true) }
)
compose.waitUntil(30000) { compose.activity.window.decorView.width != initialWidth }
compose.runOnIdle {
val current = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
assertEquals("appearance", current.search.state.value.query)
current.search.clear()
}
}
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val authentication = object : AuthenticationRepository by original.authentication {
override val state = MutableStateFlow(AuthenticationState())
override val presentation = ProtectedPresentation()
override fun reload() = Unit
}
val model = ShellViewModel(
AdaptiveShellTest.Fixture(), original.onboarding, original.passwords,
original.home, original.search, authentication, original.editor,
SettingsScreenTest.Fixture(), original.totp,
SavedStateHandle(
mapOf("shell.tab" to "Preferences")
)
)
compose.runOnIdle { compose.activity.viewModelStore.put("tier2-window-fixture", model) }
lateinit var inputMode: InputModeManager
var keyboard: SoftwareKeyboardController? = null
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent(authentication.presentation) {
inputMode = LocalInputModeManager.current
keyboard = LocalSoftwareKeyboardController.current
ApplicationTheme(authentication) { ShellScreen(model, setup) }
}
}
val width =
compose.activity.window.decorView.width /
compose.activity.resources.displayMetrics.density
val navigation = InstrumentationRegistry.getArguments().getString("expectedNavigation")
?: if (width >= 600) "shell-navigation-rail" else "shell-bottom-navigation"
compose.onNodeWithTag(navigation).assertIsDisplayed()
compose.onNodeWithTag("settings-search").assertIsDisplayed()
if (InstrumentationRegistry.getArguments().getString("hardeningIme") == "true") {
compose.onNodeWithTag("settings-search").performClick()
compose.runOnIdle { requireNotNull(keyboard).show() }
val root = compose.activity.window.decorView
fun imeBottom() = ViewCompat.getRootWindowInsets(root)
?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
compose.waitUntil(10000) { imeBottom() > 0 }
val bottom = compose.onNodeWithTag("shell-content").getUnclippedBoundsInRoot()
.bottom.value * compose.activity.resources.displayMetrics.density
assertTrue(
"Shell content must end above the IME: $bottom",
bottom <= root.height - imeBottom()
)
compose.runOnIdle { requireNotNull(keyboard).hide() }
}
val list = compose.onNodeWithTag("settings-list")
list.performScrollToNode(hasTestTag("shell-open-appearance"))
val appearance = compose.onNodeWithTag("shell-open-appearance")
appearance.assertIsDisplayed().assertHasClickAction()
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
appearance.performSemanticsAction(SemanticsActions.RequestFocus) { assertTrue(it()) }
.performKeyInput { pressKey(Key.Enter) }
compose.onNodeWithText("System default").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Back").performScrollTo().performClick()
compose.onNodeWithTag("settings-list").performScrollToNode(hasTestTag("settings-search"))
compose.onNodeWithTag("settings-search").assertIsDisplayed()
}
}
@@ -0,0 +1,59 @@
package de.rfc1437.ironstorage
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.EntryPoints
import de.rfc1437.ironstorage.core.EditorFieldKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assume.assumeTrue
import org.junit.Test
class TotpDiscoveryFixtureTest {
/** Public test entry only, guarded by the retained fixture's exact key identity. */
@Test fun addPublicTotpForComputerUse(): Unit = runBlocking {
assumeTrue(InstrumentationRegistry.getArguments().getString("totpFixtureSetup") == "true")
val context = InstrumentationRegistry.getInstrumentation().targetContext
val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java)
val setup = access.onboarding()
withContext(Dispatchers.Main.immediate) { setup.refresh() }
val before = withTimeout(30000) { setup.setup.first { !it.busy } }
check(
before.configured &&
before.keys.single().fingerprint == "0C1ABFD707B2103E495B42ACF34D59DF60521FE3"
)
val catalog = access.totp()
withContext(Dispatchers.Main.immediate) { catalog.enter(null, null) }
val cached = withTimeout(30000) { catalog.state.first { !it.loading } }
check(cached.error == null)
if (cached.rows.any { it.path == "discovery-fixture" }) {
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
return@runBlocking
}
val editor = access.editor()
withContext(Dispatchers.Main.immediate) {
editor.create("", "discovery-fixture", "public-provider-fixture-passphrase")
}
val created = withTimeout(30000) { editor.state.first { !it.busy } }
check(created.open && !created.locked && created.error == null) { created.error.orEmpty() }
withContext(Dispatchers.Main.immediate) {
editor.add(
EditorFieldKind.OtpUri,
null,
"otpauth://totp/Public:discovery?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Public"
)
}
withTimeout(30000) {
editor.state.first {
!it.busy &&
it.fields.any { field -> field.kind == EditorFieldKind.OtpUri }
}
}
withContext(Dispatchers.Main.immediate) { editor.save() }
val saved = withTimeout(30000) { editor.state.first { !it.busy && !it.open } }
check(saved.error == null)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
}
}
@@ -0,0 +1,247 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.Density
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.PasswordOtp
import de.rfc1437.ironstorage.core.PasswordOtpKind
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.TotpDiscoveryPhase
import de.rfc1437.ironstorage.core.TotpDiscoveryProgress
import de.rfc1437.ironstorage.core.TotpRepository
import de.rfc1437.ironstorage.core.TotpState
import de.rfc1437.ironstorage.features.TotpScreen
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class TotpScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private fun display(fixture: Fixture, fontScale: Float = 1f) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(density.density, fontScale)
) {
MaterialTheme { TotpScreen(fixture, fixture.base.authentication) }
}
}
}
}
@Test fun catalogRequiresExplicitUnlockAndShowsSharedLargeCode() {
val fixture = Fixture()
display(fixture)
compose.onNodeWithTag("otp-code").assertDoesNotExist()
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithTag("otp-code").assertTextEquals("123 456")
compose.onNodeWithText("17 seconds remaining").assertIsDisplayed()
compose.onNodeWithText("Copy code").performClick()
assertEquals(1, fixture.copies)
compose.onNodeWithText("Refresh code").performClick()
assertEquals(1, fixture.refreshes)
compose.onNodeWithText("Lock").performClick()
compose.onNodeWithTag("otp-code").assertDoesNotExist()
compose.onNodeWithText("Unlock").assertIsDisplayed()
}
@Test fun ticksStopWhenTheCodeComponentLeaves() {
val fixture = Fixture()
display(fixture)
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
compose.waitUntil(5000) { fixture.ticks > 0 }
compose.onNodeWithText("Back to TOTP entries").performClick()
compose.onNodeWithTag("otp-code").assertDoesNotExist()
val stopped = compose.runOnIdle { fixture.ticks }
Thread.sleep(1200)
compose.runOnIdle { assertEquals(stopped, fixture.ticks) }
}
@Test fun largeTextShowsEveryDigitWithoutClipping() {
val fixture = Fixture()
display(fixture, 3f)
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
val results = mutableListOf<TextLayoutResult>()
compose.onNodeWithTag(
"otp-code"
).performSemanticsAction(SemanticsActions.GetTextLayoutResult) {
org.junit.Assert.assertTrue(it(results))
}
org.junit.Assert.assertTrue(results.isNotEmpty())
org.junit.Assert.assertFalse(results.single().hasVisualOverflow)
}
@Test fun hotpGenerationRequiresConfirmationAndCancelDoesNotConsume() {
val fixture = Fixture(hotp = true)
display(fixture)
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithTag("otp-code").assertDoesNotExist()
compose.onNodeWithText("Generate next HOTP code").performClick()
compose.onNodeWithText("Cancel").performClick()
assertEquals(0, fixture.generations)
compose.onNodeWithText("Generate next HOTP code").performClick()
compose.onAllNodesWithText("Generate next HOTP code").onLast().performClick()
assertEquals(1, fixture.generations)
compose.onNodeWithTag("otp-code").assertTextEquals("287 082")
compose.onNodeWithText("Copy code").performClick()
compose.onNodeWithText("Refresh code").performClick()
assertEquals(1, fixture.generations)
compose.runOnIdle { fixture.base.authentication.lock() }
compose.onNodeWithTag("otp-code").assertDoesNotExist()
}
@Test fun discoveryShowsTypedProgressCancellationAndCachedQuery() {
val fixture = Fixture()
fixture.state.value = fixture.state.value.copy(
refreshing = true,
progress = TotpDiscoveryProgress(TotpDiscoveryPhase.Inspecting, 10, 4, 3, 2, 1)
)
display(fixture)
compose.onNodeWithText("Inspecting entries").assertIsDisplayed()
compose.onNodeWithText(
"Inspected: 4 / 10 · Cache hits: 3 · Matches: 2 · Unavailable: 1"
).assertIsDisplayed()
compose.onNodeWithTag("totp-progress").assertIsDisplayed()
compose.onNodeWithTag("totp-query").performTextInput("cached")
assertEquals("cached", fixture.state.value.query)
compose.onNodeWithText("Cancel").performClick()
compose.onNodeWithText(
"Cancelled. Completed checkpoints are retained; refresh to resume."
).assertIsDisplayed()
compose.onNodeWithText("Apple Watch").assertDoesNotExist()
}
@Test fun inspectDisposableTotpWithComputerUse() {
val arguments = InstrumentationRegistry.getArguments()
assumeTrue(arguments.getString("otpInspect") == "true")
val fixture = Fixture(hotp = arguments.getString("otpKind") == "hotp")
val done = CountDownLatch(1)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
Column(Modifier.fillMaxSize()) {
TextButton(onClick = { done.countDown() }) { Text("Finish OTP inspection") }
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides
Density(
density.density,
arguments.getString("otpFont")?.toFloat() ?: 1f
)
) {
TotpScreen(fixture, fixture.base.authentication)
}
}
}
}
}
compose.waitForIdle()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
}
}
private class Fixture(private val hotp: Boolean = false) : TotpRepository {
val base = EntryViewerTest.Fixture()
var copies = 0
var refreshes = 0
var generations = 0
var ticks = 0
override val state = MutableStateFlow(
TotpState(
rows = base.state.value.rows,
hasCatalog = true
)
)
override val viewer = object : PasswordRepository by base {
override fun unlock(passphrase: String) {
base.unlock(passphrase)
if (hotp) {
base.state.value = base.state.value.copy(
otp = PasswordOtp(
PasswordOtpKind.Hotp, null, null, "RFC", "alice",
null, null, null, null, 1u
)
)
}
}
override fun copyOtp() {
copies += 1
}
override fun tickOtp() {
ticks += 1
}
override fun refreshOtp() {
refreshes += 1
}
override fun advanceHotp() {
generations += 1
base.state.value = base.state.value.copy(
otp = PasswordOtp(
PasswordOtpKind.Hotp, "287082", "287 082", "RFC", "alice",
null, null, null, 1u, 2u
)
)
}
}
override fun enter(selected: String?, revision: String?) = Unit
override fun reload() = Unit
override fun query(value: String) {
state.value = state.value.copy(query = value)
}
override fun open(id: String, revision: String) {
state.value =
state.value.copy(detail = true)
}
override fun back() {
viewer.leave()
state.value = state.value.copy(detail = false)
}
override fun discover(passphrase: String) = Unit
override fun cancel() {
state.value = state.value.copy(
refreshing = false,
progress = state.value.progress?.copy(phase = TotpDiscoveryPhase.Cancelled)
)
}
override fun leave() = viewer.leave()
}
}
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application><profileable android:shell="true" /></application>
</manifest>
@@ -0,0 +1,20 @@
package de.rfc1437.ironstorage
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.TotpRepository
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ProviderFixtureAccess {
fun onboarding(): OnboardingRepository
fun authentication(): NativeAuthenticationRepository
fun credentials(): NativeCredentialRepository
fun editor(): EditorRepository
fun totp(): TotpRepository
}
@@ -0,0 +1,37 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel
@Composable
fun VariantContent(
viewModel: ShellViewModel,
repository: ShellRepository,
setupForm: SetupFormViewModel
) {
Column(Modifier.statusBarsPadding()) {
Row(Modifier.horizontalScroll(rememberScrollState())) {
ShellState.entries.forEach { state ->
TextButton(onClick = {
(repository as NativeFixtureShellRepository).showFixture(state)
}) {
Text("Fixture: ${state.name}")
}
}
}
ShellScreen(viewModel, setupForm)
}
}
+44
View File
@@ -0,0 +1,44 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- OS-mediated clients need signer verification even without discoverable intent filters. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Credential Manager is a provider on API 34+; API 26–33 uses framework Autofill. -->
<application
tools:ignore="CredentialDependency"
android:name=".IronStorageApplication"
android:allowBackup="false"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.IronStorage"
android:usesCleartextTraffic="false">
<service android:name=".providers.IronAutofillService" android:exported="true"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
<intent-filter><action android:name="android.service.autofill.AutofillService" /></intent-filter>
<meta-data android:name="android.autofill" android:resource="@xml/autofill_service" />
</service>
<service tools:targetApi="34" android:name=".providers.IronCredentialProviderService" android:exported="true" android:enabled="@bool/credential_provider_enabled"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE">
<intent-filter><action android:name="android.service.credentials.CredentialProviderService" /></intent-filter>
<meta-data android:name="android.credentials.provider" android:resource="@xml/credential_provider" />
</service>
<activity android:name=".providers.CredentialSelectionActivity" android:exported="false"
android:excludeFromRecents="true" android:taskAffinity="" android:windowSoftInputMode="adjustResize" />
<activity android:name=".MainActivity" android:exported="true"
android:windowSoftInputMode="adjustResize"
android:supportsPictureInPicture="false">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,34 @@
package de.rfc1437.ironstorage
import android.app.Application
import android.content.ComponentCallbacks2
import dagger.hilt.android.HiltAndroidApp
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
import javax.inject.Inject
@HiltAndroidApp
class IronStorageApplication : Application() {
@Inject lateinit var authentication: AuthenticationRepository
override fun onCreate() {
super.onCreate()
authentication.lifecycle(AuthenticationLifecycle.ProcessRecreated)
}
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onTrimMemory(level: Int) {
// UI/cache transitions also occur while the system picker holds a non-secret request.
if (level < ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN ||
level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE
) {
authentication.lifecycle(AuthenticationLifecycle.MemoryPressure)
}
super.onTrimMemory(level)
}
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onLowMemory() {
authentication.lifecycle(AuthenticationLifecycle.MemoryPressure)
super.onLowMemory()
}
}
@@ -0,0 +1,171 @@
package de.rfc1437.ironstorage
import android.app.KeyguardManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.view.Display
import android.view.View
import android.view.WindowManager
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.core.view.WindowCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.ProtectedContent
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : FragmentActivity() {
@Inject lateinit var keystore: AndroidKeystoreAdapter
@Inject lateinit var repository: ShellRepository
@Inject lateinit var authentication: AuthenticationRepository
@Inject lateinit var onboarding: OnboardingRepository
@Inject lateinit var settingsPlatform: AndroidSettingsPlatform
@Inject lateinit var settings: SettingsRepository
private val setupForm: SetupFormViewModel by viewModels()
private val viewModel: ShellViewModel by viewModels()
private var cleanup: AutoCloseable? = null
private val protectedState = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_SCREEN_OFF) {
authentication.lifecycle(AuthenticationLifecycle.DeviceLocked)
}
}
}
override fun onPause() {
authentication.lifecycle(AuthenticationLifecycle.Background)
super.onPause()
}
override fun onUserLeaveHint() {
authentication.lifecycle(AuthenticationLifecycle.Background)
super.onUserLeaveHint()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus && !keystore.authenticationPending &&
!authentication.presentation.hasOwnedWindow
) {
authentication.lifecycle(AuthenticationLifecycle.Background)
}
}
override fun onStop() {
authentication.lifecycle(AuthenticationLifecycle.Background)
super.onStop()
}
override fun onResume() {
super.onResume()
if (getSystemService(KeyguardManager::class.java).isDeviceLocked) {
authentication.lifecycle(AuthenticationLifecycle.DeviceLocked)
} else {
authentication.lifecycle(AuthenticationLifecycle.Foreground)
}
}
override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) {
super.onTopResumedActivityChanged(isTopResumedActivity)
if (!isTopResumedActivity && !keystore.authenticationPending) {
authentication.lifecycle(AuthenticationLifecycle.Background)
}
}
override fun onUserInteraction() {
authentication.touch()
super.onUserInteraction()
}
override fun onDestroy() {
cleanup?.close()
unregisterReceiver(protectedState)
keystore.detach(this)
settingsPlatform.detach(this)
super.onDestroy()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (Build.VERSION.SDK_INT >= 33) setRecentsScreenshotEnabled(false)
window.decorView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
if (Build.VERSION.SDK_INT >= 30) {
window.decorView.importantForContentCapture =
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS
}
cleanup = authentication.presentation.register {
setupForm.clearSecrets()
onboarding.cancel()
}
val filter = IntentFilter(Intent.ACTION_SCREEN_OFF)
if (Build.VERSION.SDK_INT >= 33) {
registerReceiver(protectedState, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(protectedState, filter)
}
@Suppress("DEPRECATION")
val externalDisplay = windowManager.defaultDisplay.displayId != Display.DEFAULT_DISPLAY
if (externalDisplay) {
authentication.lifecycle(AuthenticationLifecycle.Background)
finish()
return
}
keystore.attach(this)
settingsPlatform.attach(this, settings::addQrImages)
enableEdgeToEdge()
setContent {
val startup by viewModel.snapshot.collectAsStateWithLifecycle()
ReportDrawnWhen { startup.state != ShellState.Loading }
ProtectedContent(authentication.presentation) {
ApplicationTheme(authentication) {
val lightBars = MaterialTheme.colorScheme.background.luminance() > 0.5f
SideEffect {
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = lightBars
isAppearanceLightNavigationBars = lightBars
}
}
Surface(Modifier.fillMaxSize()) {
VariantContent(viewModel, repository, setupForm)
}
}
}
}
}
}
@@ -0,0 +1,185 @@
package de.rfc1437.ironstorage
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
import de.rfc1437.ironstorage.core.NativeEditorRepository
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
import de.rfc1437.ironstorage.core.NativeHomeRepository
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
import de.rfc1437.ironstorage.core.NativePasswordRepository
import de.rfc1437.ironstorage.core.NativePrivateSession
import de.rfc1437.ironstorage.core.NativeSearchRepository
import de.rfc1437.ironstorage.core.NativeSettingsRepository
import de.rfc1437.ironstorage.core.NativeTotpRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.SearchRepository
import de.rfc1437.ironstorage.core.SettingsPlatform
import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.TotpRepository
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform
import de.rfc1437.ironstorage.platform.privateDirectories
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
object ShellModule {
@Provides
@Singleton
fun credentials(
authentication: NativeAuthenticationRepository
): de.rfc1437.ironstorage.core.NativeCredentialRepository =
de.rfc1437.ironstorage.core.NativeCredentialRepository(authentication)
@Provides
@Singleton
fun androidSettingsPlatform(
@ApplicationContext context: Context,
keystore: AndroidKeystoreAdapter
): AndroidSettingsPlatform = AndroidSettingsPlatform(context, keystore)
@Provides
fun settingsPlatform(platform: AndroidSettingsPlatform): SettingsPlatform = platform
@Provides
@Singleton
fun settingsRepository(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository,
platform: SettingsPlatform
): SettingsRepository = NativeSettingsRepository(
session,
authentication,
platform,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
@Provides
@Singleton
fun editorRepository(
authentication: NativeAuthenticationRepository,
passwords: PasswordRepository
): EditorRepository = NativeEditorRepository(
authentication,
passwords,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
@Provides
@Singleton
fun searchRepository(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository
): SearchRepository = NativeSearchRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
Dispatchers.IO,
authentication.presentation
)
@Provides
@Singleton
fun homeRepository(
session: NativePrivateSession,
passwords: PasswordRepository
): HomeRepository = NativeHomeRepository(
session,
passwords,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
Dispatchers.IO.limitedParallelism(2)
)
@Provides
@Singleton
fun passwordRepository(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository,
@ApplicationContext context: Context
): PasswordRepository = NativePasswordRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
authentication,
Dispatchers.IO,
AndroidSecretClipboard(context)
)
@Provides
@Singleton
fun totpRepository(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository,
@ApplicationContext context: Context
): TotpRepository {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val viewer =
NativePasswordRepository(
session,
scope,
authentication,
Dispatchers.IO,
AndroidSecretClipboard(context)
)
return NativeTotpRepository(session, authentication, viewer, scope)
}
@Provides
@Singleton
fun nativeAuthentication(session: NativePrivateSession): NativeAuthenticationRepository =
NativeAuthenticationRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
Dispatchers.IO
)
@Provides
fun authentication(repository: NativeAuthenticationRepository): AuthenticationRepository =
repository
@Provides
@Singleton
fun privateSession(
@ApplicationContext context: Context,
keystore: AndroidKeystoreAdapter
): NativePrivateSession =
NativePrivateSession(context, { privateDirectories(context) }, keystore)
@Provides
@Singleton
fun onboardingRepository(
session: NativePrivateSession,
shell: ShellRepository,
authentication: AuthenticationRepository
): OnboardingRepository = NativeOnboardingRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
shell,
authentication
)
@Provides
@Singleton
fun keystore(@ApplicationContext context: Context): AndroidKeystoreAdapter =
AndroidKeystoreAdapter(context)
@Provides
@Singleton
fun shellRepository(session: NativePrivateSession): ShellRepository =
NativeFixtureShellRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}
@@ -0,0 +1,362 @@
package de.rfc1437.ironstorage.providers
import android.app.KeyguardManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.service.autofill.Dataset
import android.view.View
import android.view.WindowManager
import android.view.autofill.AutofillManager
import android.view.autofill.AutofillManager.EXTRA_AUTHENTICATION_RESULT_EPHEMERAL_DATASET
import android.view.autofill.AutofillValue
import android.widget.RemoteViews
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CreatePasswordResponse
import androidx.credentials.GetCredentialResponse
import androidx.credentials.GetPasswordOption
import androidx.credentials.PasswordCredential
import androidx.credentials.provider.PendingIntentHandler
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import de.rfc1437.ironstorage.core.ProviderRequest
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@AndroidEntryPoint
class CredentialSelectionActivity : FragmentActivity() {
@Inject lateinit var credentials: NativeCredentialRepository
@Inject lateinit var authentication: AuthenticationRepository
@Inject lateinit var keystore: AndroidKeystoreAdapter
private var context: ProviderContext? = null
private var request by mutableStateOf<ProviderRequest?>(null)
private var selected by mutableStateOf<String?>(null)
private var query by mutableStateOf("")
private var destination by mutableStateOf("")
private var passphrase by mutableStateOf("")
private var consent by mutableStateOf(false)
private var overwrite by mutableStateOf(false)
private var busy by mutableStateOf(false)
private var failure by mutableStateOf<String?>(null)
private var delivered = false
private var searchSequence = 0
private var cleanup: AutoCloseable? = null
private val screenState = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_SCREEN_OFF) cancel()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
window.decorView.filterTouchesWhenObscured = true
cleanup = authentication.presentation.register {
passphrase = ""
if (!delivered) finish()
}
val screenFilter = IntentFilter(Intent.ACTION_SCREEN_OFF)
if (Build.VERSION.SDK_INT >=
33
) {
registerReceiver(screenState, screenFilter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(screenState, screenFilter)
}
if (Build.VERSION.SDK_INT >= 33) setRecentsScreenshotEnabled(false)
window.decorView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
if (Build.VERSION.SDK_INT >=
30
) {
window.decorView.importantForContentCapture =
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS
}
setResult(RESULT_CANCELED)
keystore.attach(this)
context = ProviderRequests.get(intent)
selected = context?.selected
if (context == null || savedInstanceState != null ||
getSystemService(KeyguardManager::class.java).isDeviceLocked
) {
finish()
return
}
lifecycleScope.launch {
try {
val pending = requireNotNull(context)
when (pending.mode) {
"credential" -> {
if (Build.VERSION.SDK_INT < 34) error("Unsupported credential platform")
val supplied =
requireNotNull(
PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
)
require(
credentialCaller(supplied.callingAppInfo, credentials) == pending.caller
)
val options = supplied.credentialOptions
.filterIsInstance<GetPasswordOption>()
require(
options.size == 1 &&
options.single().allowedUserIds.toList().sorted() ==
pending.allowedUsernames.sorted()
)
}
"create" -> {
if (Build.VERSION.SDK_INT < 34) error("Unsupported credential platform")
val supplied =
requireNotNull(
PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
)
require(
credentialCaller(supplied.callingAppInfo, credentials) == pending.caller
)
val password = supplied.callingRequest as CreatePasswordRequest
credentials.stage(
pending.token,
pending.caller,
password.id,
password.password
)
}
"autofill" -> {
@Suppress("DEPRECATION")
val structure =
requireNotNull(
intent.getParcelableExtra<android.app.assist.AssistStructure>(
AutofillManager.EXTRA_ASSIST_STRUCTURE
)
)
val facts = autofillFacts(structure, credentials)
require(
facts.caller == pending.caller && facts.username == pending.username &&
facts.password == pending.password
)
}
"save" -> require(installedCaller(pending.caller.packageName) == pending.caller)
else -> error("Unsupported request")
}
// System requests can contain secrets. Retain only the bound metadata.
intent = Intent().setAction(intent.action).setData(intent.data)
request = credentials.request(pending.token, pending.caller)
} catch (_: Exception) {
cancel()
return@launch
}
while (!isFinishing) {
delay(1_000)
if (!busy) {
try {
credentials.request(pendingToken(), requireNotNull(context).caller, query)
} catch (
_: Exception
) {
cancel()
return@launch
}
}
}
}
setContent {
de.rfc1437.ironstorage.features.ProtectedContent(authentication.presentation) {
de.rfc1437.ironstorage.features.ApplicationTheme(authentication) {
de.rfc1437.ironstorage.features.CredentialSelectionScreen(
request, context?.mode in listOf("create", "save"), selected, query,
destination, passphrase, consent, overwrite, busy, failure,
onSelect = {
selected = it
consent = false
},
onQuery = {
query = it
search()
},
onDestination = {
destination = it
consent = false
overwrite = false
},
onPassphrase = { passphrase = it },
onConsent = { consent = it },
onOverwrite = { overwrite = it },
onConfirm = ::confirm,
onCancel = ::cancel
)
}
}
}
}
private fun pendingToken() = requireNotNull(context).token
private fun search() {
val sequence = ++searchSequence
lifecycleScope.launch {
delay(150)
if (sequence != searchSequence || busy) return@launch
try {
val result = credentials.request(
pendingToken(),
requireNotNull(context).caller,
query
)
if (sequence == searchSequence) request = result
} catch (_: Exception) {
if (sequence == searchSequence && !busy) cancel()
}
}
}
private fun confirm() {
if (busy) return
searchSequence++
busy = true
failure = null
val suppliedPassphrase = passphrase.takeIf { it.isNotEmpty() }
passphrase = ""
lifecycleScope.launch {
try {
val pending = requireNotNull(context)
check(!getSystemService(KeyguardManager::class.java).isDeviceLocked)
val caller = installedCaller(pending.caller.packageName)
.copy(origin = pending.caller.origin)
val output = Intent()
withTimeout(120_000) {
when (pending.mode) {
"create", "save" -> {
credentials.save(
pending.token,
caller,
destination,
consent,
overwrite,
suppliedPassphrase
)
if (pending.mode ==
"create"
) {
PendingIntentHandler.setCreateCredentialResponse(
output,
CreatePasswordResponse()
)
}
}
else -> {
val value = credentials.fill(
pending.token,
caller,
requireNotNull(selected),
consent,
suppliedPassphrase,
pending.allowedUsernames
)
if (pending.mode == "credential") {
PendingIntentHandler.setGetCredentialResponse(
output,
GetCredentialResponse(
PasswordCredential(value.username, value.password)
)
)
} else {
val display = RemoteViews(
packageName,
android.R.layout.simple_list_item_1
).apply {
setTextViewText(android.R.id.text1, requireNotNull(selected))
}
@Suppress("DEPRECATION")
val dataset = Dataset.Builder(display)
.setValue(
requireNotNull(pending.username),
AutofillValue.forText(value.username)
)
.setValue(
requireNotNull(pending.password),
AutofillValue.forText(value.password)
).build()
output.putExtra(
AutofillManager.EXTRA_AUTHENTICATION_RESULT,
dataset
)
if (Build.VERSION.SDK_INT >=
31
) {
output.putExtra(
EXTRA_AUTHENTICATION_RESULT_EPHEMERAL_DATASET,
true
)
}
}
}
}
}
delivered = true
ProviderRequests.remove(pending.token)
setResult(RESULT_OK, output)
finish()
} catch (_: Exception) {
failure = authentication.state.value.failure?.let { value ->
"${value.title}\n${value.detail}"
} ?: "The request could not be completed. Cancel and request again."
} finally {
busy = false
}
}
}
private fun cancel() {
authentication.cancel()
passphrase = ""
setResult(RESULT_CANCELED)
finish()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus && !keystore.authenticationPending && !delivered) {
passphrase = ""
authentication.lifecycle(AuthenticationLifecycle.Background)
finish()
}
}
override fun onUserLeaveHint() {
if (!keystore.authenticationPending && !delivered) cancel()
super.onUserLeaveHint()
}
override fun onStop() {
if (!keystore.authenticationPending && !delivered) cancel()
super.onStop()
}
override fun onPause() {
passphrase = ""
if (!keystore.authenticationPending) {
authentication.lifecycle(AuthenticationLifecycle.Background)
if (!delivered) finish()
}
super.onPause()
}
override fun onDestroy() {
cleanup?.close()
unregisterReceiver(screenState)
passphrase = ""
context?.let { pending ->
ProviderRequests.remove(pending.token)
}
authentication.lock()
keystore.detach(this)
super.onDestroy()
}
}
@@ -0,0 +1,173 @@
package de.rfc1437.ironstorage.providers
import android.app.assist.AssistStructure
import android.os.Build
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.Dataset
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.FillResponse
import android.service.autofill.InlinePresentation
import android.service.autofill.SaveCallback
import android.service.autofill.SaveInfo
import android.service.autofill.SaveRequest
import android.widget.RemoteViews
import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi
import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@AndroidEntryPoint
class IronAutofillService : AutofillService() {
@Inject lateinit var credentials: NativeCredentialRepository
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@Suppress("DEPRECATION")
override fun onFillRequest(
request: FillRequest,
cancellationSignal: CancellationSignal,
callback: FillCallback
) {
var token: String? = null
val job = scope.launch {
try {
val facts = autofillFacts(request.fillContexts.last().structure, credentials)
val result = withTimeout(5_000) { credentials.begin(facts.caller) }
token = result.token
if (cancellationSignal.isCanceled) {
credentials.cancel(result.token)
return@launch
}
val pending = ProviderRequests.put(
this@IronAutofillService,
ProviderContext(
result.token,
facts.caller,
"autofill",
username = facts.username,
password = facts.password
),
"autofill"
)
val presentation = RemoteViews(
packageName,
android.R.layout.simple_list_item_1
).apply {
setTextViewText(android.R.id.text1, "Unlock and search IronStorage")
}
@Suppress("DEPRECATION")
val dataset = Dataset.Builder(presentation)
.setValue(facts.username, null)
.setValue(facts.password, null)
.setAuthentication(pending.intentSender)
if (Build.VERSION.SDK_INT >= 30) {
val spec = request.inlineSuggestionsRequest
?.inlinePresentationSpecs?.firstOrNull()
if (spec != null &&
UiVersions.getVersions(spec.style).contains(UiVersions.INLINE_UI_VERSION_1)
) {
val content: UiVersions.Content = InlineSuggestionUi.newContentBuilder(
ProviderRequests.attribution(this@IronAutofillService)
).setTitle("IronStorage").setSubtitle("Unlock and search").build()
dataset.setInlinePresentation(
InlinePresentation(content.slice, spec, false)
)
}
}
val response = FillResponse.Builder().addDataset(dataset.build())
.setSaveInfo(
SaveInfo.Builder(
SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,
arrayOf(facts.username, facts.password)
).build()
)
.build()
callback.onSuccess(response)
} catch (_: Exception) {
if (!cancellationSignal.isCanceled) callback.onSuccess(null)
}
}
cancellationSignal.setOnCancelListener {
job.cancel()
token?.let { value ->
ProviderRequests.remove(value)
scope.launch { credentials.cancel(value) }
}
}
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
scope.launch {
try {
val structure = request.fillContexts.last().structure
val facts = autofillFacts(structure, credentials)
fun value(id: android.view.autofill.AutofillId): String {
fun find(node: AssistStructure.ViewNode): String? {
if (node.autofillId ==
id
) {
return node.autofillValue?.takeIf {
it.isText
}?.textValue?.toString()
}
for (index in 0 until node.childCount) {
find(node.getChildAt(index))?.let {
return it
}
}
return null
}
for (index in 0 until structure.windowNodeCount) {
find(
structure.getWindowNodeAt(index).rootViewNode
)?.let {
return it
}
}
error("The system did not supply the credential field.")
}
val result = withTimeout(5_000) {
credentials.begin(facts.caller).also {
credentials.stage(
it.token,
facts.caller,
value(facts.username),
value(facts.password)
)
}
}
val pending = ProviderRequests.put(
this@IronAutofillService,
ProviderContext(result.token, facts.caller, "save"),
"save"
)
if (Build.VERSION.SDK_INT >= 28) {
callback.onSuccess(pending.intentSender)
} else {
pending.send()
callback.onSuccess()
}
} catch (
_: Exception
) {
callback.onFailure(
"IronStorage could not offer this save. Open the entry editor to save manually."
)
}
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
@@ -0,0 +1,180 @@
package de.rfc1437.ironstorage.providers
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.annotation.RequiresApi
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginCreatePasswordCredentialRequest
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.BeginGetPasswordOption
import androidx.credentials.provider.CreateEntry
import androidx.credentials.provider.CredentialProviderService
import androidx.credentials.provider.PasswordCredentialEntry
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@RequiresApi(34)
@AndroidEntryPoint
class IronCredentialProviderService : CredentialProviderService() {
@Inject lateinit var credentials: NativeCredentialRepository
@Inject lateinit var authentication: AuthenticationRepository
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
override fun onBeginGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>
) {
var token: String? = null
val job = scope.launch {
try {
val caller = credentialCaller(requireNotNull(request.callingAppInfo), credentials)
val options = request.beginGetCredentialOptions
.filterIsInstance<BeginGetPasswordOption>()
require(options.isNotEmpty() && options.size <= 16)
val result = withTimeout(5_000) { credentials.begin(caller) }
token = result.token
if (cancellationSignal.isCanceled) {
credentials.cancel(result.token)
return@launch
}
val budget = (20 / options.size - 1).coerceAtLeast(0)
val entries = options.flatMapIndexed { optionIndex, option ->
val candidates = result.candidates.take(budget).mapIndexed { index, candidate ->
PasswordCredentialEntry(
this@IronCredentialProviderService,
candidate.path,
ProviderRequests.put(
this@IronCredentialProviderService,
ProviderContext(
result.token,
caller,
"credential",
candidate.path,
allowedUsernames = option.allowedUserIds.toList()
),
"$optionIndex/$index"
),
option,
displayName = candidate.path,
isAutoSelectAllowed = false
)
}
// Manual search retains the selected OS option and its account constraint.
val search = PasswordCredentialEntry(
this@IronCredentialProviderService,
"Search IronStorage",
ProviderRequests.put(
this@IronCredentialProviderService,
ProviderContext(
result.token,
caller,
"credential",
allowedUsernames = option.allowedUserIds.toList()
),
"$optionIndex/search"
),
option,
isAutoSelectAllowed = false
)
candidates + search
}
callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))
} catch (_: Exception) {
if (!cancellationSignal.isCanceled) {
callback.onError(
GetCredentialUnknownException("IronStorage could not prepare this request.")
)
}
}
}
cancellationSignal.setOnCancelListener {
job.cancel()
token?.let { value ->
ProviderRequests.remove(value)
scope.launch { credentials.cancel(value) }
}
}
}
override fun onBeginCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>
) {
var token: String? = null
val job = scope.launch {
try {
require(request is BeginCreatePasswordCredentialRequest)
val caller = credentialCaller(requireNotNull(request.callingAppInfo), credentials)
val result = withTimeout(5_000) { credentials.begin(caller) }
token = result.token
if (cancellationSignal.isCanceled) {
credentials.cancel(result.token)
return@launch
}
callback.onResult(
BeginCreateCredentialResponse(
listOf(
CreateEntry(
"IronStorage",
ProviderRequests.put(
this@IronCredentialProviderService,
ProviderContext(result.token, caller, "create"),
"create"
),
description = "Choose a destination and confirm saving"
)
)
)
)
} catch (_: Exception) {
if (!cancellationSignal.isCanceled) {
callback.onError(
CreateCredentialUnknownException(
"IronStorage could not prepare this save request."
)
)
}
}
}
cancellationSignal.setOnCancelListener {
job.cancel()
token?.let { value ->
ProviderRequests.remove(value)
scope.launch { credentials.cancel(value) }
}
}
}
override fun onClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>
) {
ProviderRequests.clear()
authentication.lock()
callback.onResult(null)
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
@@ -0,0 +1,169 @@
package de.rfc1437.ironstorage.providers
import android.app.PendingIntent
import android.app.assist.AssistStructure
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.view.autofill.AutofillId
import androidx.annotation.RequiresApi
import androidx.credentials.provider.CallingAppInfo
import de.rfc1437.ironstorage.MainActivity
import de.rfc1437.ironstorage.core.CredentialAttribute
import de.rfc1437.ironstorage.core.CredentialCaller
import de.rfc1437.ironstorage.core.CredentialField
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
internal data class ProviderContext(
val token: String,
val caller: CredentialCaller,
val mode: String,
val selected: String? = null,
val username: AutofillId? = null,
val password: AutofillId? = null,
val allowedUsernames: List<String> = emptyList(),
val created: Long = android.os.SystemClock.elapsedRealtime()
)
/** Contains only request metadata. Lost process state always requires a new system request. */
internal object ProviderRequests {
private val requests = ConcurrentHashMap<String, ProviderContext>()
fun attribution(context: Context): PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java)
.setAction("de.rfc1437.ironstorage.OPEN_PROVIDER"),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
fun put(context: Context, request: ProviderContext, suffix: String): PendingIntent {
val now = android.os.SystemClock.elapsedRealtime()
requests.entries.removeIf { now - it.value.created >= 120_000 }
require(requests.size < 512)
val key = "${request.token}/$suffix"
requests[key] = request
val intent = Intent(context, CredentialSelectionActivity::class.java)
.setAction("de.rfc1437.ironstorage.SELECT_CREDENTIAL")
.setData(
Uri.Builder().scheme(
"ironstorage-provider"
).authority("request").appendEncodedPath(key).build()
)
return PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
fun get(intent: Intent): ProviderContext? =
intent.data?.path?.removePrefix("/")?.let(requests::get)
fun remove(token: String) {
requests.entries.removeIf { it.value.token == token }
}
fun clear() {
requests.clear()
}
}
internal fun Context.installedCaller(packageName: String): CredentialCaller {
@Suppress("DEPRECATION")
val info = packageManager.getPackageInfo(
packageName,
if (Build.VERSION.SDK_INT >=
28
) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
PackageManager.GET_SIGNATURES
}
)
@Suppress("DEPRECATION")
val signatures = if (Build.VERSION.SDK_INT >=
28
) {
info.signingInfo?.apkContentsSigners
} else {
info.signatures
}
val fingerprints = requireNotNull(signatures).map { signature ->
MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()).joinToString("") {
"%02X".format(it)
}
}
return CredentialCaller(packageName, fingerprints, null)
}
@RequiresApi(34)
internal suspend fun Context.credentialCaller(
info: CallingAppInfo,
credentials: NativeCredentialRepository
): CredentialCaller {
val installed = installedCaller(info.packageName)
val supplied = info.signingInfoCompat.apkContentsSigners.map { signature ->
MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()).joinToString("") {
"%02X".format(it)
}
}
require(installed.certificates.sorted() == supplied.sorted())
val origin = try {
info.getOrigin(credentials.privilegedCallers())
} catch (
_: IllegalStateException
) {
null
}
return installed.copy(origin = origin)
}
internal data class AutofillFacts(
val caller: CredentialCaller,
val username: AutofillId,
val password: AutofillId
)
internal suspend fun Context.autofillFacts(
structure: AssistStructure,
credentials: NativeCredentialRepository
): AutofillFacts {
val caller = installedCaller(structure.activityComponent.packageName)
val ids = mutableListOf<AutofillId>()
val fields = mutableListOf<CredentialField>()
var visited = 0
fun visit(node: AssistStructure.ViewNode, depth: Int) {
require(depth < 64 && ++visited <= 2048)
node.autofillId?.let { id ->
require(fields.size < 512)
val existing = ids.indexOf(id)
val index = if (existing < 0) {
ids += id
(ids.size - 1).toUInt()
} else {
existing.toUInt()
}
fields += CredentialField(
index,
node.autofillHints?.toList() ?: emptyList(),
node.htmlInfo?.attributes?.map { CredentialAttribute(it.first, it.second) }
?: emptyList(),
node.inputType.toUInt(),
node.isFocused,
node.autofillType == android.view.View.AUTOFILL_TYPE_TEXT
)
}
for (index in 0 until node.childCount) visit(node.getChildAt(index), depth + 1)
}
require(structure.windowNodeCount <= 16)
for (index in 0 until structure.windowNodeCount) {
visit(
structure.getWindowNodeAt(index).rootViewNode,
0
)
}
val form = credentials.form(fields)
return AutofillFacts(caller, ids[form.username.toInt()], ids[form.password.toInt()])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,3 @@
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_vault"
android:inset="18dp" />
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF"
android:strokeWidth="3" android:pathData="M54,24A30,30 0,1 1,54 84A30,30 0,1 1,54 24ZM54,31A23,23 0,1 1,54 77A23,23 0,1 1,54 31ZM54,41A13,13 0,1 1,54 67A13,13 0,1 1,54 41" />
<path android:fillColor="#FFFFFFFF"
android:pathData="M52,41L56,41L56,52L66,58L64,62L54,56L44,62L42,58L52,52ZM50,50A5.7,5.7 0,1 1,58 58A5.7,5.7 0,1 1,50 50ZM79,37L85,37L85,71L79,71ZM73,39L79,39L79,44L73,44ZM73,64L79,64L79,69L73,69" />
</vector>
@@ -0,0 +1,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/icon_background" />
<foreground android:drawable="@drawable/ic_vault_foreground" />
<monochrome android:drawable="@drawable/ic_vault_monochrome" />
</adaptive-icon>
@@ -0,0 +1 @@
<resources><bool name="credential_provider_enabled">true</bool></resources>
@@ -0,0 +1,3 @@
<resources>
<color name="icon_background">#30363F</color>
</resources>
@@ -0,0 +1 @@
<resources><bool name="credential_provider_enabled">false</bool></resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">IronStorage</string>
</resources>
@@ -0,0 +1,7 @@
<resources>
<style name="Theme.IronStorage" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
@@ -0,0 +1 @@
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" android:settingsActivity="de.rfc1437.ironstorage.MainActivity" />
@@ -0,0 +1,11 @@
<full-backup-content>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</full-backup-content>
@@ -0,0 +1,3 @@
<credential-provider xmlns:android="http://schemas.android.com/apk/res/android" android:settingsActivity="de.rfc1437.ironstorage.MainActivity">
<capabilities><capability name="android.credentials.TYPE_PASSWORD_CREDENTIAL" /></capabilities>
</credential-provider>
@@ -0,0 +1,24 @@
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,17 @@
package de.rfc1437.ironstorage
import androidx.compose.runtime.Composable
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel
@Composable
@Suppress("UNUSED_PARAMETER")
fun VariantContent(
viewModel: ShellViewModel,
repository: ShellRepository,
setupForm: SetupFormViewModel
) {
ShellScreen(viewModel, setupForm)
}
@@ -0,0 +1,19 @@
package de.rfc1437.ironstorage
import de.rfc1437.ironstorage.core.ShellFixtures
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab
import org.junit.Assert.assertEquals
import org.junit.Test
class DebugShellRepositoryTest {
@Test
fun fixtureInventorySupportsEveryNativeTab() {
val selected = ShellTab.Totp
ShellState.entries.forEach { state ->
val pages = ShellFixtures.all.getValue(state)
assertEquals(ShellTab.entries, pages.map { it.tab })
assertEquals(selected, pages.first { it.tab == selected }.tab)
}
}
}
+144
View File
@@ -0,0 +1,144 @@
import groovy.json.JsonOutput
import java.io.File
import java.security.MessageDigest
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.artifacts.ExternalModuleDependency
import org.gradle.api.artifacts.ProjectDependency
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.compose) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.ktlint) apply false
}
val allowed = mapOf(
":provider-client" to emptySet(),
":performance" to setOf(":app"),
":app" to setOf(":features", ":core", ":platform"),
":features" to setOf(":core"),
":core" to setOf(":rust-bridge"),
":platform" to setOf(":core"),
":rust-bridge" to emptySet(),
)
subprojects {
apply(plugin = "org.jlleitschuh.gradle.ktlint")
extensions.configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
version.set(rootProject.libs.versions.ktlint.get())
filter { exclude("**/build/**") }
}
dependencyLocking { lockAllConfigurations() }
val ownerPath = project.path
configurations.configureEach {
dependencies.withType<ProjectDependency>().configureEach {
val destination = this.path
require(destination == ownerPath || destination in allowed.getValue(ownerPath)) {
"$ownerPath cannot depend on $destination"
}
}
dependencies.withType<ExternalModuleDependency>().configureEach {
require(group.orEmpty().let { group ->
listOf("org.eclipse.jgit", "org.bouncycastle", "androidx.room", "com.squareup.retrofit2", "com.squareup.okhttp3").none { group.startsWith(it) }
}) { "Domain libraries must stay in crates/storage: $group:$name" }
}
val enforceDomain = name.endsWith("RuntimeClasspath") || name.endsWith("CompileClasspath")
resolutionStrategy.eachDependency {
require(requested.version.orEmpty().let { version ->
!version.contains('+') && !version.contains("SNAPSHOT") &&
!version.startsWith("latest.") && version.none { it in "[]()," }
}) { "Dynamic versions are forbidden" }
require(!enforceDomain || listOf("org.eclipse.jgit", "org.bouncycastle", "androidx.room", "com.squareup.retrofit2", "com.squareup.okhttp3", "org.pgpainless", "com.google.mlkit").none {
requested.group == it || requested.group.startsWith("$it.")
}) { "Transitive domain libraries must stay in crates/storage" }
}
}
}
val cargoExecutable = providers.environmentVariable("CARGO")
.orElse(providers.environmentVariable("PATH").map { path ->
path.split(File.pathSeparatorChar)
.map { File(it, "cargo") }
.firstOrNull { it.isFile && it.canExecute() }?.absolutePath
?: error("Cargo is required: put it in PATH or set CARGO to its absolute path")
})
val workspace = layout.projectDirectory.dir("..").asFile
val checkArchitecture = tasks.register<Exec>("checkArchitecture") {
workingDir = workspace
inputs.files(fileTree(".") { include("**/src/**/*.kt", "**/build.gradle.kts"); exclude("**/build/**", ".gradle/**") })
inputs.files(workspace.resolve("Cargo.lock"), workspace.resolve("tools/android-build/src/main.rs"))
commandLine(cargoExecutable.get(), "run", "--locked", "--package", "ironstorage-android-build", "--", "check", layout.projectDirectory.asFile.absolutePath)
}
subprojects {
tasks.matching { it.name == "preBuild" || it.name == "check" }.configureEach { dependsOn(rootProject.tasks.named("checkArchitecture")) }
}
tasks.register<Exec>("auditNativePackages") {
dependsOn(":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease")
workingDir = workspace
val packages = listOf(
layout.projectDirectory.file("app/build/outputs/apk/debug/app-debug.apk"),
layout.projectDirectory.file("app/build/outputs/apk/release/app-release-unsigned.apk"),
layout.projectDirectory.file("app/build/outputs/bundle/release/app-release.aab")
)
inputs.files(packages)
commandLine(
listOf(
cargoExecutable.get(), "+${libs.versions.rust.get()}", "run", "--locked",
"--package", "ironstorage-android-build", "--", "audit-package"
) + packages.map { it.asFile.absolutePath }
)
}
// Each project resolves its own configurations under Gradle's project lock.
subprojects {
val owner = project
tasks.register("dependencyInventory") {
val output = layout.buildDirectory.file("reports/android-dependencies.json")
outputs.file(output)
outputs.upToDateWhen { false }
doLast {
val rows = listOf("debugRuntimeClasspath", "releaseRuntimeClasspath", "debugAndroidTestRuntimeClasspath", "debugUnitTestRuntimeClasspath", "benchmarkRuntimeClasspath").flatMap { name ->
val configuration = owner.configurations.findByName(name)
if (configuration == null || !configuration.isCanBeResolved) emptyList() else {
configuration.incoming.artifactView {
componentFilter { it is ModuleComponentIdentifier }
}.artifacts.artifacts.mapNotNull { artifact ->
val id = artifact.id.componentIdentifier as? ModuleComponentIdentifier
id?.let {
mapOf(
"group" to it.group, "name" to it.module, "version" to it.version,
"sha256" to MessageDigest.getInstance("SHA-256").digest(artifact.file.readBytes())
.joinToString("") { byte -> "%02x".format(byte) },
"scope" to "${owner.path}:$name"
)
}
}
}
}.sortedWith(compareBy({ it.getValue("scope") }, { it.getValue("group") }, { it.getValue("name") }, { it.getValue("version") }, { it.getValue("sha256") }))
output.get().asFile.apply {
parentFile.mkdirs()
writeText(JsonOutput.prettyPrint(JsonOutput.toJson(rows)) + "\n")
}
}
notCompatibleWithConfigurationCache("Resolves reviewed artifact graphs")
}
}
tasks.register("dependencyInventory") {
dependsOn(subprojects.map { "${it.path}:dependencyInventory" })
}
tasks.register("androidVerification") {
dependsOn(":provider-client:assembleDebug")
dependsOn("checkArchitecture", "dependencyInventory", "auditNativePackages")
dependsOn(subprojects.map { "${it.path}:ktlintCheck" })
dependsOn(subprojects.filter { it.name != "performance" }.map { "${it.path}:lintDebug" })
dependsOn(subprojects.filter { it.name != "performance" }.map { "${it.path}:testDebugUnitTest" })
dependsOn(":app:assembleBenchmark", ":app:assembleProfile", ":performance:assembleBenchmark")
dependsOn(":core:assembleDebugAndroidTest", ":app:assembleDebugAndroidTest", ":rust-bridge:assembleDebugAndroidTest", ":platform:assembleDebugAndroidTest")
}
+4
View File
@@ -0,0 +1,4 @@
{
"url": "https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar",
"sha256": "a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29"
}
+14
View File
@@ -0,0 +1,14 @@
{
"sdk_cli": {
"url": "https://dl.google.com/android/repository/commandlinetools-linux-14742923_latest.zip",
"sha256": "04453066b540409d975c676d781da1477479dde3761310f1a7eb92a1dfb15af7"
},
"ndk": {
"url": "https://github.com/HomuHomu833/android-ndk-custom/releases/download/r30/android-ndk-r30-aarch64-linux-gnu.tar.xz",
"sha256": "f8eeb06b7c97415d883a8ecdd0a725e759406edab4502e13b1a6a8561461a0c8"
},
"sdk_native": {
"url": "https://github.com/lzhiyong/android-sdk-tools/releases/download/35.0.2/android-sdk-tools-static-aarch64.zip",
"sha256": "db1cea2c4454d5f9c5a802646b2d1cf560b4ee7badbe23e51ab8e1881bb50fc2"
}
}
+84
View File
@@ -0,0 +1,84 @@
import java.io.File
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "de.rfc1437.ironstorage.core"
compileSdk = libs.versions.compileSdk.get().toInt()
ndkVersion = libs.versions.ndk.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig { consumerProguardFiles("consumer-rules.pro") }
lint {
abortOnError = true
warningsAsErrors = true
}
}
kotlin {
jvmToolchain(25)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
implementation(project(":rust-bridge"))
implementation(libs.coroutines.android)
testImplementation(libs.coroutines.test)
testImplementation(libs.junit)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.android.test.runner)
androidTestImplementation(libs.android.test.core)
}
val fixtureOutput = layout.buildDirectory.file(
"generated/fixtures/de/rfc1437/ironstorage/core/ShellFixtures.kt"
)
val cargoExecutable = providers.environmentVariable("CARGO")
.orElse(
providers.environmentVariable("PATH").map { path ->
path.split(File.pathSeparatorChar)
.map { File(it, "cargo") }
.firstOrNull { it.isFile && it.canExecute() }?.absolutePath
?: error("Cargo is required: put it in PATH or set CARGO to its absolute path")
}
)
val workspace = rootProject.layout.projectDirectory.dir("..").asFile
val generateShellFixtures = tasks.register<Exec>("generateShellFixtures") {
workingDir = workspace
inputs.files(
workspace.resolve("Cargo.toml"),
workspace.resolve("Cargo.lock"),
workspace.resolve("tools/android-build/Cargo.toml"),
workspace.resolve("tools/android-build/src/main.rs"),
workspace.resolve("crates/storage/src/mobile.rs")
)
outputs.file(fixtureOutput)
commandLine(
cargoExecutable.get(),
"run",
"--locked",
"--package",
"ironstorage-android-build",
"--",
"fixtures",
fixtureOutput.get().asFile.absolutePath
)
}
androidComponents.onVariants(androidComponents.selector().withBuildType("debug")) { variant ->
variant.sources.kotlin!!.addStaticSourceDirectory(
layout.buildDirectory.dir("generated/fixtures").get().asFile.absolutePath
)
}
tasks.matching { it.name == "preDebugBuild" }.configureEach { dependsOn(generateShellFixtures) }
+1
View File
@@ -0,0 +1 @@
# Native/generated keep rules are added with the UniFFI packaging in #92.
+169
View File
@@ -0,0 +1,169 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :core:dependencies --write-locks
androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-jvm:1.7.0=debugAndroidTestCompileClasspath
androidx.annotation:annotation:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation:1.7.0=debugAndroidTestCompileClasspath
androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.lifecycle:lifecycle-common:2.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.services:storage:1.6.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
ch.qos.logback:logback-classic:1.3.15=ktlint
ch.qos.logback:logback-core:1.3.15=ktlint
com.android.tools.analytics-library:protos:32.4.1=androidLintTool
com.android.tools.analytics-library:shared:32.4.1=androidLintTool
com.android.tools.analytics-library:tracker:32.4.1=androidLintTool
com.android.tools.androidtest:android-test-engine-result-listener:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.androidtest:android-test-engine:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.androidtest:test-result-event-proto:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.androidtest:test-result-proto:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.build:aapt2-proto:9.4.1-15978811=androidLintTool
com.android.tools.build:builder-model:9.4.1=androidLintTool
com.android.tools.build:manifest-merger:32.4.1=androidLintTool
com.android.tools.ddms:ddmlib:32.4.1=androidLintTool
com.android.tools.external.com-intellij:intellij-core:32.4.1=androidLintTool
com.android.tools.external.com-intellij:kotlin-compiler:32.4.1=androidLintTool
com.android.tools.external.org-jetbrains:uast:32.4.1=androidLintTool
com.android.tools.layoutlib:layoutlib-api:32.4.1=androidLintTool
com.android.tools.lint:lint-api:32.4.1=androidLintTool
com.android.tools.lint:lint-checks:32.4.1=androidLintTool
com.android.tools.lint:lint-gradle:32.4.1=androidLintTool
com.android.tools.lint:lint-model:32.4.1=androidLintTool
com.android.tools.lint:lint-typedef-remover:32.4.1=androidLintTool
com.android.tools.lint:lint:32.4.1=androidLintTool
com.android.tools.utp:gradle-work-action:32.4.1=unified-test-platform-gradle-work-action
com.android.tools:annotations:32.4.1=androidLintTool
com.android.tools:common:32.4.1=androidLintTool
com.android.tools:dvlib:32.4.1=androidLintTool
com.android.tools:play-sdk-proto:32.4.1=androidLintTool
com.android.tools:repository:32.4.1=androidLintTool
com.android.tools:sdk-common:32.4.1=androidLintTool
com.android.tools:sdklib:32.4.1=androidLintTool
com.github.ajalt.clikt:clikt-jvm:5.0.3=ktlint
com.github.ajalt.clikt:clikt:5.0.3=ktlint
com.google.code.findbugs:jsr305:3.0.2=androidLintTool
com.google.code.gson:gson:2.11.0=androidLintTool
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool
com.google.guava:failureaccess:1.0.2=androidLintTool
com.google.guava:guava:33.4.0-jre=androidLintTool
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool
com.google.jimfs:jimfs:1.1=androidLintTool
com.google.protobuf:protobuf-java:3.25.5=androidLintTool,unified-test-platform-gradle-work-action
com.pinterest.ktlint:ktlint-cli-reporter-baseline:1.7.1=ktlint,ktlintBaselineReporter
com.pinterest.ktlint:ktlint-cli-reporter-checkstyle:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-core:1.7.1=ktlint,ktlintBaselineReporter
com.pinterest.ktlint:ktlint-cli-reporter-format:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-html:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-json:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-plain-summary:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-plain:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-reporter-sarif:1.7.1=ktlint
com.pinterest.ktlint:ktlint-cli-ruleset-core:1.7.1=ktlint,ktlintRuleset
com.pinterest.ktlint:ktlint-cli:1.7.1=ktlint
com.pinterest.ktlint:ktlint-logger:1.7.1=ktlint,ktlintBaselineReporter,ktlintRuleset
com.pinterest.ktlint:ktlint-rule-engine-core:1.7.1=ktlint,ktlintBaselineReporter,ktlintRuleset
com.pinterest.ktlint:ktlint-rule-engine:1.7.1=ktlint
com.pinterest.ktlint:ktlint-ruleset-standard:1.7.1=ktlint,ktlintRuleset
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool
commons-codec:commons-codec:1.17.1=androidLintTool
commons-io:commons-io:2.16.1=androidLintTool
commons-logging:commons-logging:1.2=androidLintTool
dev.drewhamilton.poko:poko-annotations-jvm:0.19.1=ktlint,ktlintBaselineReporter,ktlintRuleset
dev.drewhamilton.poko:poko-annotations:0.19.1=ktlint,ktlintBaselineReporter,ktlintRuleset
io.github.detekt.sarif4k:sarif4k-jvm:0.6.0=ktlint,ktlintReporter
io.github.detekt.sarif4k:sarif4k:0.6.0=ktlint,ktlintReporter
io.github.oshai:kotlin-logging-jvm:7.0.7=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset
io.github.oshai:kotlin-logging:5.1.0=ktlint,ktlintBaselineReporter,ktlintReporter
jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool
javax.inject:javax.inject:1=androidLintTool
junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.java.dev.jna:jna-platform:5.6.0=androidLintTool
net.java.dev.jna:jna:5.19.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
net.java.dev.jna:jna:5.6.0=androidLintTool
net.sf.kxml:kxml2:2.3.0=androidLintTool
org.apache.commons:commons-compress:1.27.1=androidLintTool
org.apache.commons:commons-lang3:3.16.0=androidLintTool
org.apache.groovy:groovy-bom:4.0.29=androidLintTool
org.apache.groovy:groovy:4.0.29=androidLintTool
org.apache.httpcomponents:httpclient:4.5.6=androidLintTool
org.apache.httpcomponents:httpcore:4.4.16=androidLintTool
org.apache.httpcomponents:httpmime:4.5.6=androidLintTool
org.bouncycastle:bcpkix-jdk18on:1.80.2=androidLintTool
org.bouncycastle:bcprov-jdk18on:1.80.2=androidLintTool
org.bouncycastle:bcutil-jdk18on:1.80.2=androidLintTool
org.checkerframework:checker-qual:3.43.0=androidLintTool
org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool
org.glassfish.jaxb:txw2:2.3.2=androidLintTool
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool
org.jetbrains.kotlin:kotlin-script-runtime:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=ktlintReporter
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=ktlintReporter
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=ktlintReporter
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:2.2.0=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset
org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.4.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.4.1=ktlintReporter
org.jetbrains.kotlinx:kotlinx-serialization-core:1.4.1=ktlintReporter
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.4.1=ktlintReporter
org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1=ktlintReporter
org.jetbrains:annotations:13.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset
org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-gradle-work-action
org.junit.platform:junit-platform-commons:1.13.3=unified-test-platform-gradle-work-action
org.junit.platform:junit-platform-engine:1.13.3=unified-test-platform-gradle-work-action
org.junit.platform:junit-platform-launcher:1.13.3=unified-test-platform-gradle-work-action
org.junit:junit-bom:5.13.3=unified-test-platform-gradle-work-action
org.jvnet.staxex:stax-ex:1.8.1=androidLintTool
org.opentest4j:opentest4j:1.3.0=unified-test-platform-gradle-work-action
org.ow2.asm:asm-analysis:9.9=androidLintTool
org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm:9.9=androidLintTool
org.slf4j:slf4j-api:2.0.7=ktlint
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
@@ -0,0 +1,13 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
xjMEZZIAgBYJKwYBBAHaRw8BAQdANd08TxPj0ZuIl1cMvEf0FMCxubDwlzXDLX6w
9M+959XNJUJvYiBGaXh0dXJlIDxib2JAaXJvbnN0b3JhZ2UuaW52YWxpZD7CiQQT
FggAMQUCanjntBYhBLNwJ7VvxAa9P2piKyrANJK5ktBvAhsDAh4BAwsJBwIVCAEW
AScCGQEACgkQKsA0krmS0G9BCwD/daZy2BdmNQX8TldPBtoLTrugeaFmtrcVDQZ3
AzWEMcMA/16q87Zkaw2ozanLSBcRiq4iZnBTAmU/7bUSlA2myZoHzjgEZZIAgBIK
KwYBBAGXVQEFAQEHQByJpci1aMxEcUVlw9mByZUPyaQ7kI1emdJOuN1hcLEKAwEI
B8J4BBgWCAAgBQJqeOe1AhsMFiEEs3AntW/EBr0/amIrKsA0krmS0G8ACgkQKsA0
krmS0G+2lwEAvD9R7o52H37GsfeMS15UJBeIHBaYHjpsIyfoOWq0vsEA/j0oL4IG
SIakL3z1C1KHU+4iz3rzz2vNnhgGQd7+DS8H
=vS0q
-----END PGP PUBLIC KEY BLOCK-----
@@ -0,0 +1,195 @@
package de.rfc1437.ironstorage.core
import android.os.Bundle
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** Actual native GPG and repository transactions through the production provider facade. */
class NativeCredentialProviderTest {
@Test fun lockedFillAndSavesRejectStaleRequests(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = NativeEntryViewerTest.rootName
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val session =
NativePrivateSession(context, {
PrivateDirectories(
roots[0].canonicalPath,
roots[1].canonicalPath,
roots[2].canonicalPath
)
}, NativeEntryViewerTest.MemoryKeystore())
val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val credentials = NativeCredentialRepository(authentication)
val caller =
CredentialCaller(
"example.nativeclient",
listOf("AA".repeat(32)),
"https://untrusted.example/"
)
val passphrase = "public-credential-fixture-passphrase"
suspend fun rejects(block: suspend () -> Unit) {
var rejected = false
try {
block()
} catch (_: Exception) {
rejected = true
}
assertTrue(rejected)
}
try {
withContext(Dispatchers.IO) {
session.initialize()
val setup = session.storage().androidOnboarding(session.backend())
val key =
requireNotNull(setup.generate("Credential fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
authentication.authenticated { auth ->
val editor = auth.beginCreateEntry("", "example.nativeclient", passphrase)
val page = auth.addEntryEditorField(
editor.id,
MobileEntryEditorFieldKind.FIELD,
"username",
"alice"
)
auth.saveEntryEditor(
editor.id,
page.fields.map {
MobileEntryEditorInput(
it.id,
it.name,
if (it.name ==
"username"
) {
"alice"
} else {
"fixture-password"
}
)
}
)
}
withContext(Dispatchers.Main.immediate) { authentication.lock() }
val request = credentials.begin(caller)
assertEquals("example.nativeclient", request.title)
assertFalse(authentication.state.value.unlocked)
assertTrue(request.candidates.single().matched)
assertEquals("example.nativeclient", request.candidates.single().path)
rejects {
credentials.fill(
request.token,
caller,
"example.nativeclient",
false,
passphrase,
emptyList()
)
}
rejects {
credentials.fill(
request.token,
caller,
"example.nativeclient",
true,
null,
emptyList()
)
}
val fillStart = SystemClock.elapsedRealtime()
val value = credentials.fill(
request.token,
caller,
"example.nativeclient",
true,
passphrase,
emptyList()
)
val fillMs = SystemClock.elapsedRealtime() - fillStart
assertTrue("Authenticated provider fill must finish within 5 s", fillMs < 5000)
InstrumentationRegistry.getInstrumentation().sendStatus(
0,
Bundle().apply { putLong("ironstorage_provider_authenticated_fill_ms", fillMs) }
)
assertEquals("alice", value.username)
assertEquals("fixture-password", value.password)
assertEquals("CredentialValue([REDACTED])", value.toString())
rejects {
credentials.fill(
request.token,
caller,
"example.nativeclient",
true,
null,
emptyList()
)
}
val cancelled = credentials.begin(caller)
credentials.cancel(cancelled.token)
rejects { credentials.request(cancelled.token, caller) }
val stale = credentials.begin(caller)
authentication.authenticated {
it.replaceEntryField("example.nativeclient", 0u, "changed-password")
}
rejects {
credentials.fill(
stale.token,
caller,
"example.nativeclient",
true,
null,
emptyList()
)
}
val saving = credentials.begin(caller)
credentials.stage(saving.token, caller, "bob", "new-password")
rejects {
credentials.save(saving.token, caller, "example.nativeclient", true, false, null)
}
credentials.save(saving.token, caller, "example.nativeclient", true, true, null)
val updated = credentials.begin(caller)
assertEquals(
"bob",
credentials.fill(
updated.token,
caller,
"example.nativeclient",
true,
null,
emptyList()
).username
)
val locked = credentials.begin(caller)
withContext(Dispatchers.Main.immediate) { authentication.lock() }
rejects {
credentials.fill(
locked.token,
caller,
"example.nativeclient",
true,
passphrase,
emptyList()
)
}
} finally {
withContext(Dispatchers.Main.immediate) { authentication.lock() }
scope.cancel()
withContext(Dispatchers.IO) { roots.forEach { it.deleteRecursively() } }
}
}
}
@@ -0,0 +1,705 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Actual encrypted Rust documents and Kotlin facade, with deterministic OS capabilities. */
class NativeEntryViewerTest {
companion object {
// Rust activates one private root per process; each test recreates this disposable root.
internal val rootName = "native-controller-${UUID.randomUUID()}"
}
@Test fun fieldsEditingCopyMutationsAndTotpExpiryUseTheNativeOwner(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = rootName
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath
}
val keystore = MemoryKeystore()
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, keystore)
withContext(Dispatchers.IO) { session.initialize() }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val clipboard = MemoryClipboard()
val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val repository =
NativePasswordRepository(session, scope, authentication, Dispatchers.IO, clipboard)
val password = "public-viewer-passphrase"
suspend fun request(action: () -> Unit): PasswordState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { repository.state.first { !it.loading } }
}
fun fields() = requireNotNull(repository.state.value.sections).flatMap { it.fields }
suspend fun open() {
val navigation = request { repository.restore("", null, null) }
val row = navigation.rows.single { it.title == "viewer" }
request { repository.activate(row.id, navigation.revision) }
assertNull(repository.state.value.sections)
val page = request { repository.unlock(password) }
assertNull(page.error)
assertNotNull(page.sections)
}
try {
withContext(Dispatchers.Main.immediate) { authentication.reload() }
withTimeout(30000) {
authentication.state.first {
!it.busy && it.failure?.kind == AuthenticationFailureKind.Configuration
}
}
val setup = NativeOnboardingRepository(
session,
scope,
NativeFixtureShellRepository(session, scope),
authentication
)
withTimeout(30000) { setup.setup.first { !it.busy } }
withContext(Dispatchers.Main.immediate) {
setup.generate("Viewer fixture", password)
}
val generated = withTimeout(30000) {
setup.setup.first { !it.busy && it.keys.isNotEmpty() }
}
withContext(Dispatchers.Main.immediate) {
setup.createLocal(generated.selectedKey, password)
}
withTimeout(30000) { setup.setup.first { !it.busy && it.configured } }
withTimeout(30000) {
authentication.state.first { !it.busy && it.failure == null }
}
assertFalse(authentication.state.value.unlocked)
repeat(12) {
coroutineScope {
val onboarding = async(Dispatchers.IO) {
session.storage().androidOnboarding(session.backend()).use { it.status() }
}
val home = async(Dispatchers.IO) {
session.storage().androidHomeOperation(
session.backend(),
object : MobileClock {
override fun monotonicMilliseconds(): ULong =
android.os.SystemClock.elapsedRealtime().toULong()
}
).use { it.cached() }
}
assertTrue(onboarding.await().configured)
home.await()
}
}
for (appearance in Appearance.entries) {
withContext(Dispatchers.Main.immediate) { authentication.setAppearance(appearance) }
withTimeout(30000) {
authentication.state.first {
!it.busy &&
it.appearance == appearance
}
}
assertEquals(appearance, authentication.state.value.appearance)
assertFalse(authentication.state.value.unlocked)
}
val restoredAppearance = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
withContext(Dispatchers.Main.immediate) { restoredAppearance.reload() }
withTimeout(30000) {
restoredAppearance.state.first {
!it.busy &&
it.appearance == Appearance.Dark
}
}
assertFalse(restoredAppearance.state.value.unlocked)
authentication.authenticated { auth ->
val draft = auth.beginCreateEntry("", "viewer", password)
val first = draft.page.fields.first()
auth.updateEntryEditor(
draft.id,
listOf(MobileEntryEditorInput(first.id, first.name, "fixture-secret"))
)
auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.FIELD,
"PIN",
"123493"
)
auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.FIELD,
"custom",
"Unicode Ελληνικά 日本語"
)
auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.FIELD,
"custom",
"Repeated field"
)
auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.NOTE,
null,
"First line\nSecond line"
)
val page = auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
"otpauth://totp/Fixture:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Fixture"
)
auth.saveEntryEditor(
draft.id,
page.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
}
)
auth.createDirectory("", "destination")
auth.setAuthenticationTimeout(30u)
}
val search =
NativeSearchRepository(session, scope, Dispatchers.IO, authentication.presentation)
withContext(Dispatchers.Main.immediate) {
authentication.lock()
search.query("VIEWER")
}
val searched = withTimeout(30000) {
search.state.first {
!it.loading &&
it.query == "VIEWER"
}
}
assertNull(searched.error)
val result = searched.results.single()
assertEquals("viewer", result.path)
assertFalse(authentication.state.value.unlocked)
val selected = request {
repository.openSearch(result.id, result.revision)
val immediate = repository.state.value
assertNull(immediate.sections)
if (immediate.loading) {
assertEquals("", immediate.revision)
assertNull(immediate.selected)
} else {
assertEquals(result.id, immediate.selected?.id)
}
}
assertEquals(result.id, selected.selected?.id)
assertNull(selected.sections)
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
assertEquals("VIEWER", search.state.value.query)
authentication.lock()
assertEquals("", search.state.value.query)
}
open()
val sensitive = fields().filter { it.sensitive }
assertEquals(3, sensitive.size)
assertTrue(sensitive.all { it.value == "Hidden" && !it.revealed && !it.selectable })
assertEquals(2, fields().count { it.label == "Custom" })
assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline })
assertNotNull(repository.state.value.otp)
assertTrue(repository.state.value.otp!!.remainingSeconds!! in 1uL..30uL)
for (event in listOf(
AuthenticationLifecycle.Background,
AuthenticationLifecycle.DeviceLocked,
AuthenticationLifecycle.MemoryPressure,
AuthenticationLifecycle.ProcessRecreated
)) {
val field = fields().first()
request { repository.edit(field.id) }
request { repository.copy(field.id) }
withTimeout(10000) { while (clipboard.value == null) delay(20) }
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(event)
assertNull(repository.state.value.sections)
assertNull(repository.state.value.edit)
assertNull(repository.state.value.otp)
assertNull(clipboard.value)
assertFalse(authentication.state.value.unlocked)
}
request { repository.unlock(password) }
assertNotNull(repository.state.value.sections)
}
val pin = sensitive.single { it.label == "PIN" }
request { repository.reveal(pin.id) }
assertEquals("123493", fields().single { it.id == pin.id }.value)
request { repository.hide(pin.id) }
assertEquals("Hidden", fields().single { it.id == pin.id }.value)
request { repository.copy(pin.id) }
withTimeout(10000) { while (clipboard.value == null) delay(20) }
assertEquals("123493", clipboard.value!!.toString(Charsets.UTF_8))
request { repository.edit(pin.id) }
assertEquals(pin.id, repository.state.value.edit!!.id)
request { repository.saveEdit("654321") }
assertNull(repository.state.value.error)
assertNull(repository.state.value.edit)
assertEquals("Hidden", fields().single { it.id == pin.id }.value)
request { repository.reveal(pin.id) }
assertEquals("654321", fields().single { it.id == pin.id }.value)
request { repository.prepareMutation(PasswordMutation.Copy) }
val target = repository.state.value.mutation!!.destinations.single {
it.path ==
"destination"
}
request { repository.mutate(target.path, false) }
assertNull(repository.state.value.error)
request { repository.restore("destination", null, null) }
assertEquals("viewer", repository.state.value.rows.single().title)
open()
request { repository.prepareMutation(PasswordMutation.Delete) }
request { repository.mutate(null, false) }
assertTrue(repository.state.value.rows.none { it.title == "viewer" })
val copied = request { repository.restore("destination", null, null) }
request { repository.activate(copied.rows.single().id, copied.revision) }
request { repository.unlock(password) }
request { repository.edit(fields().first().id) }
request { repository.lock() }
assertNull(repository.state.value.sections)
assertNull(repository.state.value.edit)
assertNull(repository.state.value.otp)
withTimeout(10000) { while (clipboard.value != null) delay(20) }
request { repository.unlock(password) }
authentication.authenticated {
it.replaceEntryField("destination/viewer", pin.id, "stale external update")
}
request { repository.reveal(pin.id) }
assertNull(repository.state.value.sections)
withTimeout(10000) { authentication.state.first { !it.unlocked } }
assertFalse(authentication.state.value.unlocked)
val changed = request { repository.restore("destination", null, null) }
request { repository.activate(changed.rows.single().id, changed.revision) }
request { repository.unlock(password) }
authentication.authenticated { it.setAuthenticationTimeout(2u) }
withTimeout(10000) { authentication.state.first { !it.unlocked } }
withTimeout(10000) { repository.state.first { it.sections == null } }
assertNull(repository.state.value.otp)
assertFalse(authentication.state.value.unlocked)
authentication.authenticated { it.setAuthenticationTimeout(300u) }
structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
authentication,
repository,
scope,
password
)
settingsUseTheSameNativeOwnerAndProtectedTransfer(
session,
authentication,
scope,
password,
keystore
)
withContext(Dispatchers.IO) {
val vault = File(roots[1], "ironstorage/vault")
val ciphertext = File(vault, "editor/new.gpg").readBytes()
repeat(50) { directory ->
val folder = File(vault, "load/slot-${directory.toString().padStart(3, '0')}")
assertTrue(folder.mkdirs())
repeat(200) { entry -> File(folder, "entry-$entry.gpg").writeBytes(ciphertext) }
}
val storage = session.storage()
val navigateStart = android.os.SystemClock.elapsedRealtime()
assertEquals(50, storage.passwordNavigation("load", null, null, false).rows.size)
assertEquals(
200,
storage.passwordNavigation("load/slot-000", null, null, false).rows.size
)
val navigateMs = android.os.SystemClock.elapsedRealtime() - navigateStart
assertTrue("10000-entry navigation must finish in 3 s", navigateMs < 3000)
val searchStart = android.os.SystemClock.elapsedRealtime()
assertEquals(10000, storage.passwordNameSearch("load").size)
assertEquals(200, storage.passwordNameSearch("load/slot-019").size)
val searchMs = android.os.SystemClock.elapsedRealtime() - searchStart
assertTrue("10000-entry name search must finish in 5 s", searchMs < 5000)
performance("tree_10000_navigation_ms", navigateMs)
performance("tree_10000_search_ms", searchMs)
}
} finally {
withContext(Dispatchers.Main.immediate) { repository.lock() }
scope.cancel()
roots.forEach { assertTrue(File(it).deleteRecursively()) }
}
}
private fun performance(name: String, value: Long) {
android.util.Log.i("IronStoragePerformance", "$name=$value")
InstrumentationRegistry.getInstrumentation().sendStatus(
0,
android.os.Bundle().apply { putLong("ironstorage_$name", value) }
)
}
private suspend fun structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
authentication: NativeAuthenticationRepository,
passwords: NativePasswordRepository,
scope: CoroutineScope,
passphrase: String
) {
// The native private root is intentionally immutable for the life of this test process.
val editor = NativeEditorRepository(authentication, passwords, scope)
suspend fun request(action: () -> Unit): EditorState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { editor.state.first { !it.busy } }
}
try {
assertNull(request { editor.createFolder("", "editor") }.error)
assertNull(request { editor.create("editor", "new", passphrase) }.error)
val first = editor.state.value.fields.first().id
request { editor.change(first, null, "initial password") }
request { editor.add(EditorFieldKind.Field, "custom", "Unicode 日本語") }
request { editor.add(EditorFieldKind.Field, "custom", "repeated") }
request { editor.add(EditorFieldKind.Field, "PIN", "123493") }
request { editor.add(EditorFieldKind.Note, null, "first note\nsecond note") }
request {
editor.add(
EditorFieldKind.OtpUri,
null,
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example"
)
}
assertNull(editor.state.value.error)
assertTrue(editor.state.value.fields.single { it.name == "PIN" }.sensitive)
val custom = editor.state.value.fields.first { it.name == "custom" }.id
request { editor.reorder(custom, 2u) }
assertEquals(custom, editor.state.value.fields[2].id)
request { editor.add(EditorFieldKind.Field, "temporary", "remove me") }
val temporary = editor.state.value.fields.single { it.name == "temporary" }.id
request { editor.remove(temporary) }
assertTrue(editor.state.value.fields.none { it.id == temporary })
request { editor.generate(24u, true) }
val generated = requireNotNull(editor.state.value.fields.first().value)
assertEquals(24, generated.length)
assertTrue(generated.all { it.isLetterOrDigit() && it.code < 128 })
assertNull(request { editor.save() }.error)
assertFalse(editor.state.value.open)
withContext(Dispatchers.Main.immediate) { passwords.restore("editor", null, null) }
val navigation = withTimeout(30000) { passwords.state.first { !it.loading } }
val row = navigation.rows.single { it.title == "new" }
withContext(Dispatchers.Main.immediate) {
passwords.activate(row.id, navigation.revision)
}
withTimeout(30000) { passwords.state.first { !it.loading } }
withContext(Dispatchers.Main.immediate) { passwords.unlock(passphrase) }
withTimeout(30000) { passwords.state.first { !it.loading } }
assertNotNull(passwords.state.value.sections)
assertNull(request { editor.begin("editor/new") }.error)
assertNull(passwords.state.value.sections)
assertEquals(generated, editor.state.value.fields.first().value)
assertEquals(2, editor.state.value.fields.count { it.name == "custom" })
assertEquals(
"first note\nsecond note",
editor.state.value.fields.single {
it.kind ==
EditorFieldKind.Note
}.value
)
assertEquals("123493", editor.state.value.fields.single { it.name == "PIN" }.value)
val field = editor.state.value.fields.first { it.name == "custom" }
request { editor.change(field.id, "bad\nname", "retained after validation") }
assertNotNull(request { editor.save() }.error)
assertEquals(
"retained after validation",
editor.state.value.fields.single {
it.id ==
field.id
}.value
)
request { editor.change(field.id, "custom", "retained after validation") }
assertNull(request { editor.save() }.error)
request { editor.begin("editor/new") }
val passwordId = editor.state.value.fields.first().id
val typingStart = android.os.SystemClock.elapsedRealtimeNanos()
val updateTimes = LongArray(100)
withContext(Dispatchers.Main.immediate) {
repeat(100) {
val start = android.os.SystemClock.elapsedRealtimeNanos()
editor.change(passwordId, null, "public typing $it")
updateTimes[it] = android.os.SystemClock.elapsedRealtimeNanos() - start
}
editor.change(passwordId, null, "in-memory recovered secret")
}
val typingMs = (android.os.SystemClock.elapsedRealtimeNanos() - typingStart) / 1_000_000
assertTrue("100 input updates must finish within 500 ms", typingMs < 500)
val updateP95Us = updateTimes.sorted()[94] / 1000
assertTrue("Input update p95 must fit within 32 ms", updateP95Us < 32000)
performance("editor_100_updates_ms", typingMs)
performance("editor_update_p95_us", updateP95Us)
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
}
assertTrue(editor.state.value.locked)
assertTrue(editor.state.value.fields.isEmpty())
assertFalse(authentication.state.value.unlocked)
assertNull(request { editor.resume(passphrase) }.error)
assertEquals("in-memory recovered secret", editor.state.value.fields.first().value)
assertTrue(editor.state.value.dirty)
var navigated = false
request { editor.requestClose { navigated = true } }
assertTrue(editor.state.value.discardRequested)
assertFalse(navigated)
request { editor.keepEditing() }
assertTrue(editor.state.value.open)
assertFalse(navigated)
request { editor.requestClose { navigated = true } }
request { editor.confirmDiscard() }
withContext(Dispatchers.Main.immediate) { assertTrue(navigated) }
request { editor.begin("editor/new") }
assertEquals(
"discard cannot change the stored entry",
generated,
editor.state.value.fields.first().value
)
authentication.authenticated { it.setAuthenticationTimeout(2u) }
withTimeout(10000) { editor.state.first { it.locked } }
assertTrue(editor.state.value.fields.isEmpty())
authentication.authenticated { it.setAuthenticationTimeout(300u) }
assertNull(request { editor.resume(passphrase) }.error)
request { editor.requestClose() }
if (editor.state.value.discardRequested) request { editor.confirmDiscard() }
assertFalse(editor.state.value.open)
} finally {
withContext(Dispatchers.Main.immediate) { authentication.lock() }
}
}
private suspend fun settingsUseTheSameNativeOwnerAndProtectedTransfer(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository,
scope: CoroutineScope,
passphrase: String,
keystore: MemoryKeystore
) {
var image = ByteArray(0)
var cameraReceiver: ((CameraFrame) -> Unit)? = null
val platform = object : SettingsPlatform {
override fun autofill() = ProviderStatus(true, false, false)
override fun credentials() =
ProviderStatus(android.os.Build.VERSION.SDK_INT >= 34, false, false)
override fun biometricsAvailable() = false
override fun version() = "native-settings-fixture"
override fun openAutofill() = true
override fun openCredentials() = true
override fun cameraPreview(
frame: (CameraFrame) -> Unit,
failure: () -> Unit
): CameraPreview? {
cameraReceiver = frame
return null
}
override fun chooseQrImages() = true
override suspend fun readQrImage(uri: String) = image.copyOf()
}
val settings = NativeSettingsRepository(session, authentication, platform, scope)
suspend fun request(action: () -> Unit): SettingsState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { settings.state.first { !it.busy } }
}
val initial = request { settings.reload() }
assertNull(initial.error)
assertEquals("native-settings-fixture", initial.page!!.version)
assertFalse(initial.page.biometricAvailable)
assertNull(
request {
settings.setGitIdentity("Android settings author", "android-settings@example.test")
}.error
)
assertEquals("Android settings author", settings.state.value.page!!.gitName)
assertNotNull(request { settings.setGitIdentity("candidate", "invalid\nemail") }.error)
assertEquals("Android settings author", settings.state.value.page!!.gitName)
assertNull(request { settings.setTimeout("45") }.error)
assertEquals(45uL, settings.state.value.page!!.timeout)
assertNotNull(request { settings.setTimeout("0") }.error)
assertEquals(45uL, settings.state.value.page!!.timeout)
assertNotNull(request { settings.removeToken() }.error)
assertNull(settings.state.value.page!!.account)
val cacheStart = android.os.SystemClock.elapsedRealtime()
assertNull(request { settings.rebuildCache(passphrase) }.error)
val cacheMs = android.os.SystemClock.elapsedRealtime() - cacheStart
assertTrue("Fixture cache rebuild must finish in 15 s", cacheMs < 15000)
performance("cache_rebuild_ms", cacheMs)
assertTrue(settings.state.value.page!!.cacheDetail.contains("current TOTP entries"))
val beforeEnrollment = keystore.keyCount
authentication.authenticated { it.enrollBiometricUnlock(passphrase) }
assertTrue(authentication.state.value.biometricEnabled)
assertEquals(beforeEnrollment + 1, keystore.keyCount)
withContext(Dispatchers.IO) {
session.storage().androidHomeOperation(
session.backend(),
object : MobileClock {
override fun monotonicMilliseconds(): ULong =
android.os.SystemClock.elapsedRealtime().toULong()
}
).use { home ->
assertEquals("Android settings author", home.identity().name)
home.cached()
}
}
assertTrue(authentication.authenticated { it.state().unlocked })
keystore.invalidated = true
authentication.authenticated { it.setBiometricUnlock(false) }
assertFalse(authentication.state.value.biometricEnabled)
assertFalse(authentication.state.value.unlocked)
assertEquals(beforeEnrollment, keystore.keyCount)
keystore.invalidated = false
assertNull(request { settings.beginTransfer() }.error)
val fingerprint = settings.state.value.transfer.keys.first { it.privateKey }.fingerprint
assertNotNull(request { settings.exportKey(fingerprint, true, "incorrect") }.error)
assertTrue(settings.state.value.transfer.frames.isEmpty())
assertNull(request { settings.exportKey(fingerprint, false, "") }.error)
val frame = settings.state.value.transfer.frames.single()
val size = (frame.width.toInt() + 8) * 4
val pixels = IntArray(size * size) { android.graphics.Color.WHITE }
for (row in 0 until frame.width.toInt()) {
for (column in 0 until frame.width.toInt()) {
if (frame.modules[row * frame.width.toInt() + column].toInt() == 1) {
for (dy in 0 until 4) {
for (dx in 0 until 4) {
pixels[((row + 4) * 4 + dy) * size + (column + 4) * 4 + dx] =
android.graphics.Color.BLACK
}
}
}
}
}
val bitmap = android.graphics.Bitmap.createBitmap(
pixels,
size,
size,
android.graphics.Bitmap.Config.ARGB_8888
)
val output = java.io.ByteArrayOutputStream()
assertTrue(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output))
bitmap.recycle()
image = output.toByteArray()
request { settings.closeTransfer() }
assertTrue(frame.modules.all { it.toInt() == 0 })
assertNull(request { settings.beginTransfer() }.error)
withContext(Dispatchers.Main.immediate) { settings.cameraPreview() }
val luminance = ByteArray(size * size) { index ->
if (pixels[index] == android.graphics.Color.BLACK) 0 else 255.toByte()
}
requireNotNull(
cameraReceiver
)(CameraFrame(luminance, size.toUInt(), size.toUInt(), size.toUInt(), 1u, 90u))
withTimeout(30000) { settings.state.first { it.transfer.ready != null } }
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
assertTrue(luminance.all { it.toInt() == 0 })
request { settings.closeTransfer() }
val qrStart = android.os.SystemClock.elapsedRealtime()
assertNull(request { settings.addQrImages(listOf("content://public-fixture/qr")) }.error)
val qrMs = android.os.SystemClock.elapsedRealtime() - qrStart
assertTrue("Fixture QR analysis must finish in 5 s", qrMs < 5000)
performance("qr_analysis_ms", qrMs)
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
assertNotNull(request { settings.importKey("", false, false) }.error)
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
request { settings.closeTransfer() }
assertNull(request { settings.beginTransfer() }.error)
val armor = InstrumentationRegistry.getInstrumentation().context.assets.open(
"bob-public.asc"
).bufferedReader().use {
it.readText()
}
assertNull(request { settings.addQrPayload(armor) }.error)
assertFalse(settings.state.value.transfer.ready!!.privateKey)
assertNull(request { settings.importKey("", false, false) }.error)
assertFalse(settings.state.value.transfer.open)
assertFalse(authentication.state.value.unlocked)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
assertNull(request { settings.beginTransfer() }.error)
val bob = settings.state.value.transfer.keys.single { it.title.contains("Bob") }
assertNotNull(request { settings.setDefaultKey(bob.fingerprint) }.error)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
assertNull(request { settings.beginTransfer() }.error)
assertNull(request { settings.exportKey(fingerprint, true, passphrase) }.error)
assertTrue(settings.state.value.transfer.exportedKey!!.privateKey)
val privateFrames = settings.state.value.transfer.frames
assertTrue(privateFrames.isNotEmpty())
withContext(Dispatchers.Main.immediate) { authentication.presentation.clear() }
assertFalse(settings.state.value.transfer.open)
assertTrue(settings.state.value.transfer.frames.isEmpty())
assertTrue(privateFrames.all { frame -> frame.modules.all { it.toInt() == 0 } })
assertNull(request { settings.setDefaultKey(fingerprint) }.error)
assertFalse(authentication.state.value.unlocked)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
request { settings.setTimeout("300") }
}
internal class MemoryClipboard : SecretClipboard {
override fun beginCopy(): SecretClipboard = this
@Volatile var value: ByteArray? = null
override fun read() = value?.copyOf()
override fun write(value: ByteArray) {
this.value = value.copyOf()
}
override fun clear() {
value = null
}
}
internal class MemoryKeystore : AndroidKeystore {
private val keys = mutableSetOf<String>()
val keyCount get() = keys.size
var invalidated = false
override fun deviceState() =
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, true, false)
override fun createKey(specification: KeystoreKeySpecification) {
keys.add(specification.alias)
}
private data class Record(val alias: String, val aad: ByteArray, val secret: ByteArray)
private val ciphertexts = mutableMapOf<List<Byte>, Record>()
override fun encrypt(
alias: String,
authorization: KeystoreAuthorization,
aad: ByteArray,
value: ByteArray
): KeystoreCiphertext {
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
val seed = UUID.randomUUID().toString().toByteArray()
val token = ByteArray(value.size + 16) { seed[it % seed.size] }
ciphertexts[token.toList()] = Record(alias, aad.copyOf(), value.copyOf())
return KeystoreCiphertext(token.take(12).toByteArray(), token)
}
override fun decrypt(
alias: String,
authorization: KeystoreAuthorization,
aad: ByteArray,
value: KeystoreCiphertext
): ByteArray {
if (invalidated && authorization == KeystoreAuthorization.StrongBiometric) {
throw KeystoreException(KeystoreFailure.Invalidated)
}
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
val record =
ciphertexts[value.bytes.toList()]
?: throw KeystoreException(KeystoreFailure.Corrupted)
if (record.alias != alias || !record.aad.contentEquals(aad) ||
!value.nonce.contentEquals(value.bytes.take(12).toByteArray())
) {
throw KeystoreException(KeystoreFailure.Corrupted)
}
return record.secret.copyOf()
}
override fun deleteKey(alias: String) {
keys.remove(alias)
}
override fun aliases() = keys.toList()
override fun cancelPending() = Unit
}
}
@@ -0,0 +1,187 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeOtpPresentationTest {
@Test fun catalogCodesBoundariesHotpAndLifecycleUseRust(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = NativeEntryViewerTest.rootName
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath
}
val monotonic = AtomicLong(0)
val wall = AtomicLong(59)
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, NativeEntryViewerTest.MemoryKeystore())
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val memoryClipboard = NativeEntryViewerTest.MemoryClipboard()
var expireCopy = false
val clipboard = object : SecretClipboard by memoryClipboard {
override fun beginCopy(): SecretClipboard {
if (expireCopy) wall.set(60)
return memoryClipboard
}
}
val authentication =
NativeAuthenticationRepository(
session,
scope,
Dispatchers.IO,
object : MobileClock {
override fun monotonicMilliseconds() = monotonic.get().toULong()
}
)
val viewer = NativePasswordRepository(
session,
scope,
authentication,
Dispatchers.IO,
clipboard,
{ wall.get().toULong() }
)
val catalog = NativeTotpRepository(session, authentication, viewer, scope)
val passphrase = "public-otp-fixture"
suspend fun view(action: () -> Unit): PasswordState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { viewer.state.first { !it.loading } }
}
try {
withContext(Dispatchers.IO) {
session.initialize()
val setup = session.storage().androidOnboarding(session.backend())
val key = requireNotNull(setup.generate("OTP fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
authentication.authenticated { auth ->
for ((entry, uri) in listOf(
"timed" to
"otpauth://totp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&digits=8&issuer=RFC",
"counter" to
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0&issuer=RFC"
)) {
val draft = auth.beginCreateEntry("", entry, passphrase)
val page = auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
uri
)
auth.saveEntryEditor(
draft.id,
page.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
}
)
}
auth.setAuthenticationTimeout(2u)
}
withContext(Dispatchers.Main.immediate) {
authentication.lock()
catalog.enter(null, null)
}
val cached = withTimeout(30000) { catalog.state.first { !it.loading } }
assertNull(cached.error)
assertTrue(cached.hasCatalog)
assertEquals(listOf("timed"), cached.rows.map { it.title })
assertFalse(authentication.state.value.unlocked)
assertNull(viewer.state.value.otp)
val row = cached.rows.single()
view { catalog.open(row.id, row.revision) }
assertNull(viewer.state.value.otp)
val opened = view { viewer.unlock(passphrase) }
assertNull(opened.error)
assertEquals("9428 7082", requireNotNull(opened.otp).groupedCode)
assertEquals(1uL, requireNotNull(opened.otp).remainingSeconds)
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
val observation = scope.async {
runCatching {
authentication.observe { auth ->
val value = auth.state()
entered.countDown()
assertTrue(release.await(10, TimeUnit.SECONDS))
value
}
}
}
withContext(Dispatchers.IO) { assertTrue(entered.await(10, TimeUnit.SECONDS)) }
val userAction = scope.async { authentication.authenticated { it.state() } }
withTimeout(10000) { authentication.state.first { it.busy } }
release.countDown()
assertTrue(observation.await().isFailure)
assertTrue(userAction.await().unlocked)
assertTrue(authentication.state.value.unlocked)
view { viewer.copyOtp() }
withTimeout(10000) { while (memoryClipboard.value == null) delay(20) }
assertEquals("94287082", memoryClipboard.value!!.toString(Charsets.UTF_8))
withContext(Dispatchers.Main.immediate) { memoryClipboard.clear() }
expireCopy = true
val rejectedCopy = view { viewer.copyOtp() }
assertTrue(rejectedCopy.error != null)
assertNull(memoryClipboard.value)
expireCopy = false
wall.set(60)
monotonic.set(1000)
withContext(Dispatchers.Main.immediate) { viewer.tickOtp() }
withTimeout(30000) { viewer.state.first { it.otp?.remainingSeconds == 30uL } }
assertEquals(30uL, viewer.state.value.otp!!.remainingSeconds)
assertNotEquals("94287082", viewer.state.value.otp!!.code)
monotonic.set(2000)
withTimeout(10000) {
authentication.state.first { !it.unlocked }
}
assertFalse(authentication.state.value.unlocked)
assertNull(memoryClipboard.value)
view { viewer.restore("", null, null) }
val counter = viewer.state.value.rows.single { it.title == "counter" }
view { viewer.activate(counter.id, viewer.state.value.revision) }
val pending = view { viewer.unlock(passphrase) }
assertEquals(PasswordOtpKind.Hotp, requireNotNull(pending.otp).kind)
assertNull(requireNotNull(pending.otp).code)
assertEquals(1uL, requireNotNull(pending.otp).nextCounter)
val generated = view { viewer.advanceHotp() }
assertNull(generated.error)
assertEquals("287 082", requireNotNull(generated.otp).groupedCode)
assertEquals(1uL, requireNotNull(generated.otp).counter)
view { viewer.refreshOtp() }
view { viewer.copyOtp() }
assertEquals(1uL, viewer.state.value.otp!!.counter)
val next = view { viewer.advanceHotp() }
assertEquals("359152", requireNotNull(next.otp).code)
assertEquals(2uL, requireNotNull(next.otp).counter)
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
assertNull(viewer.state.value.otp)
assertNull(viewer.state.value.sections)
assertNull(memoryClipboard.value)
}
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
roots.forEach { File(it).deleteRecursively() }
}
}
}
@@ -0,0 +1,142 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeTotpDiscoveryTest {
@Test fun cachedSearchRefreshRecoveryAndProcessRecreationUseRust(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, NativeEntryViewerTest.rootName).apply { mkdirs() }
}
val directories =
PrivateDirectories(
roots[0].canonicalPath,
roots[1].canonicalPath,
roots[2].canonicalPath
)
val keystore = NativeEntryViewerTest.MemoryKeystore()
val session = NativePrivateSession(context, { directories }, keystore)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val auth = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val viewer =
NativePasswordRepository(
session,
scope,
auth,
Dispatchers.IO,
NativeEntryViewerTest.MemoryClipboard()
)
val catalog = NativeTotpRepository(session, auth, viewer, scope)
val passphrase = "public-discovery-fixture"
suspend fun catalogAction(action: () -> Unit): TotpState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { catalog.state.first { !it.loading } }
}
try {
withContext(Dispatchers.IO) {
val setup = session.storage().androidOnboarding(session.backend())
val key =
requireNotNull(setup.generate("Discovery fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
auth.authenticated { native ->
for (name in listOf("first", "second", "ordinary")) {
val editor = native.beginCreateEntry("", name, passphrase)
val page = if (name != "ordinary") {
native.addEntryEditorField(
editor.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
"otpauth://totp/Public:$name?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
)
} else {
editor.page
}
native.saveEntryEditor(
editor.id,
page.fields.map {
MobileEntryEditorInput(
it.id,
it.name,
if (it.kind == MobileEntryEditorFieldKind.PASSWORD) {
"public-discovery-password"
} else {
it.value.orEmpty()
}
)
}
)
}
}
withContext(Dispatchers.Main.immediate) { auth.lock() }
assertEquals(2, catalogAction { catalog.enter(null, null) }.rows.size)
assertFalse(auth.state.value.unlocked)
val warm = catalogAction { catalog.discover("") }
assertNull(warm.error)
assertEquals(TotpDiscoveryPhase.Complete, warm.progress!!.phase)
assertEquals(3L, warm.progress.cacheHits)
assertFalse(auth.state.value.unlocked)
val latest = catalogAction {
catalog.query("first")
catalog.query("second")
catalog.query("missing")
}
assertEquals("missing", latest.query)
assertTrue(latest.rows.isEmpty())
assertEquals("first", catalogAction { catalog.query(" FIRST ") }.rows.single().title)
assertFalse(auth.state.value.unlocked)
val cache = File(roots[1], "ironstorage/totp-catalog.toml")
assertTrue(cache.isFile)
assertFalse(cache.readText().contains("otpauth://"))
// Only this isolated test catalog is damaged; production state is never reset.
cache.writeText("damaged fixture catalog")
assertFalse(catalogAction { catalog.query("") }.hasCatalog)
withContext(Dispatchers.Main.immediate) {
catalog.discover(passphrase)
catalog.cancel()
}
assertEquals(TotpDiscoveryPhase.Cancelled, catalog.state.value.progress!!.phase)
val recovered = catalogAction { catalog.discover(passphrase) }
assertNull(recovered.error)
assertEquals(2, recovered.rows.size)
assertEquals(0L, recovered.progress!!.cacheHits)
assertEquals(3L, recovered.progress.inspected)
withContext(Dispatchers.Main.immediate) { auth.lock() }
val reopened = NativePrivateSession(context, { directories }, keystore)
val recreatedAuth = NativeAuthenticationRepository(reopened, scope, Dispatchers.IO)
val recreatedViewer =
NativePasswordRepository(
reopened,
scope,
recreatedAuth,
Dispatchers.IO,
NativeEntryViewerTest.MemoryClipboard()
)
val recreated = NativeTotpRepository(reopened, recreatedAuth, recreatedViewer, scope)
withContext(Dispatchers.Main.immediate) { recreated.enter(null, null) }
val retained = withTimeout(30000) { recreated.state.first { !it.loading } }
assertNull(retained.error)
assertEquals(2, retained.rows.size)
assertFalse(recreatedAuth.state.value.unlocked)
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
roots.forEach { it.deleteRecursively() }
}
}
}
@@ -0,0 +1,126 @@
package de.rfc1437.ironstorage.core
import android.os.Bundle
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryPhase
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeTotpPerformanceTest {
@Test fun cachedCatalogAndProviderStayResponsiveWhileLocked(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, NativeEntryViewerTest.rootName).apply { mkdirs() }
}
val session = NativePrivateSession(context, {
PrivateDirectories(
roots[0].canonicalPath,
roots[1].canonicalPath,
roots[2].canonicalPath
)
}, NativeEntryViewerTest.MemoryKeystore())
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val auth = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val passphrase = "public-totp-performance-fixture"
try {
withContext(Dispatchers.IO) {
session.initialize()
val setup = session.storage().androidOnboarding(session.backend())
val key =
requireNotNull(setup.generate("Performance fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
auth.authenticated { native ->
val editor = native.beginCreateEntry("", "seed", passphrase)
val page = native.addEntryEditorField(
editor.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
"otpauth://totp/Public:seed?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
)
native.saveEntryEditor(
editor.id,
page.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
}
)
}
withContext(Dispatchers.IO) {
// Replicate only Rust-encrypted public fixture bytes in this isolated test root.
val vault = File(roots[1], "ironstorage/vault")
val ciphertext = File(vault, "seed.gpg").readBytes()
repeat(99) { File(vault, "seed-$it.gpg").writeBytes(ciphertext) }
}
auth.authenticated { native ->
mobileTotpOperation().use { operation ->
assertEquals(100u, native.refreshTotpCatalog(operation, null).progress.matches)
}
}
withContext(Dispatchers.Main.immediate) { auth.lock() }
measure("totp_cached_open_p95_us", 1_000_000) {
auth.metadata { native ->
mobileTotpOperation().use { operation ->
assertEquals(100, native.searchCachedTotpNavigation("", operation)!!.size)
}
}
assertFalse(auth.state.value.unlocked)
}
measure("totp_warm_refresh_p95_us", 3_000_000) {
auth.metadata { native ->
mobileTotpOperation().use { operation ->
val progress = native.refreshTotpCatalog(operation, null).progress
assertEquals(MobileTotpDiscoveryPhase.COMPLETE, progress.phase)
assertEquals(100u, progress.cacheHits)
assertEquals(100u, progress.matches)
}
}
assertFalse(auth.state.value.unlocked)
}
val credentials = NativeCredentialRepository(auth)
val caller =
CredentialCaller("public.performance.client", listOf("AA".repeat(32)), null)
measure("provider_prepare_p95_us", 1_000_000) {
val request = credentials.begin(caller)
try {
assertEquals(100, request.candidates.size)
assertFalse(auth.state.value.unlocked)
} finally {
credentials.cancel(request.token)
}
}
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
withContext(Dispatchers.IO) { roots.forEach { it.deleteRecursively() } }
}
}
private suspend fun measure(name: String, budgetUs: Long, action: suspend () -> Unit) {
repeat(10) { action() }
val samples = LongArray(30)
repeat(samples.size) { index ->
val start = SystemClock.elapsedRealtimeNanos()
action()
samples[index] = (SystemClock.elapsedRealtimeNanos() - start) / 1000
}
samples.sort()
val p95 = samples[28]
assertTrue("$name must finish within $budgetUs microseconds", p95 < budgetUs)
InstrumentationRegistry.getInstrumentation().sendStatus(
0,
Bundle().apply { putLong("ironstorage_$name", p95) }
)
}
}
@@ -0,0 +1,187 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeTreeMutationTest {
@Test fun nativePlansDirtyEditorsNavigationAndInterruptionRemainCoherent(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, NativeEntryViewerTest.rootName).apply { mkdirs() }.canonicalPath
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, NativeEntryViewerTest.MemoryKeystore())
val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val passwords = NativePasswordRepository(
session,
scope,
authentication,
Dispatchers.IO,
NativeEntryViewerTest.MemoryClipboard()
)
val editor =
NativeEditorRepository(authentication, passwords, scope)
val passphrase = "public-mutation-passphrase"
suspend fun request(action: () -> Unit): PasswordState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { passwords.state.first { !it.loading } }
}
try {
withContext(Dispatchers.IO) {
session.initialize()
val setup = session.storage().androidOnboarding(session.backend())
val key = requireNotNull(setup.generate("Mutation fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
authentication.authenticated { auth ->
auth.createDirectory("", "source")
auth.createDirectory("", "destination")
val draft = auth.beginCreateEntry("source", "entry", passphrase)
auth.saveEntryEditor(
draft.id,
draft.page.fields.map {
MobileEntryEditorInput(it.id, it.name, "fixture-secret")
}
)
}
withContext(Dispatchers.Main.immediate) { authentication.lock() }
val root = request { passwords.restore("", null, null) }
val source = root.rows.single { it.path == "source" }
val prepared =
request {
passwords.prepareRowMutation(source.id, source.revision, PasswordMutation.Move)
}
assertFalse(authentication.state.value.unlocked)
assertNull(prepared.sections)
assertEquals(PasswordKind.Folder, requireNotNull(prepared.mutation).kind)
assertEquals(1u, requireNotNull(prepared.mutation).entries)
assertEquals("source/", requireNotNull(prepared.mutation).source)
request { passwords.dismissMutation() }
val unchanged = request { passwords.refresh() }
assertTrue(unchanged.rows.any { it.path == "source" })
request {
passwords.prepareRowMutation(source.id, source.revision, PasswordMutation.Move)
}
assertNull(request { passwords.unlockMutation(passphrase) }.error)
withContext(Dispatchers.Main.immediate) { editor.begin("source/entry") }
val opened = withTimeout(30000) {
editor.state.first {
!it.busy &&
it.fields.isNotEmpty()
}
}
val field = opened.fields.first().id
withContext(Dispatchers.Main.immediate) { editor.change(field, null, "unsaved edit") }
withTimeout(30000) { editor.state.first { !it.busy && it.dirty } }
val dirty = withTimeout(30000) {
var page =
request {
passwords.prepareRowMutation(
source.id,
source.revision,
PasswordMutation.Move
)
}
while (page.mutation?.hasDirtyEditor != true) {
delay(20)
page =
request {
passwords.prepareRowMutation(
source.id,
source.revision,
PasswordMutation.Move
)
}
}
page
}
assertTrue(requireNotNull(dirty.mutation).hasOpenEditor)
assertTrue(requireNotNull(dirty.mutation).hasDirtyEditor)
val rejected = request { passwords.mutate("destination", false, false) }
assertNotNull(rejected.error)
assertTrue(editor.state.value.open)
assertEquals("unsaved edit", editor.state.value.fields.first().value)
val refreshedSource = rejected.rows.single { it.path == "source" }
request {
passwords.prepareRowMutation(
refreshedSource.id,
refreshedSource.revision,
PasswordMutation.Move
)
}
val moved = request { passwords.mutate("destination", false, true) }
assertNull(moved.error)
assertEquals("destination/source", moved.path)
assertFalse(editor.state.value.open)
assertTrue(editor.state.value.fields.isEmpty())
val entry = moved.rows.single()
request { passwords.activate(entry.id, moved.revision) }
request { passwords.unlock(passphrase) }
val password = passwords.state.value.sections!!.first().fields.first().id
val revealed = request { passwords.reveal(password) }
assertEquals("fixture-secret", revealed.sections!!.first().fields.first().value)
request { passwords.prepareMutation(PasswordMutation.Copy) }
val copied = request { passwords.mutate("destination", false, false) }
assertNull(copied.error)
assertEquals("destination", copied.path)
val selected = requireNotNull(copied.selected)
authentication.authenticated { auth ->
auth.replaceEntryField("destination/entry", 0u, "replacement-secret")
}
val stale =
request {
passwords.prepareRowMutation(
selected.id,
selected.revision,
PasswordMutation.Delete
)
}
assertNotNull(stale.error)
assertNull(stale.mutation)
request { passwords.refresh() }
val current = passwords.state.value.rows.single { it.path == "destination/entry" }
request {
passwords.prepareRowMutation(current.id, current.revision, PasswordMutation.Delete)
}
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
assertNull(passwords.state.value.mutation)
assertNull(passwords.state.value.sections)
passwords.mutate(null, false, false)
}
val retained = request { passwords.refresh() }
assertTrue(retained.rows.any { it.id == current.id })
assertFalse(authentication.state.value.unlocked)
request {
passwords.prepareRowMutation(current.id, current.revision, PasswordMutation.Delete)
}
request { passwords.unlockMutation(passphrase) }
val deleted = request { passwords.mutate(null, false, false) }
assertNull(deleted.error)
assertEquals("destination", deleted.path)
assertTrue(deleted.rows.none { it.id == current.id })
assertNotNull(deleted.notice)
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
roots.forEach { assertTrue(File(it).deleteRecursively()) }
}
}
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,58 @@
package de.rfc1437.ironstorage.core
/** OS facts and cipher mechanics. Rust chooses policy, aliases and persistence. */
class KeystoreDeviceState(
val api: Int,
val locked: Boolean,
val secure: Boolean,
val strongBiometric: Boolean,
val strongbox: Boolean
)
enum class KeystoreAuthorization { None, StrongBiometric, UserPresence }
class KeystoreKeySpecification(
val alias: String,
val authorization: KeystoreAuthorization,
val unlockedDevice: Boolean,
val invalidateOnEnrollment: Boolean,
val strongbox: Boolean
)
class KeystoreCiphertext(val nonce: ByteArray, val bytes: ByteArray)
enum class KeystoreFailure {
MissingKey,
Invalidated,
Lockout,
Locked,
Denied,
Cancelled,
Corrupted,
UnsupportedProtection,
StrongboxUnavailable,
AlreadyExists,
Unavailable
}
class KeystoreException(val kind: KeystoreFailure) : Exception("Keystore operation failed")
interface AndroidKeystore {
fun deviceState(): KeystoreDeviceState
fun createKey(specification: KeystoreKeySpecification)
fun encrypt(
alias: String,
authorization: KeystoreAuthorization,
aad: ByteArray,
value: ByteArray
): KeystoreCiphertext
fun decrypt(
alias: String,
authorization: KeystoreAuthorization,
aad: ByteArray,
value: KeystoreCiphertext
): ByteArray
fun deleteKey(alias: String)
fun aliases(): List<String>
fun cancelPending()
}
@@ -0,0 +1,44 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class Appearance { System, Light, Dark }
enum class AuthenticationFailureKind {
PassphraseRequired,
InvalidPassphrase,
Cancelled,
BiometryUnavailable,
Configuration,
KeyMaterial,
Entry,
SecureStorage,
Expired,
Conflict
}
data class AuthenticationFailure(
val kind: AuthenticationFailureKind,
val title: String,
val detail: String
)
data class AuthenticationState(
val unlocked: Boolean = false,
val biometricEnabled: Boolean = false,
val remainingSeconds: ULong = 0u,
val busy: Boolean = false,
val failure: AuthenticationFailure? = null,
val appearance: Appearance = Appearance.System
)
interface AuthenticationRepository {
val presentation: ProtectedPresentation
val state: StateFlow<AuthenticationState>
fun setAppearance(appearance: Appearance)
fun reload()
fun unlock()
fun enroll(passphrase: String)
fun disable()
fun lock()
fun cancel()
fun touch()
fun lifecycle(event: AuthenticationLifecycle)
}
@@ -0,0 +1,61 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.generated.MobileCredentialAttribute
import de.rfc1437.ironstorage.bridge.generated.MobileCredentialCaller
import de.rfc1437.ironstorage.bridge.generated.MobileCredentialField
import de.rfc1437.ironstorage.bridge.generated.MobileProviderRequest
import de.rfc1437.ironstorage.bridge.generated.mobileCredentialForm
import de.rfc1437.ironstorage.bridge.generated.mobileCredentialPrivilegedCallers
data class CredentialCaller(
val packageName: String,
val certificates: List<String>,
val origin: String?
)
data class CredentialAttribute(val name: String, val value: String)
data class CredentialField(
val index: UInt,
val hints: List<String>,
val htmlAttributes: List<CredentialAttribute>,
val inputType: UInt,
val focused: Boolean,
val editable: Boolean
)
data class CredentialForm(val username: UInt, val password: UInt)
data class CredentialCandidate(val path: String, val matched: Boolean)
data class ProviderRequest(
val token: String,
val title: String,
val revision: String,
val candidates: List<CredentialCandidate>
)
class CredentialValue(val username: String, val password: String) {
override fun toString() = "CredentialValue([REDACTED])"
}
internal fun credentialForm(fields: List<CredentialField>): CredentialForm {
val result = mobileCredentialForm(
fields.map { field ->
MobileCredentialField(
field.index,
field.hints,
field.htmlAttributes.map {
MobileCredentialAttribute(it.name, it.value)
},
field.inputType,
field.focused,
field.editable
)
}
)
return CredentialForm(result.username, result.password)
}
internal fun privilegedCredentialCallers(): String = mobileCredentialPrivilegedCallers()
internal fun CredentialCaller.native() = MobileCredentialCaller(packageName, certificates, origin)
internal fun MobileProviderRequest.facade() = ProviderRequest(
token,
title,
revision,
candidates.map {
CredentialCandidate(it.path, it.matched)
}
)
@@ -0,0 +1,54 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class EditorFieldKind { Password, Field, Note, OtpUri }
data class EditorField(
val id: ULong,
val kind: EditorFieldKind,
val name: String?,
val label: String,
val value: String?,
val maskedValue: String,
val diagnostic: String?,
val sensitive: Boolean,
val multiline: Boolean,
val nameEditable: Boolean,
val removable: Boolean,
val reorderable: Boolean
) {
override fun toString() = "EditorField(id=$id, [REDACTED])"
}
data class EditorState(
val open: Boolean = false,
val locked: Boolean = false,
val busy: Boolean = false,
val title: String = "",
val path: String = "",
val fields: List<EditorField> = emptyList(),
val dirty: Boolean = false,
val defaultLength: UInt = 0u,
val maximumLength: UInt = 0u,
val error: String? = null,
val discardRequested: Boolean = false,
val completedOperations: Long = 0
) {
override fun toString() = "EditorState(open=$open, locked=$locked, [REDACTED])"
}
interface EditorRepository {
val state: StateFlow<EditorState>
fun processRecreated()
fun begin(path: String)
fun create(parent: String, name: String, passphrase: String = "")
fun createFolder(parent: String, name: String)
fun change(id: ULong, name: String?, value: String?)
fun add(kind: EditorFieldKind, name: String?, value: String)
fun remove(id: ULong)
fun reorder(id: ULong, index: UInt)
fun generate(length: UInt?, noSymbols: Boolean)
fun save()
fun resume(passphrase: String)
fun requestClose(after: () -> Unit = {})
fun keepEditing()
fun confirmDiscard()
}
@@ -0,0 +1,66 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class HomeActionKind { Commit, Fetch, Pull, Push }
enum class HomeFreshness { NeverRefreshed, Cached, Current }
enum class HomeFailureKind {
MissingConfiguration,
Configuration,
Authentication,
Conflict,
DirtyLocalChanges,
NoChanges,
Offline,
Interrupted,
SecureStorage,
Repository,
PartialProgress
}
data class HomeAction(val kind: HomeActionKind, val title: String, val icon: ShellIcon)
data class HomeRow(
val id: String,
val title: String,
val detail: String,
val icon: ShellIcon,
val actions: List<HomeAction>
)
data class HomeChange(val id: String, val title: String, val detail: String, val icon: ShellIcon)
data class HomeCommit(
val id: String,
val title: String,
val detail: String,
val timestamp: Long,
val changes: List<HomeChange>,
val actions: List<HomeAction>
)
data class HomeNotice(val title: String, val detail: String)
data class HomePage(
val freshness: HomeFreshness,
val refreshedAt: Long?,
val summaries: List<HomeRow>,
val incoming: List<HomeCommit>,
val outgoing: List<HomeCommit>,
val incomingTotal: UInt,
val outgoingTotal: UInt,
val notice: HomeNotice?,
val activityKnown: Boolean = true
)
data class HomeFailure(val kind: HomeFailureKind, val title: String, val detail: String)
data class HomeState(
val page: HomePage? = null,
val name: String = "",
val email: String = "",
val busy: Boolean = false,
val progress: HomeNotice? = null,
val failure: HomeFailure? = null
)
interface HomeRepository {
val state: StateFlow<HomeState>
fun enter()
fun refresh()
fun perform(action: HomeActionKind, message: String = "")
fun reload()
fun cancel()
fun leave()
}
@@ -0,0 +1,74 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidAuthorization
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidCiphertext
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidDeviceState
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidKeySpecification
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidKeystore
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidKeystoreException
internal class KeystoreCallback(private val os: AndroidKeystore) : MobileAndroidKeystore {
override fun deviceState(): MobileAndroidDeviceState = call {
val s = os.deviceState()
MobileAndroidDeviceState(s.api.toUInt(), s.locked, s.secure, s.strongBiometric, s.strongbox)
}
override fun createKey(specification: MobileAndroidKeySpecification) = call {
os.createKey(
KeystoreKeySpecification(
specification.alias,
specification.authorization.toOs(),
specification.unlockedDevice,
specification.invalidateOnEnrollment,
specification.strongbox
)
)
}
override fun encrypt(
alias: String,
authorization: MobileAndroidAuthorization,
aad: ByteArray,
value: ByteArray
): MobileAndroidCiphertext = call {
try {
val result = os.encrypt(alias, authorization.toOs(), aad, value)
MobileAndroidCiphertext(result.nonce, result.bytes)
} finally {
value.fill(0)
}
}
override fun decrypt(
alias: String,
authorization: MobileAndroidAuthorization,
aad: ByteArray,
value: MobileAndroidCiphertext
): ByteArray = call {
os.decrypt(alias, authorization.toOs(), aad, KeystoreCiphertext(value.nonce, value.bytes))
}
override fun deleteKey(alias: String) = call { os.deleteKey(alias) }
override fun aliases(): List<String> = call { os.aliases() }
override fun cancelPending() = os.cancelPending()
}
private fun MobileAndroidAuthorization.toOs(): KeystoreAuthorization = when (this) {
MobileAndroidAuthorization.NONE -> KeystoreAuthorization.None
MobileAndroidAuthorization.STRONG_BIOMETRIC -> KeystoreAuthorization.StrongBiometric
MobileAndroidAuthorization.USER_PRESENCE -> KeystoreAuthorization.UserPresence
}
private inline fun <T> call(block: () -> T): T = try {
block()
} catch (error: KeystoreException) {
throw when (error.kind) {
KeystoreFailure.MissingKey -> MobileAndroidKeystoreException.MissingKey()
KeystoreFailure.Invalidated -> MobileAndroidKeystoreException.Invalidated()
KeystoreFailure.Lockout -> MobileAndroidKeystoreException.Lockout()
KeystoreFailure.Locked -> MobileAndroidKeystoreException.Locked()
KeystoreFailure.Denied -> MobileAndroidKeystoreException.Denied()
KeystoreFailure.Cancelled -> MobileAndroidKeystoreException.Cancelled()
KeystoreFailure.Corrupted -> MobileAndroidKeystoreException.Corrupted()
KeystoreFailure.UnsupportedProtection ->
MobileAndroidKeystoreException.UnsupportedProtection()
KeystoreFailure.StrongboxUnavailable ->
MobileAndroidKeystoreException.StrongboxUnavailable()
KeystoreFailure.AlreadyExists -> MobileAndroidKeystoreException.AlreadyExists()
KeystoreFailure.Unavailable -> MobileAndroidKeystoreException.Unavailable()
}
}
@@ -0,0 +1,301 @@
package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAppearance
import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationState
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileLifecycleEvent
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/** Mirrors the single Rust lease; no timeout or biometric policy lives here. */
class NativeAuthenticationRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope,
private val io: CoroutineDispatcher,
private val clock: MobileClock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
) : AuthenticationRepository {
internal fun text(id: Int) = session.text(id)
override val presentation = ProtectedPresentation()
private val mutable = MutableStateFlow(AuthenticationState())
override val state: StateFlow<AuthenticationState> = mutable
private val sequence = AtomicLong()
private val operation = Mutex()
private var revocation: Job? = null
@Volatile private var current: MobileAuthentication? = null
init {
presentation.onWindowFocusLost = { lifecycle(AuthenticationLifecycle.Background) }
scope.launch {
while (true) {
delay(250)
try {
withContext(io) { current?.expireCredentialRequests() }
} catch (_: MobileAuthenticationFfiException) {
lock()
}
if (mutable.value.unlocked && !mutable.value.busy) {
val ticket = sequence.get()
try {
val value = withContext(io) { current?.state() }
if (ticket == sequence.get() && value != null) {
publish(value.toFacade().copy(appearance = mutable.value.appearance))
}
} catch (_: MobileAuthenticationFfiException) {
if (ticket == sequence.get()) lock()
} catch (_: NativeBridgeException) {
if (ticket == sequence.get()) lock()
}
}
}
}
}
private fun owner(): MobileAuthentication =
session.storage().authentication(session.backend(), clock).also {
if (current == null) it.lifecycle(MobileLifecycleEvent.PROCESS_RECREATED)
current = it
}
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
access(true, block)
internal suspend fun <T> metadata(block: (MobileAuthentication) -> T): T = access(false, block)
internal suspend fun <T> observe(block: (MobileAuthentication) -> T): T? =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (mutable.value.busy) null else access(false, block)
}
private suspend fun <T> access(showBusy: Boolean, block: (MobileAuthentication) -> T): T =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
val ticket = if (showBusy) sequence.incrementAndGet() else sequence.get()
if (showBusy) mutable.value = mutable.value.copy(busy = true, failure = null)
operation.withLock {
revocation?.join()
if (ticket != sequence.get()) throw cancelled(session)
try {
val result = withContext(io) {
val authentication = owner()
if (ticket != sequence.get()) throw cancelled(session)
val result = block(authentication)
if (ticket != sequence.get()) {
if (showBusy) authentication.manualLock()
throw cancelled(session)
}
result
}
val value = withContext(io) {
current!!.state().toFacade().copy(
appearance = current!!.mobileAppearance().toFacade()
)
}
if (ticket == sequence.get()) publish(value)
result
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket == sequence.get()) {
val value = withContext(io) { current?.state() }
publish(
(value?.toFacade() ?: AuthenticationState()).copy(
appearance = mutable.value.appearance,
failure = error.toFacade()
)
)
}
throw error
} catch (error: MobilePrivateStorageFfiException) {
if (ticket == sequence.get()) unavailable()
throw error
} catch (error: NativeBridgeException) {
if (ticket == sequence.get()) unavailable()
throw error
} catch (error: java.io.IOException) {
if (ticket == sequence.get()) unavailable()
throw error
} finally {
if (showBusy &&
ticket == sequence.get()
) {
mutable.value = mutable.value.copy(busy = false)
}
}
}
}
internal suspend fun checkpointEditor(
editor: ULong,
version: ULong,
fields: List<de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput>
) = withContext(io) {
(current ?: throw cancelled(session)).checkpointEntryEditorInputs(editor, version, fields)
}
internal suspend fun discardEditor(editor: ULong) = withContext(io) {
current?.discardEntryEditor(editor)
Unit
}
override fun setAppearance(appearance: Appearance) = run {
it.setMobileAppearance(
when (appearance) {
Appearance.System -> MobileAppearance.SYSTEM
Appearance.Light -> MobileAppearance.LIGHT
Appearance.Dark -> MobileAppearance.DARK
}
)
}
override fun reload() = run { it.state() }
override fun unlock() = run { it.restoreBiometricLease() }
override fun enroll(passphrase: String) = run { it.enrollBiometricUnlock(passphrase) }
override fun disable() = run { it.setBiometricUnlock(false) }
private fun run(block: (MobileAuthentication) -> Unit) {
if (mutable.value.busy) return
scope.launch {
try {
authenticated(block)
} catch (_: MobileAuthenticationFfiException) {
// The typed failure is published by authenticated().
} catch (_: MobilePrivateStorageFfiException) {
// The failure is published before releasing the operation.
} catch (_: NativeBridgeException) {
// The failure is published before releasing the operation.
} catch (_: java.io.IOException) {
// The failure is published before releasing the operation.
}
}
}
private fun publish(value: AuthenticationState) {
if (mutable.value.unlocked && !value.unlocked) presentation.clear()
mutable.value = value
}
private fun unavailable() {
publish(
AuthenticationState(
appearance = mutable.value.appearance,
failure = AuthenticationFailure(
AuthenticationFailureKind.Configuration,
session.text(R.string.core_auth_unavailable),
session.text(R.string.core_auth_recover)
)
)
)
}
override fun lock() {
presentation.clearMetadata()
revoke(false)
}
override fun cancel() = revoke(true)
private fun revoke(cancel: Boolean, event: MobileLifecycleEvent? = null) {
val ticket = sequence.incrementAndGet()
presentation.clear()
mutable.value = mutable.value.copy(
unlocked = false,
remainingSeconds = 0u,
busy = false,
failure = if (cancel) {
AuthenticationFailure(
AuthenticationFailureKind.Cancelled,
session.text(R.string.core_auth_cancelled),
session.text(R.string.core_auth_no_content)
)
} else {
null
}
)
session.cancelAuthentication()
val authentication = current
val previous = revocation
revocation = scope.launch(io) {
previous?.join()
try {
when {
event != null -> authentication?.lifecycle(event)
cancel -> authentication?.cancel()
else -> authentication?.manualLock()
}
} catch (_: MobileAuthenticationFfiException) {
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (ticket == sequence.get()) unavailable()
}
}
}
}
override fun lifecycle(event: AuthenticationLifecycle) {
if (event == AuthenticationLifecycle.ProcessRecreated) presentation.clearMetadata()
val native = when (event) {
AuthenticationLifecycle.Foreground -> MobileLifecycleEvent.FOREGROUND
AuthenticationLifecycle.Background -> MobileLifecycleEvent.BACKGROUND
AuthenticationLifecycle.DeviceLocked -> MobileLifecycleEvent.DEVICE_LOCKED
AuthenticationLifecycle.MemoryPressure -> MobileLifecycleEvent.MEMORY_PRESSURE
AuthenticationLifecycle.ProcessRecreated -> MobileLifecycleEvent.PROCESS_RECREATED
}
if (event == AuthenticationLifecycle.Foreground) {
run { it.lifecycle(native) }
} else {
revoke(false, native)
}
}
override fun touch() {
if (!mutable.value.unlocked || mutable.value.busy) return
val ticket = sequence.get()
scope.launch(io) {
try {
current?.lifecycle(MobileLifecycleEvent.USER_ACTIVITY)
} catch (_: MobileAuthenticationFfiException) {
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (ticket == sequence.get()) lock()
}
}
}
}
}
private fun cancelled(session: NativePrivateSession) = MobileAuthenticationFfiException.Failed(
MobileAuthenticationErrorKind.CANCELLED,
session.text(R.string.core_auth_cancelled),
session.text(R.string.core_auth_no_content)
)
private fun MobileAuthenticationState.toFacade() = AuthenticationState(
unlocked,
biometricUnlockEnabled,
remainingSeconds
)
private fun MobileAuthenticationFfiException.Failed.toFacade() = AuthenticationFailure(
when (kind) {
MobileAuthenticationErrorKind.PASSPHRASE_REQUIRED ->
AuthenticationFailureKind.PassphraseRequired
MobileAuthenticationErrorKind.INVALID_PASSPHRASE ->
AuthenticationFailureKind.InvalidPassphrase
MobileAuthenticationErrorKind.CANCELLED -> AuthenticationFailureKind.Cancelled
MobileAuthenticationErrorKind.BIOMETRY_UNAVAILABLE ->
AuthenticationFailureKind.BiometryUnavailable
MobileAuthenticationErrorKind.CONFIGURATION -> AuthenticationFailureKind.Configuration
MobileAuthenticationErrorKind.KEY_MATERIAL -> AuthenticationFailureKind.KeyMaterial
MobileAuthenticationErrorKind.ENTRY -> AuthenticationFailureKind.Entry
MobileAuthenticationErrorKind.SECURE_STORAGE -> AuthenticationFailureKind.SecureStorage
MobileAuthenticationErrorKind.EXPIRED -> AuthenticationFailureKind.Expired
MobileAuthenticationErrorKind.CONFLICT -> AuthenticationFailureKind.Conflict
},
title,
detail
)
private fun MobileAppearance.toFacade(): Appearance = when (this) {
MobileAppearance.SYSTEM -> Appearance.System
MobileAppearance.LIGHT -> Appearance.Light
MobileAppearance.DARK -> Appearance.Dark
}
@@ -0,0 +1,56 @@
package de.rfc1437.ironstorage.core
/** Providers use the same Rust owner and lease as the entry viewer. */
class NativeCredentialRepository(private val authentication: NativeAuthenticationRepository) {
suspend fun form(fields: List<CredentialField>): CredentialForm =
authentication.authenticated { credentialForm(fields) }
suspend fun privilegedCallers(): String = authentication.authenticated {
privilegedCredentialCallers()
}
suspend fun begin(caller: CredentialCaller): ProviderRequest =
authentication.authenticated { it.beginCredentialRequest(caller.native()).facade() }
suspend fun request(token: String, caller: CredentialCaller, query: String = "") =
authentication.authenticated {
it.credentialRequest(token, caller.native(), query).facade()
}
suspend fun fill(
token: String,
caller: CredentialCaller,
path: String,
consent: Boolean,
passphrase: String?,
allowedUsernames: List<String>
): CredentialValue = authentication.authenticated {
it.fillCredential(
token,
caller.native(),
path,
consent,
passphrase,
allowedUsernames
).let { value ->
CredentialValue(value.username, value.password)
}
}
suspend fun stage(token: String, caller: CredentialCaller, username: String, password: String) =
authentication.authenticated {
it.stageCredentialSave(token, caller.native(), username, password)
}
suspend fun save(
token: String,
caller: CredentialCaller,
path: String,
consent: Boolean,
overwrite: Boolean,
passphrase: String?
) = authentication.authenticated {
it.saveStagedCredential(token, caller.native(), path, consent, overwrite, passphrase)
}
suspend fun cancel(token: String) = authentication.authenticated {
it.cancelCredentialRequest(token)
}
}
@@ -0,0 +1,350 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorPage
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorSession
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Frontend operation/input state only; Rust owns documents and encrypted recovery checkpoints. */
class NativeEditorRepository(
private val authentication: NativeAuthenticationRepository,
private val passwords: PasswordRepository,
private val scope: CoroutineScope
) : EditorRepository {
private val mutable = MutableStateFlow(EditorState())
override val state: StateFlow<EditorState> = mutable
private val operation = Mutex()
@Volatile private var epoch = 0L
@Volatile private var version = 0uL
@Volatile private var checkpoint: ULong? = null
private var pending: Job? = null
private var afterClose: () -> Unit = {}
private var retry: (suspend (String) -> MobileEntryEditorSession)? = null
init {
val registration = authentication.presentation.register {
if (mutable.value.open) {
mutable.value = mutable.value.copy(locked = true, fields = emptyList())
}
}
val discarded = authentication.presentation.registerEditorDiscard { ids ->
if (checkpoint in ids) {
epoch += 1
pending?.cancel()
checkpoint = null
retry = null
afterClose = {}
mutable.value = EditorState()
}
}
scope.coroutineContext[Job]?.invokeOnCompletion {
registration.close()
discarded.close()
}
}
override fun processRecreated() {
scope.launch {
val notice = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
de.rfc1437.ironstorage.bridge.generated.mobileEditorProcessRecreatedNotice()
}
if (!mutable.value.open) mutable.value = mutable.value.copy(error = notice)
}
}
override fun begin(path: String) {
if (mutable.value.open) return
epoch += 1
authentication.presentation.clear()
mutable.value = EditorState(open = true, path = path)
retry = { passphrase ->
authentication.authenticated {
it.unlockEntry(path, passphrase.ifEmpty { null })
it.beginEntryEditor(path).also { session ->
checkpoint = session.id
it.checkpointEntryEditor(session.id, ++version)
}
}
}
resume("")
}
override fun create(parent: String, name: String, passphrase: String) {
if (mutable.value.open) return
epoch += 1
authentication.presentation.clear()
mutable.value = EditorState(open = true)
retry = { secret ->
authentication.authenticated {
it.beginCreateEntry(parent, name, secret.ifEmpty { null }).also { session ->
checkpoint = session.id
it.checkpointEntryEditor(session.id, ++version)
}
}
}
resume(passphrase)
}
override fun createFolder(parent: String, name: String) = run(protectedResult = false) {
authentication.authenticated { it.createDirectory(parent, name) }
passwords.restore(passwords.state.value.path, null, null)
passwords.state.first { !it.loading }
EditorState()
}
override fun resume(passphrase: String) = run {
pending?.join()
val id = checkpoint
if (id == null) {
requireNotNull(retry)(passphrase).page.facade()
} else {
val recovered = authentication.authenticated {
it.resumeEntryEditor(id, passphrase.ifEmpty { null })
}
val inputs = recovered.inputs?.associateBy { it.id }
recovered.session.page.facade().let { page ->
page.copy(
fields = page.fields.map { field ->
val input = inputs?.get(field.id)
if (input ==
null
) {
field
} else {
field.copy(name = input.name, value = input.value)
}
},
dirty = page.dirty || inputs != null
)
}
}
}
override fun change(id: ULong, name: String?, value: String?) {
val current = mutable.value
if (!current.open || current.locked || current.busy) return
if (current.fields.none { it.id == id }) return
mutable.value = current.copy(
fields = current.fields.map {
if (it.id ==
id
) {
it.copy(name = name, value = value)
} else {
it
}
},
dirty = true,
error = null
)
val editor = checkpoint ?: return
val fields = inputs()
val ticket = epoch
val inputVersion = ++version
pending?.cancel()
pending = scope.launch {
try {
// Coalesce typing; save, reorder and recovery join the latest encrypted checkpoint.
delay(150)
authentication.checkpointEditor(editor, inputVersion, fields)
} catch (error: CancellationException) {
throw error
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket == epoch &&
inputVersion == version
) {
mutable.value =
mutable.value.copy(error = error.detail)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = authentication.text(R.string.core_update_retry))
}
}
}
}
private fun inputs() = mutable.value.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value)
}
private fun modify(
action: (
de.rfc1437.ironstorage.bridge.generated.MobileAuthentication,
ULong
) -> MobileEntryEditorPage
) = run {
pending?.join()
val id = requireNotNull(checkpoint)
val fields = inputs()
authentication.touch()
authentication.authenticated {
it.updateEntryEditor(id, fields)
action(it, id).also { _ -> it.checkpointEntryEditor(id, ++version) }
}.facade()
}
override fun add(kind: EditorFieldKind, name: String?, value: String) = modify { auth, id ->
auth.addEntryEditorField(id, kind.native(), name, value)
}
override fun remove(id: ULong) =
modify { auth, editor -> auth.removeEntryEditorField(editor, id) }
override fun reorder(id: ULong, index: UInt) =
modify { auth, editor -> auth.reorderEntryEditorField(editor, id, index) }
override fun generate(length: UInt?, noSymbols: Boolean) =
modify { auth, editor -> auth.generateEntryEditorPassword(editor, length, noSymbols) }
override fun save() = run(protectedResult = false) {
pending?.join()
val id = requireNotNull(checkpoint)
val fields = inputs()
authentication.touch()
authentication.authenticated { it.saveEntryEditor(id, fields) }
checkpoint = null
retry = null
passwords.restore(passwords.state.value.path, null, null)
passwords.state.first { !it.loading }
EditorState()
}
override fun requestClose(after: () -> Unit) {
afterClose = after
if (mutable.value.dirty) {
mutable.value = mutable.value.copy(discardRequested = true)
} else {
confirmDiscard()
}
}
override fun keepEditing() {
afterClose = {}
mutable.value = mutable.value.copy(discardRequested = false)
}
override fun confirmDiscard() {
val after = afterClose
afterClose = {}
epoch += 1
val ticket = epoch
mutable.value = mutable.value.copy(busy = true, discardRequested = false)
scope.launch {
operation.withLock {
try {
pending?.join()
checkpoint?.let { authentication.discardEditor(it) }
if (ticket != epoch) return@withLock
checkpoint = null
retry = null
mutable.value = EditorState()
after()
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(busy = false, error = error.detail)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
busy = false,
error = authentication.text(R.string.core_update_retry)
)
}
}
}
}
}
private fun run(protectedResult: Boolean = true, action: suspend () -> EditorState) {
if (mutable.value.busy) return
val ticket = epoch
mutable.value = mutable.value.copy(busy = true, error = null)
scope.launch {
operation.withLock {
if (ticket != epoch) return@withLock
try {
val result = action()
if (ticket == epoch &&
(!protectedResult || authentication.state.value.unlocked)
) {
mutable.value = result.copy(
completedOperations = mutable.value.completedOperations + 1
)
}
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = error.title + "\n" + error.detail)
}
} catch (_: MobilePrivateStorageFfiException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
error = authentication.text(R.string.core_unlock_retry)
)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
error = authentication.text(R.string.core_update_retry)
)
}
} catch (_: java.io.IOException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
error = authentication.text(R.string.core_draft_storage_unavailable)
)
}
} finally {
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
}
}
}
}
}
private fun EditorFieldKind.native() = when (this) {
EditorFieldKind.Password -> MobileEntryEditorFieldKind.PASSWORD
EditorFieldKind.Field -> MobileEntryEditorFieldKind.FIELD
EditorFieldKind.Note -> MobileEntryEditorFieldKind.NOTE
EditorFieldKind.OtpUri -> MobileEntryEditorFieldKind.OTP_URI
}
private fun MobileEntryEditorPage.facade() = EditorState(
open = true,
title = title,
path = path,
fields = fields.map { field ->
EditorField(
field.id,
when (field.kind) {
MobileEntryEditorFieldKind.PASSWORD -> EditorFieldKind.Password
MobileEntryEditorFieldKind.FIELD -> EditorFieldKind.Field
MobileEntryEditorFieldKind.NOTE -> EditorFieldKind.Note
MobileEntryEditorFieldKind.OTP_URI -> EditorFieldKind.OtpUri
},
field.name, field.label, field.value, field.maskedValue, field.diagnostic,
field.sensitive, field.multiline, field.nameEditable, field.removable, field.reorderable
)
},
dirty = dirty,
defaultLength = defaultPasswordLength,
maximumLength = maximumPasswordLength
)
@@ -0,0 +1,212 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.NativeFailureKind
import de.rfc1437.ironstorage.bridge.generated.MobileIcon
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
import de.rfc1437.ironstorage.bridge.generated.MobileTab
import de.rfc1437.ironstorage.bridge.generated.mobilePrivateStorageFailure
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Rust-owned private initialization; debug controls can switch to explicit fixtures. */
class NativeFixtureShellRepository internal constructor(
private val scope: CoroutineScope,
private val loadCurrent: (suspend () -> ShellSnapshot)? = null,
private val load: suspend (ShellState) -> ShellSnapshot
) : ShellRepository {
constructor(session: NativePrivateSession, scope: CoroutineScope) : this(
scope,
{ nativeLoader(session, true)(ShellState.Empty) },
nativeLoader(session)
)
private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList()))
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
private val revision = AtomicLong()
private var job: Job? = null
private var selectedByUi = false
init {
refresh()
}
override fun selectTab(tab: ShellTab) {
selectedByUi = true
mutableSnapshot.update { it.copy(selectedTab = tab) }
}
fun showFixture(state: ShellState) {
update(true) { load(state) }
}
override fun refresh() {
update(false, loadCurrent ?: { load(ShellState.Empty) })
}
private fun update(fixture: Boolean, loader: suspend () -> ShellSnapshot) {
val operation = revision.incrementAndGet()
job?.cancel()
job = scope.launch {
val result = try {
loader()
} catch (error: NativeBridgeException) {
ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade())
}
if (revision.get() == operation) {
mutableSnapshot.update {
result.copy(
fixture = fixture,
selectedTab = if (!selectedByUi &&
it.pages.isEmpty()
) {
result.selectedTab
} else {
it.selectedTab
}
)
}
}
}
}
}
private fun nativeLoader(
session: NativePrivateSession,
current: Boolean = false
): suspend (ShellState) -> ShellSnapshot {
val runtime = session.runtime
return { state ->
withContext(Dispatchers.IO) {
var recovery: StorageRecovery? = null
val shell = if (current) {
try {
val initialized = session.storage()
recovery = initialized.status().recovery?.toFacade()
initialized.shell()
} catch (error: MobilePrivateStorageFfiException.Failed) {
recovery = error.kind.toFacade()
mobilePrivateStorageFailure(error.kind)
} catch (_: java.io.IOException) {
recovery = StorageRecovery.InvalidRoots
runtime.fixture(MobileShellState.ERROR)
mobilePrivateStorageFailure(MobilePrivateStorageErrorKind.INVALID_ROOTS)
}
} else {
runtime.fixture(state.toNative())
}
val pages = shell.pages.map { page ->
ShellPage(
page.tab.toFacade(),
page.title,
page.stateTitle,
page.stateDetail,
page.icon.toFacade()
)
}
val actual = shell.pages.firstOrNull()?.state
?: throw NativeBridgeException(NativeFailureKind.RustFailure)
ShellSnapshot(
actual.toFacade(),
pages,
selectedTab = shell.selectedTab.toFacade(),
storageRecovery = recovery
)
}
}
}
private fun MobilePrivateStorageErrorKind.toFacade(): StorageRecovery = when (this) {
MobilePrivateStorageErrorKind.INVALID_ROOTS -> StorageRecovery.InvalidRoots
MobilePrivateStorageErrorKind.UNSAFE_PATH -> StorageRecovery.UnsafePath
MobilePrivateStorageErrorKind.MISSING_ROOT -> StorageRecovery.MissingRoot
MobilePrivateStorageErrorKind.ACCESS_DENIED -> StorageRecovery.AccessDenied
MobilePrivateStorageErrorKind.READ_ONLY -> StorageRecovery.ReadOnly
MobilePrivateStorageErrorKind.STORAGE_FULL -> StorageRecovery.StorageFull
MobilePrivateStorageErrorKind.BUSY -> StorageRecovery.Busy
MobilePrivateStorageErrorKind.IO -> StorageRecovery.Io
MobilePrivateStorageErrorKind.CORRUPT_LAYOUT -> StorageRecovery.CorruptLayout
MobilePrivateStorageErrorKind.MIGRATION_REQUIRED -> StorageRecovery.MigrationRequired
MobilePrivateStorageErrorKind.CORRUPT_CONFIGURATION -> StorageRecovery.CorruptConfiguration
MobilePrivateStorageErrorKind.CONFIGURATION_MISSING_WITH_DATA ->
StorageRecovery.ConfigurationMissingWithData
MobilePrivateStorageErrorKind.INTERRUPTED_SETUP -> StorageRecovery.InterruptedSetup
MobilePrivateStorageErrorKind.RESTORED_WITHOUT_KEYSTORE ->
StorageRecovery.RestoredWithoutKeystore
MobilePrivateStorageErrorKind.SECURE_STORAGE_LOCKED -> StorageRecovery.SecureStorageLocked
MobilePrivateStorageErrorKind.SECURE_STORAGE_UNAVAILABLE ->
StorageRecovery.SecureStorageUnavailable
MobilePrivateStorageErrorKind.INVALID_SECURE_BINDING -> StorageRecovery.InvalidSecureBinding
}
private fun MobileShellState.toFacade(): ShellState = when (this) {
MobileShellState.LOADING -> ShellState.Loading
MobileShellState.EMPTY -> ShellState.Empty
MobileShellState.READY -> ShellState.Ready
MobileShellState.LOCKED -> ShellState.Locked
MobileShellState.ERROR -> ShellState.Error
MobileShellState.STALE -> ShellState.Stale
}
private fun ShellState.toNative(): MobileShellState = when (this) {
ShellState.Loading -> MobileShellState.LOADING
ShellState.Empty -> MobileShellState.EMPTY
ShellState.Ready -> MobileShellState.READY
ShellState.Locked -> MobileShellState.LOCKED
ShellState.Error -> MobileShellState.ERROR
ShellState.Stale -> MobileShellState.STALE
}
private fun MobileTab.toFacade(): ShellTab = when (this) {
MobileTab.HOME -> ShellTab.Home
MobileTab.PASSWORDS -> ShellTab.Passwords
MobileTab.SEARCH -> ShellTab.Search
MobileTab.TOTP -> ShellTab.Totp
MobileTab.PREFERENCES -> ShellTab.Preferences
}
private fun NativeFailureKind.toFacade(): NativeFailure = when (this) {
NativeFailureKind.UnsupportedAbi -> NativeFailure.UnsupportedAbi
NativeFailureKind.MissingLibrary -> NativeFailure.MissingLibrary
NativeFailureKind.ContractMismatch -> NativeFailure.ContractMismatch
NativeFailureKind.ChecksumMismatch -> NativeFailure.ChecksumMismatch
NativeFailureKind.LoadFailure -> NativeFailure.LoadFailure
NativeFailureKind.RustFailure -> NativeFailure.RustFailure
NativeFailureKind.MainThreadCall -> NativeFailure.MainThreadCall
}
internal fun MobileIcon.toFacade(): ShellIcon = when (this) {
MobileIcon.HOME -> ShellIcon.Home
MobileIcon.KEY -> ShellIcon.Key
MobileIcon.SEARCH -> ShellIcon.Search
MobileIcon.TIMER -> ShellIcon.Timer
MobileIcon.SETTINGS -> ShellIcon.Settings
MobileIcon.WARNING -> ShellIcon.Warning
MobileIcon.BRANCH -> ShellIcon.Branch
MobileIcon.SUCCESS -> ShellIcon.Success
MobileIcon.SYNC -> ShellIcon.Sync
MobileIcon.PROTECTED -> ShellIcon.Protected
MobileIcon.REPOSITORY -> ShellIcon.Repository
MobileIcon.HISTORY -> ShellIcon.History
MobileIcon.INCOMING -> ShellIcon.Incoming
MobileIcon.OUTGOING -> ShellIcon.Outgoing
MobileIcon.RECIPIENTS -> ShellIcon.Recipients
MobileIcon.SIGNATURE -> ShellIcon.Signature
MobileIcon.FILE -> ShellIcon.File
MobileIcon.MERGE -> ShellIcon.Merge
MobileIcon.REFRESH -> ShellIcon.Refresh
MobileIcon.FOLDER -> ShellIcon.Folder
MobileIcon.USER -> ShellIcon.User
MobileIcon.EMAIL -> ShellIcon.Email
MobileIcon.WEBSITE -> ShellIcon.Website
MobileIcon.TEXT -> ShellIcon.Text
MobileIcon.NOTE -> ShellIcon.Note
}
@@ -0,0 +1,207 @@
package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileHomeActionKind
import de.rfc1437.ironstorage.bridge.generated.MobileHomeErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileHomeFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileHomeFreshness
import de.rfc1437.ironstorage.bridge.generated.MobileHomeOperation
import de.rfc1437.ironstorage.bridge.generated.MobileHomePage
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativeHomeRepository(
private val session: NativePrivateSession,
private val passwords: PasswordRepository,
private val scope: CoroutineScope,
private val io: CoroutineDispatcher
) : HomeRepository {
private val mutable = MutableStateFlow(HomeState())
override val state: StateFlow<HomeState> = mutable
private val sequence = AtomicLong()
private val mutex = Mutex()
@Volatile private var active: MobileHomeOperation? = null
private var cancelTicket: Long? = null
private val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
override fun enter() = run(Request.Enter)
override fun reload() = run(Request.Enter)
override fun refresh() = run(Request.Refresh)
override fun perform(action: HomeActionKind, message: String) = run(
when (action) {
HomeActionKind.Commit -> Request.Commit
HomeActionKind.Fetch -> Request.Fetch
HomeActionKind.Pull -> Request.Pull
HomeActionKind.Push -> Request.Push
},
message
)
override fun cancel() {
cancelTicket = sequence.get()
val operation = active
scope.launch(io) { operation?.cancel() }
}
override fun leave() {
sequence.incrementAndGet()
val operation = active
scope.launch(io) { operation?.cancel() }
mutable.value = mutable.value.copy(busy = false, progress = null)
}
private enum class Request { Enter, Refresh, Commit, Fetch, Pull, Push }
private fun run(request: Request, message: String = "") {
if (mutable.value.busy) return
val ticket = sequence.incrementAndGet()
mutable.value = mutable.value.copy(busy = true, failure = null, progress = null)
scope.launch {
mutex.withLock {
if (sequence.get() != ticket) return@withLock
val polling = launch {
while (sequence.get() == ticket) {
delay(150)
val progress = withContext(io) { active?.progress() }
if (sequence.get() == ticket && progress != null) {
mutable.value = mutable.value.copy(
progress = HomeNotice(progress.title, progress.detail)
)
}
}
}
try {
val current = withContext(io) {
session.storage().androidHomeOperation(session.backend(), clock)
}
active = current
if (sequence.get() != ticket || cancelTicket == ticket) {
withContext(io) { current.cancel() }
}
val identity = withContext(io) { current.identity() }
val cached = withContext(io) {
current.cached().toFacade(current.activityKnown())
}
if (sequence.get() == ticket) {
mutable.value = mutable.value.copy(
page = cached,
name = identity.name,
email = identity.email
)
}
val page = withContext(io) {
when (request) {
Request.Enter -> current.refreshIfStale()
Request.Refresh -> current.refreshGesture()
Request.Commit -> current.commit(message)
Request.Fetch -> current.fetch()
Request.Pull -> current.pull()
Request.Push -> current.push()
}.toFacade(current.activityKnown())
}
if (sequence.get() == ticket) {
mutable.value = HomeState(page, identity.name, identity.email)
if (request == Request.Pull || request == Request.Refresh) {
passwords.refresh()
}
}
} catch (error: MobileHomeFfiException.Failed) {
if (sequence.get() == ticket) {
mutable.value = mutable.value.copy(
failure = HomeFailure(error.kind.toFacade(), error.title, error.detail)
)
}
} catch (_: MobilePrivateStorageFfiException) {
failure(
ticket,
session.text(R.string.core_home_recover)
)
} catch (_: NativeBridgeException) {
failure(ticket, session.text(R.string.core_home_update))
} catch (_: java.io.IOException) {
failure(
ticket,
session.text(R.string.core_home_storage)
)
} finally {
active = null
polling.cancelAndJoin()
if (sequence.get() == ticket) {
mutable.value = mutable.value.copy(busy = false, progress = null)
}
}
}
}
}
private fun failure(ticket: Long, detail: String) {
if (sequence.get() == ticket) {
mutable.value = mutable.value.copy(
failure = HomeFailure(
HomeFailureKind.Repository,
session.text(R.string.core_home_unavailable),
detail
)
)
}
}
}
private fun MobileHomeActionKind.toFacade(): HomeActionKind = when (this) {
MobileHomeActionKind.COMMIT -> HomeActionKind.Commit
MobileHomeActionKind.FETCH -> HomeActionKind.Fetch
MobileHomeActionKind.PULL -> HomeActionKind.Pull
MobileHomeActionKind.PUSH -> HomeActionKind.Push
}
private fun MobileHomeErrorKind.toFacade(): HomeFailureKind = when (this) {
MobileHomeErrorKind.MISSING_CONFIGURATION -> HomeFailureKind.MissingConfiguration
MobileHomeErrorKind.CONFIGURATION -> HomeFailureKind.Configuration
MobileHomeErrorKind.AUTHENTICATION -> HomeFailureKind.Authentication
MobileHomeErrorKind.CONFLICT -> HomeFailureKind.Conflict
MobileHomeErrorKind.DIRTY_LOCAL_CHANGES -> HomeFailureKind.DirtyLocalChanges
MobileHomeErrorKind.NO_CHANGES -> HomeFailureKind.NoChanges
MobileHomeErrorKind.OFFLINE -> HomeFailureKind.Offline
MobileHomeErrorKind.INTERRUPTED -> HomeFailureKind.Interrupted
MobileHomeErrorKind.SECURE_STORAGE -> HomeFailureKind.SecureStorage
MobileHomeErrorKind.REPOSITORY -> HomeFailureKind.Repository
MobileHomeErrorKind.PARTIAL_PROGRESS -> HomeFailureKind.PartialProgress
}
private fun MobileHomePage.toFacade(activityKnown: Boolean): HomePage {
fun action(value: de.rfc1437.ironstorage.bridge.generated.MobileHomeAction): HomeAction =
HomeAction(value.kind.toFacade(), value.title, value.icon.toFacade())
fun commit(value: de.rfc1437.ironstorage.bridge.generated.MobileHomeCommit): HomeCommit =
HomeCommit(
value.id,
value.title,
value.detail,
value.timestamp,
value.changes.map { HomeChange(it.id, it.title, it.detail, it.icon.toFacade()) },
value.actions.map(::action)
)
return HomePage(
when (freshness) {
MobileHomeFreshness.NEVER_REFRESHED -> HomeFreshness.NeverRefreshed
MobileHomeFreshness.CACHED -> HomeFreshness.Cached
MobileHomeFreshness.CURRENT -> HomeFreshness.Current
},
refreshedAt,
summaries.map {
HomeRow(it.id, it.title, it.detail, it.icon.toFacade(), it.actions.map(::action))
},
incoming.map(::commit),
outgoing.map(::commit),
incomingTotal,
outgoingTotal,
notice?.let { HomeNotice(it.title, it.detail) },
activityKnown
)
}
@@ -0,0 +1,274 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidOnboarding
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidRemoteOnboarding
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidSetupStatus
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingOutcome
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NativeOnboardingRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope,
private val shell: ShellRepository,
private val authentication: AuthenticationRepository
) : OnboardingRepository {
private val mutableSetup = MutableStateFlow(SetupStatus())
override val setup: StateFlow<SetupStatus> = mutableSetup
@Volatile private var operation: MobileAndroidOnboarding? = null
@Volatile private var remote: MobileAndroidRemoteOnboarding? = null
private val revoked = AtomicBoolean()
@Volatile private var pollProgress = false
init {
refresh()
}
override fun refresh() = runSetup {
mutableSetup.update { it.copy(branches = emptyList(), selectedBranch = "") }
remote?.close()
remote = null
operation = session.storage().androidOnboarding(session.backend())
updateStatus(requireNotNull(operation).status())
shell.refresh()
}
private fun newOperation(): MobileAndroidOnboarding {
remote?.close()
remote = null
return session.storage().androidOnboarding(session.backend()).also {
operation = it
if (revoked.get()) {
it.cancel()
throw interrupted(session)
}
}
}
override fun generate(userId: String, passphrase: String) = runSetup {
updateStatus(newOperation().generate(userId, passphrase))
}
override fun importKey(armor: String, passphrase: String) = runSetup {
updateStatus(newOperation().importKey(armor, passphrase.ifEmpty { null }))
}
override fun createLocal(fingerprint: String, passphrase: String) = runSetup {
completed(newOperation().setupLocal(fingerprint, passphrase.ifEmpty { null }))
}
override fun discover(server: String, account: String, repository: String, token: String) =
runSetup {
val setup = newOperation()
val request = setup.remote(server, account, repository, token).also {
remote = it
if (revoked.get()) {
it.cancel()
it.close()
remote = null
throw interrupted(session)
}
}
val discovery = request.discover()
if (revoked.get()) throw interrupted(session)
pollProgress = false
mutableSetup.update {
it.copy(
branches = discovery.branches,
selectedBranch = discovery.branches.getOrNull(
discovery.selectedBranch.toInt()
).orEmpty(),
title = session.text(R.string.core_branch_title),
detail = session.text(R.string.core_branch_detail)
)
}
}
override fun cloneStore(
branch: String,
fingerprint: String,
passphrase: String,
useExisting: Boolean
) = runSetup {
completed(
(remote ?: throw interrupted(session)).cloneStore(
branch,
fingerprint,
passphrase.ifEmpty {
null
},
useExisting
)
)
}
override fun connect(server: String, account: String, repository: String, token: String) =
runSetup {
val setup = newOperation()
setup.remote(server, account, repository, token).use { completed(it.connect()) }
}
override fun replaceToken(account: String, token: String) = runSetup {
newOperation().replaceToken(account, token)
if (revoked.get()) throw interrupted(session)
pollProgress = false
mutableSetup.update {
it.copy(
title = session.text(R.string.core_token_updated),
detail = session.text(R.string.core_token_updated_detail)
)
}
}
override fun cancel() {
revoked.set(true)
pollProgress = false
mutableSetup.update { it.copy(branches = emptyList(), selectedBranch = "") }
val pending = remote
remote = null
val active = operation
scope.launch(Dispatchers.IO) {
try {
active?.cancel()
pending?.cancel()
} catch (_: IllegalStateException) {
// An operation that finished concurrently already released its handle.
} finally {
pending?.close()
}
}
}
private fun completed(outcome: MobileOnboardingOutcome) {
if (revoked.get()) throw interrupted(session)
updateStatus(requireNotNull(operation).status())
mutableSetup.update {
it.copy(title = outcome.title, detail = outcome.detail, branches = emptyList())
}
remote?.close()
remote = null
scope.launch {
authentication.state.first { !it.busy }
authentication.reload()
}
shell.refresh()
}
private fun updateStatus(status: MobileAndroidSetupStatus) {
if (revoked.get()) throw interrupted(session)
pollProgress = false
mutableSetup.update {
it.copy(
title = status.title,
detail = status.detail,
configured = status.configured,
connected = status.connected,
existingClone = status.existingClone,
selectedKey = status.selectedKey.orEmpty(),
keys = status.keys.map { key -> SetupKey(key.fingerprint, key.userIds) }
)
}
}
private fun runSetup(action: () -> Unit) {
if (mutableSetup.value.busy) return
revoked.set(false)
pollProgress = true
mutableSetup.update { it.copy(busy = true, failure = false) }
scope.launch {
val progress = launch {
while (mutableSetup.value.busy) {
delay(200)
val value = try {
withContext(Dispatchers.IO) { operation?.progress() }
} catch (_: IllegalStateException) {
null
}
value?.takeIf { mutableSetup.value.busy }?.let { phase ->
mutableSetup.update {
if (pollProgress) {
it.copy(
title = phase.title,
detail = phase.detail
)
} else {
it
}
}
}
}
}
try {
withContext(Dispatchers.IO) { action() }
} catch (error: MobileOnboardingFfiException.Failed) {
pollProgress = false
if (error.kind == MobileOnboardingErrorKind.INTERRUPTED) {
withContext(Dispatchers.IO) {
remote?.close()
remote = null
}
mutableSetup.update { it.copy(branches = emptyList()) }
}
mutableSetup.update {
it.copy(title = error.title, detail = error.detail, failure = true)
}
} catch (_: MobilePrivateStorageFfiException.Failed) {
pollProgress = false
mutableSetup.update {
it.copy(
title = session.text(R.string.core_private_unavailable),
detail = session.text(R.string.core_private_retained),
failure = true
)
}
} catch (_: NativeBridgeException) {
pollProgress = false
mutableSetup.update {
it.copy(
title = session.text(R.string.core_update_required),
detail = session.text(R.string.core_native_unavailable),
failure = true
)
}
} catch (_: java.io.IOException) {
pollProgress = false
mutableSetup.update {
it.copy(
title = session.text(R.string.core_private_unavailable),
detail = session.text(R.string.core_directories_unavailable),
failure = true
)
}
} catch (error: IllegalStateException) {
if (!revoked.get()) throw error
val failure = interrupted(session)
mutableSetup.update {
it.copy(title = failure.title, detail = failure.detail, failure = true)
}
} finally {
pollProgress = false
progress.cancel()
if (revoked.get()) {
withContext(Dispatchers.IO) {
remote?.close()
remote = null
operation?.close()
operation = null
}
}
mutableSetup.update { it.copy(busy = false) }
}
}
}
}
private fun interrupted(session: NativePrivateSession) = MobileOnboardingFfiException.Failed(
MobileOnboardingErrorKind.INTERRUPTED,
session.text(R.string.core_setup_interrupted),
session.text(R.string.core_setup_return)
)
@@ -0,0 +1,628 @@
package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileClipboard
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardContent
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardWait
import de.rfc1437.ironstorage.bridge.generated.MobileEntryPresentation
import de.rfc1437.ironstorage.bridge.generated.MobileMutationAction
import de.rfc1437.ironstorage.bridge.generated.MobileMutationRequest
import de.rfc1437.ironstorage.bridge.generated.MobileOtpCodeKind
import de.rfc1437.ironstorage.bridge.generated.MobileOtpCodePresentation
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.clearMobileEntryCopy
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing
import de.rfc1437.ironstorage.bridge.generated.mobileOtpWindowCurrent
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativePasswordRepository(
private val session: NativePrivateSession,
private val scope: CoroutineScope,
private val authentication: NativeAuthenticationRepository,
private val io: CoroutineDispatcher,
private val clipboard: SecretClipboard,
private val unixSeconds: () -> ULong = ::unixSeconds
) : PasswordRepository {
private val mutable = MutableStateFlow(PasswordState())
override val state: StateFlow<PasswordState> = mutable
private val sequence = AtomicLong()
private val mutex = Mutex()
init {
val registration = authentication.presentation.register {
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
}
scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() }
// Hidden codes still expire; only the composed UI requests decryption/countdown refresh.
scope.launch {
mutable.map { state ->
state.otp?.takeIf { it.kind == PasswordOtpKind.Totp && it.code != null }?.validUntil
}.distinctUntilChanged().collectLatest { end ->
if (end != null) {
while (true) {
delay(1000L - Math.floorMod(System.currentTimeMillis(), 1000L))
expireOtp()
}
}
}
}
}
private suspend fun expireOtp() {
val current = mutable.value
val code = current.otp ?: return
if (code.validUntil != null && code.period != null && code.code != null) {
val valid = withContext(io) {
mobileOtpWindowCurrent(code.validUntil, code.period, unixSeconds())
}
if (!valid &&
mutable.value.otp === code
) {
mutable.value = current.copy(otp = code.masked())
}
}
}
override fun tickOtp() {
if (mutable.value.otp?.kind != PasswordOtpKind.Totp) return
scope.launch {
expireOtp()
if (!mutex.isLocked && !mutable.value.loading &&
authentication.state.value.unlocked && !authentication.state.value.busy
) {
refreshOtp(false)
}
}
}
override fun refreshOtp() = refreshOtp(true)
private fun refreshOtp(userActivity: Boolean) = content(userActivity) { current ->
val code = authentication.observe { auth ->
auth.presentedOtpCode(
current.entryPath(),
requireNotNull(current.documentRevision),
unixSeconds(),
current.otp?.counter
)
}
if (code == null) current else current.copy(otp = code.toFacade())
}
override fun advanceHotp() = content { current ->
val code = authentication.authenticated { auth ->
auth.advancePresentedHotp(
current.entryPath(),
requireNotNull(current.documentRevision),
true
)
}
val page = authentication.authenticated { auth ->
auth.refreshEntryPresentation(current.entryPath(), code.documentRevision, unixSeconds())
}
val folder = session.storage().passwordNavigation(current.path, null, null, false)
val refreshed = session.storage().passwordNavigation(
current.path,
current.selected?.id,
folder.revision,
false
).toFacade()
if (refreshed.selected == null) refreshed else refreshed.present(page, code)
}
override fun restore(path: String, selected: String?, revision: String?) =
navigate(path, selected, revision, false)
override fun openSearch(id: String, revision: String) {
mutable.value = PasswordState()
run { session.storage().openPasswordSearchResult(id, revision).toFacade() }
}
override fun refresh() {
val current = mutable.value
navigate(current.path, current.selected?.id, current.revision.ifEmpty { null }, false)
}
override fun activate(id: String, revision: String) =
navigate(mutable.value.path, id, revision, true)
override fun inspect(id: String, revision: String) =
navigate(mutable.value.path, id, revision, false)
override fun back() {
val current = mutable.value
navigate(
if (current.selected !=
null
) {
current.path
} else {
current.parent ?: ""
},
null,
null,
false
)
}
private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) =
run {
session.storage().passwordNavigation(path, selected, revision, activate).toFacade()
}
override fun unlock(passphrase: String) {
val current = mutable.value
val selected = current.selected ?: return
if (selected.kind != PasswordKind.Entry) return
run {
val verified = session.storage().passwordNavigation(
current.path,
selected.id,
current.revision,
false
).toFacade()
if (verified.selected == null) return@run verified
val (page, code) = authentication.authenticated { auth ->
val page = auth.navigationEntry(
current.path,
selected.id,
current.revision,
passphrase.ifEmpty { null },
unixSeconds()
)
page to
auth.presentedOtpCode(selected.path, page.page.revision, unixSeconds(), null)
}
verified.present(page, code)
}
}
override fun leave() {
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
authentication.lifecycle(AuthenticationLifecycle.Background)
}
override fun lock() {
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
authentication.lock()
}
override fun hide(id: ULong) {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(
loading = false,
sections = mutable.value.sections?.map { section ->
section.copy(
fields = section.fields.map { field ->
if (field.id ==
id
) {
field.copy(value = field.maskedValue, revealed = false)
} else {
field
}
}
)
}
)
}
override fun reveal(id: ULong) = content { current ->
val value = authentication.authenticated {
it.revealPresentedEntryField(
current.entryPath(),
id,
requireNotNull(current.documentRevision)
)
}
current.copy(
sections = current.sections?.map { section ->
section.copy(
fields = section.fields.map { field ->
if (field.id == id) field.copy(value = value, revealed = true) else field
}
)
}
)
}
override fun edit(id: ULong) = content { current ->
val field =
current.sections?.flatMap { it.fields }?.find { it.id == id && it.editable }
?: return@content current
val value = authentication.authenticated {
it.revealPresentedEntryField(
current.entryPath(),
id,
requireNotNull(current.documentRevision)
)
}
current.copy(
edit = PasswordEdit(
id,
field.label,
value,
field.multiline,
field.sensitive
)
)
}
override fun dismissEdit() {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(edit = null, loading = false)
}
override fun saveEdit(value: String) = content { current ->
val edit = current.edit ?: return@content current
authentication.authenticated {
it.replacePresentedEntryField(
current.entryPath(),
edit.id,
requireNotNull(current.documentRevision),
value
)
}
val (page, code) = authentication.authenticated {
val page = it.entryPage(current.entryPath(), unixSeconds())
page to
it.presentedOtpCode(current.entryPath(), page.page.revision, unixSeconds(), null)
}
val folder = session.storage().passwordNavigation(current.path, null, null, false)
val refreshed = session.storage().passwordNavigation(
current.path,
current.selected?.id,
folder.revision,
false
).toFacade()
if (refreshed.selected == null) refreshed else refreshed.present(page, code)
}
override fun copy(id: ULong) = copyValue { auth, current ->
auth.copyPresentedEntryField(
current.entryPath(),
id,
requireNotNull(current.documentRevision)
) to null
}
override fun copyOtp() = copyValue { auth, current ->
val now = unixSeconds()
val code = auth.presentedOtpCode(
current.entryPath(),
requireNotNull(current.documentRevision),
now,
current.otp?.counter
)
auth.copyPresentedOtpCode(
current.entryPath(),
requireNotNull(current.documentRevision),
now,
current.otp?.counter
) to code
}
private fun copyValue(
get: (de.rfc1437.ironstorage.bridge.generated.MobileAuthentication, PasswordState) ->
Pair<de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy, MobileOtpCodePresentation?>
) = content { current ->
val (copy, code) = authentication.authenticated { get(it, current) }
val ticket = sequence.get()
val owned = clipboard.beginCopy()
val written = CompletableDeferred<Boolean>()
val callback = object : MobileClipboard {
override fun read(): MobileClipboardContent = runBlocking(Dispatchers.Main.immediate) {
clipboardAccess { owned.read() }?.let { MobileClipboardContent.Text(it) }
?: MobileClipboardContent.EmptyOrNonText
}
override fun write(value: ByteArray) = runBlocking(Dispatchers.Main.immediate) {
// A lock may have happened after Rust returned the copy value.
if (!authentication.state.value.unlocked) {
throw MobilePlatformFfiException.Cancelled()
}
if (code?.kind == MobileOtpCodeKind.TOTP &&
!mobileOtpWindowCurrent(
requireNotNull(code.validUntil),
requireNotNull(code.period),
unixSeconds()
)
) {
throw MobilePlatformFfiException.Cancelled()
}
clipboardAccess { owned.write(value) }
written.complete(true)
Unit
}
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
clipboardAccess { owned.clear() }
}
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
val start = SystemClock.elapsedRealtime()
while ((SystemClock.elapsedRealtime() - start).toULong() <
milliseconds
) {
if (!authentication.state.value.unlocked) {
return MobileClipboardWait.CANCELLED
}
Thread.sleep(50)
}
return MobileClipboardWait.ELAPSED
}
}
val registration = withContext(Dispatchers.Main.immediate) {
authentication.presentation.register {
try {
clearMobileEntryCopy(copy, callback)
} catch (_: MobilePlatformFfiException) {
mutable.value =
mutable.value.copy(
error = session.text(R.string.core_clipboard_cleanup_unavailable)
)
}
}
}
scope.launch(io) {
try {
copyMobileEntryClearing(copy, callback)
} catch (_: MobilePlatformFfiException.Cancelled) {
written.complete(false)
// Rust cleans up before returning cancellation.
} catch (_: MobilePlatformFfiException) {
written.complete(false)
withContext(Dispatchers.Main.immediate) {
if (ticket == sequence.get()) {
mutable.value =
mutable.value.copy(
error = session.text(R.string.core_copy_unavailable),
notice = null
)
}
}
} finally {
withContext(kotlinx.coroutines.NonCancellable + Dispatchers.Main.immediate) {
registration.close()
}
}
}
if (written.await()) {
current.copy(notice = session.text(R.string.core_copied))
} else {
current.copy(error = session.text(R.string.core_copy_unavailable), notice = null)
}
}
override fun prepareMutation(action: PasswordMutation) {
val row = mutable.value.selected ?: return
prepareRowMutation(row.id, row.revision, action)
}
override fun prepareRowMutation(id: String, revision: String, action: PasswordMutation) {
run {
val plan = authentication.authenticated {
it.prepareNavigationMutation(id, revision, action.native())
}
mutable.value.copy(
mutation = PasswordMutationPlan(
action, plan.source, plan.revision,
plan.destinations.map {
PasswordDestination(
it.path,
it.title,
it.detail,
it.requiresOverwrite,
it.depth,
it.unavailable,
it.collisionTargets
)
},
plan.hasOpenEditor, plan.hasDirtyEditor,
if (plan.kind ==
MobilePasswordRowKind.DIRECTORY
) {
PasswordKind.Folder
} else {
PasswordKind.Entry
},
plan.sourceTitle, plan.entries
)
)
}
}
override fun unlockMutation(passphrase: String) {
val current = mutable.value
val plan = current.mutation ?: return
run(false) {
authentication.authenticated {
it.unlockMutation(plan.source, plan.revision, passphrase.ifEmpty { null })
}
current
}
}
override fun dismissMutation() {
sequence.incrementAndGet()
mutable.value = mutable.value.copy(mutation = null, loading = false, error = null)
}
override fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean) {
val current = mutable.value
val plan = current.mutation ?: return
if (current.loading || !authentication.state.value.unlocked) return
authentication.touch()
run {
val outcome = try {
authentication.authenticated {
it.performNavigationMutation(
MobileMutationRequest(
plan.action.native(),
plan.source,
plan.revision,
destination,
true,
overwrite,
discardEditor
),
current.path
)
}
} catch (error: MobileAuthenticationFfiException.Failed) {
return@run session.storage().passwordNavigation(current.path, null, null, false)
.toFacade().copy(error = error.title + "\n" + error.detail)
}
withContext(Dispatchers.Main.immediate) {
authentication.presentation.editorsDiscarded(outcome.discardedEditors.toSet())
}
outcome.navigation.toFacade().copy(
notice =
outcome.result.title + "\n" + outcome.result.detail
)
}
}
private fun content(
userActivity: Boolean = true,
action: suspend (PasswordState) -> PasswordState
) {
val current = mutable.value
if (current.sections == null || current.selected == null ||
current.documentRevision == null || !authentication.state.value.unlocked ||
current.loading
) {
return
}
if (userActivity) authentication.touch()
run(false, userActivity) { action(current) }
}
private fun run(
clear: Boolean = true,
showLoading: Boolean = true,
action: suspend () -> PasswordState
) {
val ticket = sequence.incrementAndGet()
mutable.value =
(if (clear) mutable.value.cleared() else mutable.value).copy(
loading = showLoading,
error = null
)
scope.launch {
mutex.withLock {
if (sequence.get() != ticket) return@withLock
var result = try {
withContext(io) { action() }
} catch (error: MobilePasswordFfiException.Failed) {
mutable.value.copy(error = error.detail, selected = null)
} catch (error: MobileAuthenticationFfiException.Failed) {
mutable.value.copy(error = error.detail)
} catch (_: MobilePrivateStorageFfiException) {
mutable.value.copy(
error = session.text(R.string.core_password_recovery)
)
} catch (_: NativeBridgeException) {
mutable.value.copy(error = session.text(R.string.core_update_retry))
} catch (_: java.io.IOException) {
mutable.value.copy(
error = session.text(R.string.core_password_storage),
selected = null
)
}
val code = result.otp
if (code?.kind == PasswordOtpKind.Totp && code.code != null &&
code.validUntil != null &&
code.period != null
) {
val valid =
withContext(io) {
mobileOtpWindowCurrent(code.validUntil, code.period, unixSeconds())
}
if (!valid) result = result.copy(otp = code.masked())
}
if (sequence.get() == ticket) {
mutable.value =
(
if (authentication.state.value.unlocked) {
result
} else {
result.cleared().copy(
mutation = result.mutation
)
}
)
.copy(loading = false)
}
}
}
}
}
internal fun de.rfc1437.ironstorage.bridge.generated.MobileNavigationRow.toFacade(): PasswordRow =
PasswordRow(
row.id,
row.path,
row.title,
row.detail,
when (row.kind) {
MobilePasswordRowKind.DIRECTORY -> PasswordKind.Folder
MobilePasswordRowKind.ENTRY -> PasswordKind.Entry
},
revision,
row.icon.toFacade()
)
internal fun MobilePasswordNavigation.toFacade(): PasswordState = PasswordState(
path,
title,
parent,
revision,
rows.map { it.toFacade() },
selected?.toFacade(),
stale = stale
)
private fun unixSeconds() = (System.currentTimeMillis() / 1000).toULong()
private fun PasswordState.cleared() = copy(
sections = null,
otp = null,
documentRevision = null,
edit = null,
mutation = null,
notice = null
)
private fun PasswordState.present(
value: MobileEntryPresentation,
code: MobileOtpCodePresentation?
) = copy(
documentRevision = value.page.revision,
sections = value.page.sections.map { section ->
PasswordSection(
section.title,
section.fields.map { field ->
PasswordField(
field.id, field.label, field.value ?: field.maskedValue, field.diagnostic,
field.sensitive, field.selectable, field.editable,
field.multiline, field.detail,
maskedValue = field.maskedValue
)
}
)
},
otp = code?.toFacade(),
notice = value.cacheNotice,
edit = null,
mutation = null
)
private fun PasswordOtp.masked() = copy(code = null, groupedCode = null, remainingSeconds = 0u)
private fun MobileOtpCodePresentation.toFacade() = PasswordOtp(
when (kind) {
MobileOtpCodeKind.TOTP -> PasswordOtpKind.Totp
MobileOtpCodeKind.HOTP -> PasswordOtpKind.Hotp
},
code, groupedCode, issuer, account, remainingSeconds, period, validUntil, counter, nextCounter
)
private fun PasswordMutation.native() = when (this) {
PasswordMutation.Move -> MobileMutationAction.MOVE
PasswordMutation.Copy -> MobileMutationAction.COPY
PasswordMutation.Delete -> MobileMutationAction.DELETE
}
private fun PasswordState.entryPath() = requireNotNull(selected).path
private fun <T> clipboardAccess(action: () -> T): T = try {
action()
} catch (_: java.io.IOException) {
throw MobilePlatformFfiException.ClipboardFailed()
}
@@ -0,0 +1,47 @@
package de.rfc1437.ironstorage.core
import android.content.Context
import de.rfc1437.ironstorage.bridge.NativeRuntime
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime
import de.rfc1437.ironstorage.bridge.generated.MobileSecretBackend
/** Both facades share one Rust owner and retain its Keystore callbacks for its lifetime. */
class NativePrivateSession(
private val context: Context,
private val directories: () -> PrivateDirectories,
private val keystore: AndroidKeystore
) {
internal fun text(id: Int): String = context.getString(id)
val runtime = NativeRuntime(context)
fun cancelAuthentication() = keystore.cancelPending()
fun initialize() {
storage()
}
private var initialized: MobilePrivateRuntime? = null
private var secrets: MobileSecretBackend? = null
@Synchronized
fun storage(): MobilePrivateRuntime {
initialized?.let { return it }
val roots = directories()
val storage = runtime.privateStorage(
MobilePrivateDirectories(roots.files, roots.noBackup, roots.cache)
)
storage.activate()
val backend = storage.androidSecretBackend(KeystoreCallback(keystore))
storage.verifySecureStorage(backend)
secrets = backend
initialized = storage
return storage
}
@Synchronized
fun backend(): MobileSecretBackend {
storage()
return requireNotNull(secrets)
}
}
@@ -0,0 +1,441 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileCameraFrame
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransfer
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferImport
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferKind
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferProgress
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
import de.rfc1437.ironstorage.bridge.generated.mobileKeyTransfer
import de.rfc1437.ironstorage.bridge.generated.mobileLocalKeyTransferImporter
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativeSettingsRepository(
private val session: NativePrivateSession,
private val authentication: NativeAuthenticationRepository,
private val platform: SettingsPlatform,
private val scope: CoroutineScope
) : SettingsRepository {
private val mutable = MutableStateFlow(SettingsState())
override val state: StateFlow<SettingsState> = mutable
private val operation = Mutex()
private val cameraBusy = AtomicBoolean()
@Volatile private var epoch = 0L
@Volatile private var discovery: MobileTotpOperation? = null
private var visibility: Job? = null
private var transfer: MobileKeyTransfer? = null
private var importer: MobileKeyTransferImport? = null
init {
val registration = authentication.presentation.register {
if (mutable.value.transfer.open) closeTransfer()
}
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
}
private suspend fun page(): SettingsPage {
val values = authentication.authenticated {
Triple(it.preferences(), it.gitIdentity(), it.totpCacheStatus())
}
val (preferences, identity, cache) = values
return SettingsPage(
preferences.repositoryTitle, preferences.repositoryUrl,
preferences.serverTitle, preferences.serverIdentity, preferences.applicationAccount,
preferences.defaultKeyTitle, preferences.defaultKeyFingerprint,
identity.name, identity.email, preferences.authenticationTimeoutSeconds,
cache.detail, platform.biometricsAvailable(), platform.autofill(),
platform.credentials(), platform.version()
)
}
override fun reload() = run { mutable.value = mutable.value.copy(page = page()) }
override fun setDefaultKey(fingerprint: String) = run {
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState())
releaseTransfer()
authentication.authenticated { it.setDefaultKey(fingerprint) }
authentication.lock()
mutable.value =
mutable.value.copy(
page = page(),
notice = session.text(R.string.core_default_key_updated)
)
}
override fun setGitIdentity(name: String, email: String) = run {
authentication.authenticated { it.setGitIdentity(name, email) }
mutable.value =
mutable.value.copy(
page = page(),
notice = session.text(R.string.core_git_author_updated)
)
}
override fun setTimeout(seconds: String) = run {
authentication.authenticated { it.setAuthenticationTimeoutText(seconds) }
mutable.value =
mutable.value.copy(page = page(), notice = session.text(R.string.core_timeout_updated))
}
override fun removeToken() = run {
authentication.authenticated { it.removeApplicationToken() }
mutable.value =
mutable.value.copy(
page = page(),
notice = session.text(R.string.core_token_removed)
)
}
override fun rebuildCache(passphrase: String) = run {
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
discovery = active
try {
val result = authentication.authenticated {
it.unlockTotp(passphrase.ifEmpty { null })
it.rebuildTotpCache(active)
}
mutable.value =
mutable.value.copy(
page = page(),
notice =
result.cacheNotice ?: session.text(R.string.core_cache_rebuilt)
)
} finally {
discovery = null
active.close()
}
}
override fun openAutofill() {
if (!platform.openAutofill()) {
mutable.value =
mutable.value.copy(
error = session.text(R.string.core_autofill_settings_unavailable)
)
}
}
override fun openCredentials() {
if (!platform.openCredentials()) {
mutable.value =
mutable.value.copy(
error = session.text(R.string.core_credential_settings_unavailable)
)
}
}
override fun beginTransfer() = begin(false)
override fun beginSetupTransfer() = begin(true)
private fun begin(setup: Boolean) = run { ticket ->
withContext(Dispatchers.IO) {
session.storage()
releaseTransfer()
if (setup) {
importer = mobileLocalKeyTransferImporter()
} else {
transfer = mobileKeyTransfer()
importer = requireNotNull(transfer).importer()
}
}
val keys = withContext(Dispatchers.IO) { transfer?.keys().orEmpty() }
if (ticket ==
epoch
) {
mutable.value = mutable.value.copy(
transfer = KeyTransferState(
open = true,
setup = setup,
keys = keys.map {
TransferKey(
it.fingerprint,
it.title,
it.kind == MobileKeyTransferKind.PRIVATE
)
}
)
)
}
}
override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) =
run { ticket ->
visibility?.cancel()
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value =
mutable.value.copy(
transfer = mutable.value.transfer.copy(frames = emptyList(), exportedKey = null)
)
val result = withContext(Dispatchers.IO) {
requireNotNull(transfer).export(
fingerprint,
if (privateKey) MobileKeyTransferKind.PRIVATE else MobileKeyTransferKind.PUBLIC,
passphrase.ifEmpty { null }
)
}
if (ticket ==
epoch
) {
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(
transfer = mutable.value.transfer.copy(
exportedKey = TransferKey(
result.key.fingerprint,
result.key.title,
result.key.kind == MobileKeyTransferKind.PRIVATE
),
frames = result.frames.map {
TransferFrame(it.sequence, it.total, it.width, it.modules)
}
)
)
visibility = scope.launch {
while (ticket == epoch) {
kotlinx.coroutines.delay(250)
val visible = operation.withLock {
withContext(Dispatchers.IO) { transfer?.exportVisible() == true }
}
if (ticket == epoch && !visible) {
closeTransfer()
break
}
}
}
} else {
result.frames.forEach { it.modules.fill(0) }
}
}
private fun progress(ticket: Long, progress: MobileKeyTransferProgress) {
if (ticket != epoch) return
mutable.value = mutable.value.copy(
error = null,
transfer = mutable.value.transfer.copy(
received = progress.received,
total = progress.total,
duplicate = progress.duplicate,
ready = progress.key?.let {
TransferKey(
it.fingerprint,
it.title,
it.kind == MobileKeyTransferKind.PRIVATE
)
}
)
)
}
override fun cameraPreview(): CameraPreview? {
val ticket = epoch
return platform.cameraPreview({ frame ->
if (!cameraBusy.compareAndSet(false, true)) {
frame.pixels.fill(0)
} else {
scope.launch {
try {
operation.withLock {
if (ticket != epoch || !mutable.value.transfer.open ||
mutable.value.transfer.ready != null || mutable.value.busy
) {
return@withLock
}
val result = withContext(Dispatchers.IO) {
requireNotNull(importer).addCamera(
MobileCameraFrame(
frame.width,
frame.height,
frame.rowStride,
frame.pixelStride,
frame.pixels
),
frame.rotationDegrees
)
}
frame.pixels.fill(0)
if (result != null) progress(ticket, result)
}
} catch (error: MobileKeyTransferFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value = mutable.value.copy(error = error.rustMessage)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_update_retry))
}
} finally {
frame.pixels.fill(0)
cameraBusy.set(false)
}
}
}
}, {
scope.launch {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_camera_unavailable))
}
}
})
}
override fun chooseQrImages() {
if (!platform.chooseQrImages()) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_picker_unavailable))
}
}
override fun addQrImages(uris: List<String>) {
scope.launch {
mutable.first { !it.busy }
run { ticket ->
if (importer == null) {
val keys = withContext(Dispatchers.IO) {
session.storage()
transfer = mobileKeyTransfer()
importer = requireNotNull(transfer).importer()
requireNotNull(transfer).keys()
}
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
transfer = KeyTransferState(
open = true,
keys = keys.map {
TransferKey(
it.fingerprint,
it.title,
it.kind == MobileKeyTransferKind.PRIVATE
)
}
)
)
}
}
for (uri in uris) {
if (ticket != epoch) break
val bytes = withContext(Dispatchers.IO) { platform.readQrImage(uri) }
try {
progress(
ticket,
withContext(Dispatchers.IO) {
requireNotNull(importer).addImage(bytes)
}
)
} finally {
bytes.fill(0)
}
}
}
}
}
override fun addQrPayload(payload: String) = run { ticket ->
progress(ticket, withContext(Dispatchers.IO) { requireNotNull(importer).addFrame(payload) })
}
override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = run {
if (mutable.value.transfer.setup) {
withContext(Dispatchers.IO) {
session.storage().androidOnboarding(session.backend()).use {
it.importScanned(requireNotNull(importer), passphrase.ifEmpty { null })
}
}
closeTransfer()
return@run
}
val result = withContext(Dispatchers.IO) {
requireNotNull(importer).import(passphrase.ifEmpty { null }, makeDefault, overwrite)
}
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState())
releaseTransfer()
authentication.lock()
withContext(Dispatchers.IO) { session.storage().keyMaterialChanged() }
mutable.value =
mutable.value.copy(page = page(), notice = result.title + "\n" + result.detail)
}
private fun releaseTransfer() {
importer?.close()
transfer?.close()
importer = null
transfer = null
}
override fun closeTransfer() {
visibility?.cancel()
visibility = null
epoch += 1
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState(), busy = false)
scope.launch { operation.withLock { releaseTransfer() } }
}
override fun cancel() {
epoch += 1
discovery?.cancel()
closeTransfer()
}
private fun run(action: suspend (Long) -> Unit) {
if (mutable.value.busy) return
val ticket = epoch
mutable.value = mutable.value.copy(busy = true, error = null, notice = null)
scope.launch {
operation.withLock {
if (ticket != epoch) return@withLock
try {
action(ticket)
if (ticket != epoch) {
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState())
}
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = error.title + "\n" + error.detail)
}
} catch (error: MobileKeyTransferFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value = mutable.value.copy(error = error.rustMessage)
}
} catch (_: MobilePrivateStorageFfiException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
error =
session.text(R.string.core_private_recovery)
)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_update_retry))
}
} catch (_: java.io.IOException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_image_unreadable))
}
} finally {
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
}
}
}
}
}
@@ -0,0 +1,218 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryPhase
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryProgress
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativeTotpRepository(
private val session: NativePrivateSession,
private val authentication: NativeAuthenticationRepository,
override val viewer: PasswordRepository,
private val scope: CoroutineScope
) : TotpRepository {
private val mutable = MutableStateFlow(TotpState())
override val state: StateFlow<TotpState> = mutable
private val serial = Mutex()
private var searchEpoch = 0L
private var refreshEpoch = 0L
private var debounce: Job? = null
@Volatile private var search: MobileTotpOperation? = null
@Volatile private var discovery: MobileTotpOperation? = null
init {
val cleanup = authentication.presentation.register(::cancelDiscovery)
val metadata = authentication.presentation.registerMetadata(::cancel)
scope.coroutineContext[Job]?.invokeOnCompletion {
cleanup.close()
metadata.close()
}
}
override fun enter(selected: String?, revision: String?) = load(selected, revision)
override fun reload() = load(null, null)
override fun query(value: String) {
mutable.value = mutable.value.copy(query = value)
load(null, null, true)
}
private fun load(selected: String?, revision: String?, wait: Boolean = false) {
val ticket = ++searchEpoch
search?.cancel()
debounce?.cancel()
val query = mutable.value.query
mutable.value = mutable.value.copy(searching = true, error = null)
// Debounce only queues work; native calls finish under the serial gate after cancellation.
debounce = scope.launch {
if (wait) delay(200)
scope.launch {
serial.withLock {
if (ticket != searchEpoch) return@withLock
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
search = active
if (ticket != searchEpoch) active.cancel()
try {
val rows = authentication.metadata {
it.searchCachedTotpNavigation(query, active)
}
if (ticket == searchEpoch) {
mutable.value = mutable.value.copy(
rows = rows.orEmpty().map { it.toFacade() },
hasCatalog = rows != null
)
if (selected != null && revision != null) open(selected, revision)
}
} catch (error: Exception) {
if (ticket == searchEpoch) failure(error)
} finally {
if (search === active) search = null
active.close()
if (ticket ==
searchEpoch
) {
mutable.value = mutable.value.copy(searching = false)
}
}
}
}
}
}
override fun open(id: String, revision: String) {
if (mutable.value.rows.none { it.id == id && it.revision == revision }) return
mutable.value = mutable.value.copy(detail = true, error = null)
viewer.openSearch(id, revision)
}
override fun back() {
viewer.leave()
mutable.value = mutable.value.copy(detail = false)
reload()
}
override fun discover(passphrase: String) {
if (mutable.value.refreshing) return
val ticket = ++refreshEpoch
mutable.value = mutable.value.copy(
refreshing = true,
error = null,
notice = null,
progress = TotpDiscoveryProgress(TotpDiscoveryPhase.Preparing, 0, 0, 0, 0, 0)
)
scope.launch {
serial.withLock {
if (ticket != refreshEpoch) return@withLock
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
discovery = active
if (ticket != refreshEpoch) active.cancel()
try {
coroutineScope {
val polling = launch {
while (true) {
val progress =
withContext(Dispatchers.IO) { active.progress().facade() }
if (ticket ==
refreshEpoch
) {
mutable.value =
mutable.value.copy(progress = progress)
}
delay(100)
}
}
try {
val result = authentication.authenticated {
it.refreshTotpCatalog(active, passphrase.ifEmpty { null })
}
if (ticket ==
refreshEpoch
) {
mutable.value =
mutable.value.copy(
progress = result.progress.facade(),
notice = result.cacheNotice
)
}
} finally {
polling.cancelAndJoin()
}
}
} catch (error: Exception) {
if (ticket == refreshEpoch) failure(error)
} finally {
if (discovery === active) discovery = null
active.close()
if (ticket == refreshEpoch) {
// Keep loading true until the refreshed rows have been published.
if (mutable.value.error == null) reload()
mutable.value = mutable.value.copy(refreshing = false)
}
}
}
}
}
override fun cancel() {
searchEpoch++
debounce?.cancel()
search?.cancel()
cancelDiscovery()
mutable.value = mutable.value.copy(searching = false)
}
private fun cancelDiscovery() {
refreshEpoch++
discovery?.cancel()
mutable.value = mutable.value.copy(
refreshing = false,
progress = if (mutable.value.refreshing) {
mutable.value.progress?.copy(
phase = TotpDiscoveryPhase.Cancelled
)
} else {
mutable.value.progress
}
)
}
override fun leave() {
cancel()
viewer.leave()
}
private fun failure(error: Exception) {
val text = when (error) {
is MobileAuthenticationFfiException.Failed -> error.title + "\n" + error.detail
is MobilePrivateStorageFfiException -> session.text(R.string.core_private_recovery)
is NativeBridgeException -> session.text(R.string.core_update_retry)
is java.io.IOException -> session.text(R.string.core_private_recovery)
is kotlinx.coroutines.CancellationException -> throw error
else -> throw error
}
mutable.value = mutable.value.copy(error = text)
}
}
private fun MobileTotpDiscoveryProgress.facade() = TotpDiscoveryProgress(
when (phase) {
MobileTotpDiscoveryPhase.PREPARING -> TotpDiscoveryPhase.Preparing
MobileTotpDiscoveryPhase.INSPECTING -> TotpDiscoveryPhase.Inspecting
MobileTotpDiscoveryPhase.SAVING -> TotpDiscoveryPhase.Saving
MobileTotpDiscoveryPhase.COMPLETE -> TotpDiscoveryPhase.Complete
MobileTotpDiscoveryPhase.CANCELLED -> TotpDiscoveryPhase.Cancelled
},
total.toLong(),
inspected.toLong(),
cacheHits.toLong(),
matches.toLong(),
unavailable.toLong()
)
@@ -0,0 +1,31 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
data class SetupKey(val fingerprint: String, val userIds: List<String>)
data class SetupStatus(
val configured: Boolean = false,
val connected: Boolean = false,
val existingClone: Boolean = false,
val keys: List<SetupKey> = emptyList(),
val selectedKey: String = "",
val branches: List<String> = emptyList(),
val selectedBranch: String = "",
val busy: Boolean = false,
val title: String = "",
val detail: String = "",
val failure: Boolean = false
)
interface OnboardingRepository {
val setup: StateFlow<SetupStatus>
fun refresh()
fun generate(userId: String, passphrase: String)
fun importKey(armor: String, passphrase: String)
fun createLocal(fingerprint: String, passphrase: String)
fun discover(server: String, account: String, repository: String, token: String)
fun cloneStore(branch: String, fingerprint: String, passphrase: String, useExisting: Boolean)
fun connect(server: String, account: String, repository: String, token: String)
fun replaceToken(account: String, token: String)
fun cancel()
}
@@ -0,0 +1,119 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class PasswordKind { Folder, Entry }
data class PasswordRow(
val id: String,
val path: String,
val title: String,
val detail: String,
val kind: PasswordKind,
val revision: String,
val icon: ShellIcon
)
data class PasswordField(
val id: ULong,
val label: String,
val value: String,
val diagnostic: String?,
val sensitive: Boolean = false,
val selectable: Boolean = false,
val editable: Boolean = false,
val multiline: Boolean = false,
val detail: String? = null,
val revealed: Boolean = false,
val maskedValue: String = value
) {
override fun toString() = "PasswordField(id=$id, [REDACTED])"
}
data class PasswordSection(val title: String, val fields: List<PasswordField>)
enum class PasswordOtpKind { Totp, Hotp }
data class PasswordOtp(
val kind: PasswordOtpKind,
val code: String?,
val groupedCode: String?,
val issuer: String?,
val account: String,
val remainingSeconds: ULong?,
val period: ULong?,
val validUntil: ULong?,
val counter: ULong?,
val nextCounter: ULong?
) {
override fun toString() = "PasswordOtp([REDACTED])"
}
data class PasswordEdit(
val id: ULong,
val label: String,
val value: String,
val multiline: Boolean,
val sensitive: Boolean
) {
override fun toString() = "PasswordEdit(id=$id, [REDACTED])"
}
enum class PasswordMutation { Move, Copy, Delete }
data class PasswordDestination(
val path: String,
val title: String,
val detail: String,
val overwrite: Boolean,
val depth: UInt = 0u,
val unavailable: String? = null,
val collisionTargets: List<String> = emptyList()
)
data class PasswordMutationPlan(
val action: PasswordMutation,
val source: String,
val revision: String,
val destinations: List<PasswordDestination>,
val hasOpenEditor: Boolean,
val hasDirtyEditor: Boolean = false,
val kind: PasswordKind = PasswordKind.Entry,
val sourceTitle: String = source,
val entries: UInt = 1u
)
data class PasswordState(
val path: String = "",
val title: String = "",
val parent: String? = null,
val revision: String = "",
val rows: List<PasswordRow> = emptyList(),
val selected: PasswordRow? = null,
val loading: Boolean = false,
val stale: Boolean = false,
val error: String? = null,
val sections: List<PasswordSection>? = null,
val otp: PasswordOtp? = null,
val documentRevision: String? = null,
val notice: String? = null,
val edit: PasswordEdit? = null,
val mutation: PasswordMutationPlan? = null
)
interface PasswordRepository {
val state: StateFlow<PasswordState>
fun restore(path: String, selected: String?, revision: String?)
fun openSearch(id: String, revision: String)
fun refresh()
fun activate(id: String, revision: String)
fun inspect(id: String, revision: String)
fun back()
fun unlock(passphrase: String)
fun leave() = lock()
fun lock()
fun reveal(id: ULong)
fun hide(id: ULong)
fun copy(id: ULong)
fun copyOtp()
fun tickOtp() = Unit
fun refreshOtp()
fun advanceHotp()
fun edit(id: ULong)
fun dismissEdit()
fun saveEdit(value: String)
fun prepareMutation(action: PasswordMutation)
fun prepareRowMutation(id: String, revision: String, action: PasswordMutation)
fun unlockMutation(passphrase: String)
fun dismissMutation()
fun mutate(destination: String?, overwrite: Boolean, discardEditor: Boolean = false)
}
@@ -0,0 +1,25 @@
package de.rfc1437.ironstorage.core
data class PrivateDirectories(val files: String, val noBackup: String, val cache: String) {
override fun toString(): String = "PrivateDirectories([REDACTED])"
}
enum class StorageRecovery {
InvalidRoots,
UnsafePath,
MissingRoot,
AccessDenied,
ReadOnly,
StorageFull,
Busy,
Io,
CorruptLayout,
MigrationRequired,
CorruptConfiguration,
ConfigurationMissingWithData,
InterruptedSetup,
RestoredWithoutKeystore,
SecureStorageLocked,
SecureStorageUnavailable,
InvalidSecureBinding
}
@@ -0,0 +1,55 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** Main-thread presentation cleanup; Rust still owns authentication decisions. */
class ProtectedPresentation {
private var windows = 0
val hasOwnedWindow: Boolean get() = windows > 0
var onWindowFocusLost: () -> Unit = { clear() }
fun registerWindow(): AutoCloseable {
windows += 1
return AutoCloseable { windows -= 1 }
}
private val metadata = linkedMapOf<Any, () -> Unit>()
fun registerMetadata(clear: () -> Unit): AutoCloseable {
val registration = Any()
synchronized(metadata) { metadata[registration] = clear }
return AutoCloseable { synchronized(metadata) { metadata.remove(registration) } }
}
fun clearMetadata() {
synchronized(metadata) { metadata.values.toList() }.forEach { it() }
}
private val editorListeners = linkedMapOf<Any, (Set<ULong>) -> Unit>()
fun registerEditorDiscard(discard: (Set<ULong>) -> Unit): AutoCloseable {
val registration = Any()
synchronized(editorListeners) { editorListeners[registration] = discard }
return AutoCloseable {
synchronized(editorListeners) { editorListeners.remove(registration) }
}
}
fun editorsDiscarded(ids: Set<ULong>) {
synchronized(editorListeners) { editorListeners.values.toList() }.forEach { it(ids) }
}
private val listeners = linkedMapOf<Any, () -> Unit>()
private val mutableGeneration = MutableStateFlow(0L)
val generation: StateFlow<Long> = mutableGeneration
fun register(clear: () -> Unit): AutoCloseable {
val registration = Any()
synchronized(listeners) { listeners[registration] = clear }
return AutoCloseable { synchronized(listeners) { listeners.remove(registration) } }
}
fun clear() {
synchronized(listeners) { listeners.values.toList() }.forEach { it() }
mutableGeneration.value += 1
}
}
enum class AuthenticationLifecycle {
Foreground,
Background,
DeviceLocked,
MemoryPressure,
ProcessRecreated
}
@@ -0,0 +1,115 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class SearchState(
val query: String = "",
val loading: Boolean = false,
val results: List<PasswordRow> = emptyList(),
val error: String? = null,
val cancelled: Boolean = false
) {
override fun toString() = "SearchState(loading=$loading, [REDACTED])"
}
interface SearchRepository {
val state: StateFlow<SearchState>
fun query(value: String)
fun refresh()
fun cancel()
fun clear()
}
/** Only the Rust loader matches names; cancellation prevents stale native results publishing. */
class NativeSearchRepository internal constructor(
private val scope: CoroutineScope,
presentation: ProtectedPresentation,
private val failureText: () -> String = { "" },
private val load: suspend (String) -> List<PasswordRow>
) : SearchRepository {
constructor(
session: NativePrivateSession,
scope: CoroutineScope,
io: CoroutineDispatcher,
presentation: ProtectedPresentation
) : this(scope, presentation, { session.text(R.string.core_search_unavailable) }, { query ->
withContext(io) {
session.storage().passwordNameSearch(query).map { it.toFacade() }
}
})
private val mutable = MutableStateFlow(SearchState())
override val state: StateFlow<SearchState> = mutable
private var sequence = 0L
private var pending: Job? = null
init {
val registration = presentation.registerMetadata(::clear)
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
}
override fun query(value: String) {
cancel()
mutable.value = SearchState(query = value)
request(true)
}
override fun refresh() {
cancel()
request(false)
}
private fun request(debounce: Boolean) {
val query = mutable.value.query
if (query.isBlank()) return
val ticket = sequence
mutable.value =
mutable.value.copy(
loading = true,
results = emptyList(),
error = null,
cancelled = false
)
pending = scope.launch {
if (debounce) delay(250)
try {
val rows = load(query)
if (sequence == ticket) mutable.value = SearchState(query, results = rows)
} catch (error: CancellationException) {
throw error
} catch (_: MobilePasswordFfiException) {
failure(ticket, query)
} catch (_: MobilePrivateStorageFfiException) {
failure(ticket, query)
} catch (_: de.rfc1437.ironstorage.bridge.NativeBridgeException) {
failure(ticket, query)
} catch (_: java.io.IOException) {
failure(ticket, query)
}
}
}
private fun failure(ticket: Long, query: String) {
if (sequence == ticket) {
mutable.value = SearchState(
query,
error = failureText()
)
}
}
override fun cancel() {
val cancelled = mutable.value.loading
sequence += 1
pending?.cancel()
pending = null
mutable.value =
mutable.value.copy(loading = false, results = emptyList(), cancelled = cancelled)
}
override fun clear() {
cancel()
mutable.value = SearchState()
}
}
@@ -0,0 +1,9 @@
package de.rfc1437.ironstorage.core
/** OS clipboard capability; Rust owns timeout, cancellation cleanup and content comparison. */
interface SecretClipboard {
fun beginCopy(): SecretClipboard
fun read(): ByteArray?
fun write(value: ByteArray)
fun clear()
}
Loaded 100 of 330 files, more files were not shown because too many files have changed in this diff. Show more