Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf83a0ba41 | ||
|
|
b54636b7bc | ||
|
|
45d78aa5b3 | ||
|
|
f4a6edbc68 | ||
|
|
0c8366d34f | ||
|
|
316a875d63 | ||
|
|
6a864a8d7f | ||
|
|
d466b170ab | ||
|
|
6669b77f4e | ||
|
|
43f660ace3 | ||
|
|
06575363a2 | ||
|
|
d22d71eb5e | ||
|
|
c7703d8efd | ||
|
|
a06f39af23 | ||
|
|
c0ba6d43d8 | ||
|
|
647dc1bb4b | ||
|
|
5c933a99a3 | ||
|
|
8c9341f214 | ||
|
|
36e5f55283 | ||
|
|
66b1d4a4c4 | ||
|
|
1abd75282e | ||
|
|
874c8139a4 | ||
|
|
180851087b | ||
|
|
fcb36a0684 | ||
|
|
e079b82b15 | ||
|
|
30d9119302 | ||
|
|
71a4f289cf |
No files matched your search
@@ -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")
|
||||
@@ -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]))
|
||||
Executable
+21
@@ -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:])])
|
||||
@@ -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
|
||||
@@ -3,3 +3,12 @@
|
||||
/distribution/
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
/android/.gradle/
|
||||
/android/.kotlin/
|
||||
/android/**/build/
|
||||
/android/local.properties
|
||||
/android/.idea/
|
||||
*.jks
|
||||
*.keystore
|
||||
/.gitea/scripts/__pycache__/
|
||||
Generated
+68
-22
@@ -1439,9 +1439,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher 0.5.2",
|
||||
@@ -2271,6 +2271,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
@@ -2883,10 +2892,12 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
|
||||
|
||||
[[package]]
|
||||
name = "faster-hex"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
|
||||
checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"defmt 0.3.100",
|
||||
"heapless",
|
||||
"serde",
|
||||
]
|
||||
@@ -3844,7 +3855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5390,6 +5401,7 @@ checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||
name = "ironstorage"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"apple-native-keyring-store",
|
||||
"arboard",
|
||||
"cap-std",
|
||||
@@ -5415,6 +5427,7 @@ dependencies = [
|
||||
"reqwest 0.13.4",
|
||||
"rqrr",
|
||||
"russh",
|
||||
"rustix 1.1.4",
|
||||
"rustls",
|
||||
"secret-service",
|
||||
"security-framework 3.7.0",
|
||||
@@ -5433,12 +5446,38 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ironstorage-apple"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"ironstorage",
|
||||
"uniffi",
|
||||
"ironstorage-mobile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5488,6 +5527,14 @@ dependencies = [
|
||||
"simple-file-manifest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironstorage-mobile"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"ironstorage",
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironstorage-tui"
|
||||
version = "1.0.1"
|
||||
@@ -5552,7 +5599,7 @@ version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"defmt 1.1.1",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
@@ -5569,7 +5616,7 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"defmt 1.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5969,15 +6016,12 @@ name = "lru"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.1"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6"
|
||||
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
@@ -8111,7 +8155,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"itertools 0.14.0",
|
||||
"kasuari",
|
||||
"lru 0.18.1",
|
||||
"lru 0.18.2",
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
@@ -8487,12 +8531,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rqrr"
|
||||
version = "0.10.1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffbe87d9e8db95652c25ded2418150e00b08c2fde09e23ec15896d2c470c6631"
|
||||
checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b"
|
||||
dependencies = [
|
||||
"g2p",
|
||||
"lru 0.16.4",
|
||||
"lru 0.18.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8719,9 +8763,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -8805,9 +8849,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -10542,7 +10586,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"camino",
|
||||
"cargo_metadata",
|
||||
"clap",
|
||||
"uniffi_bindgen",
|
||||
"uniffi_core",
|
||||
"uniffi_macros",
|
||||
|
||||
+6
-2
@@ -4,8 +4,11 @@ members = [
|
||||
"apps/desktop",
|
||||
"apps/tui",
|
||||
"crates/apple",
|
||||
"crates/android",
|
||||
"crates/mobile-bridge",
|
||||
"crates/storage",
|
||||
"crates/watch-apple",
|
||||
"tools/android-build",
|
||||
"tools/apple-release",
|
||||
"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"] }
|
||||
cap-std = "4.0"
|
||||
cap-tempfile = "4.0"
|
||||
rustix = { version = "1.1", features = ["fs"] }
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
clap_complete = "4.6"
|
||||
crossterm = "0.29"
|
||||
@@ -50,9 +54,9 @@ qrcode = { version = "0.14", default-features = false }
|
||||
rand = "0.8"
|
||||
regex = "1.13"
|
||||
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 }
|
||||
rqrr = { version = "0.10", default-features = false }
|
||||
rqrr = { version = "0.11", default-features = false }
|
||||
rpassword = "7.5"
|
||||
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"] }
|
||||
|
||||
+29
-1
@@ -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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
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
|
||||
fixture, emits independently decryptable single- and multi-recipient messages,
|
||||
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.
|
||||
@@ -75,6 +75,22 @@ Rollback-safe remove, move, and copy transactions, destination rules, and
|
||||
selective subtree reencryption are documented in
|
||||
[`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
|
||||
|
||||
```text
|
||||
@@ -84,6 +100,8 @@ apps/cli pass-compatible command-line frontend
|
||||
apps/tui Ratatui frontend
|
||||
apps/desktop Iced desktop frontend for macOS, Windows, and Linux
|
||||
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:
|
||||
@@ -93,6 +111,9 @@ cd apple
|
||||
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
|
||||
and Watch release checks are documented in
|
||||
[`apple/AppStore/DISTRIBUTION.md`](apple/AppStore/DISTRIBUTION.md).
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
Vendored
+1
@@ -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) }
|
||||
}
|
||||
}
|
||||
+134
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+335
@@ -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() }
|
||||
}
|
||||
}
|
||||
+108
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
+362
@@ -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()
|
||||
}
|
||||
}
|
||||
+180
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"url": "https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar",
|
||||
"sha256": "a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -0,0 +1 @@
|
||||
# Native/generated keep rules are added with the UniFFI packaging in #92.
|
||||
@@ -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-----
|
||||
+195
@@ -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() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
+705
@@ -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
|
||||
}
|
||||
}
|
||||
+187
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
+187
@@ -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()
|
||||
}
|
||||
}
|
||||
+301
@@ -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
|
||||
}
|
||||
+56
@@ -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
|
||||
)
|
||||
+212
@@ -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
|
||||
)
|
||||
}
|
||||
+274
@@ -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
Reference in new issue
Block a user