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/
|
/distribution/
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
/android/.gradle/
|
||||||
|
/android/.kotlin/
|
||||||
|
/android/**/build/
|
||||||
|
/android/local.properties
|
||||||
|
/android/.idea/
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
/.gitea/scripts/__pycache__/
|
||||||
Generated
+68
-22
@@ -1439,9 +1439,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.10.1"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cipher 0.5.2",
|
"cipher 0.5.2",
|
||||||
@@ -2271,6 +2271,15 @@ dependencies = [
|
|||||||
"generic-array 0.14.7",
|
"generic-array 0.14.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt"
|
||||||
|
version = "0.3.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
|
||||||
|
dependencies = [
|
||||||
|
"defmt 1.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "defmt"
|
name = "defmt"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -2883,10 +2892,12 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "faster-hex"
|
name = "faster-hex"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
|
checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"defmt 0.3.100",
|
||||||
"heapless",
|
"heapless",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -3844,7 +3855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
|
checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bstr",
|
"bstr",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.15.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5390,6 +5401,7 @@ checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
|||||||
name = "ironstorage"
|
name = "ironstorage"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm 0.11.1",
|
||||||
"apple-native-keyring-store",
|
"apple-native-keyring-store",
|
||||||
"arboard",
|
"arboard",
|
||||||
"cap-std",
|
"cap-std",
|
||||||
@@ -5415,6 +5427,7 @@ dependencies = [
|
|||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
"rqrr",
|
"rqrr",
|
||||||
"russh",
|
"russh",
|
||||||
|
"rustix 1.1.4",
|
||||||
"rustls",
|
"rustls",
|
||||||
"secret-service",
|
"secret-service",
|
||||||
"security-framework 3.7.0",
|
"security-framework 3.7.0",
|
||||||
@@ -5433,12 +5446,38 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironstorage-android"
|
||||||
|
version = "1.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"ironstorage-mobile",
|
||||||
|
"jni",
|
||||||
|
"rustls-platform-verifier",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironstorage-android-build"
|
||||||
|
version = "1.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"camino",
|
||||||
|
"ironstorage",
|
||||||
|
"object",
|
||||||
|
"regex",
|
||||||
|
"roxmltree",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"toml 0.9.12+spec-1.1.0",
|
||||||
|
"uniffi",
|
||||||
|
"uniffi_bindgen",
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironstorage-apple"
|
name = "ironstorage-apple"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ironstorage",
|
"ironstorage-mobile",
|
||||||
"uniffi",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5488,6 +5527,14 @@ dependencies = [
|
|||||||
"simple-file-manifest",
|
"simple-file-manifest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironstorage-mobile"
|
||||||
|
version = "1.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"ironstorage",
|
||||||
|
"uniffi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironstorage-tui"
|
name = "ironstorage-tui"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -5552,7 +5599,7 @@ version = "0.2.35"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defmt",
|
"defmt 1.1.1",
|
||||||
"jiff-core",
|
"jiff-core",
|
||||||
"jiff-static",
|
"jiff-static",
|
||||||
"jiff-tzdb-platform",
|
"jiff-tzdb-platform",
|
||||||
@@ -5569,7 +5616,7 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defmt",
|
"defmt 1.1.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5969,15 +6016,12 @@ name = "lru"
|
|||||||
version = "0.16.4"
|
version = "0.16.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||||
dependencies = [
|
|
||||||
"hashbrown 0.16.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6"
|
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
@@ -8111,7 +8155,7 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"kasuari",
|
"kasuari",
|
||||||
"lru 0.18.1",
|
"lru 0.18.2",
|
||||||
"palette",
|
"palette",
|
||||||
"serde",
|
"serde",
|
||||||
"strum",
|
"strum",
|
||||||
@@ -8487,12 +8531,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rqrr"
|
name = "rqrr"
|
||||||
version = "0.10.1"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ffbe87d9e8db95652c25ded2418150e00b08c2fde09e23ec15896d2c470c6631"
|
checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"g2p",
|
"g2p",
|
||||||
"lru 0.16.4",
|
"lru 0.18.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8719,9 +8763,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -8805,9 +8849,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.13"
|
version = "0.103.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -10542,7 +10586,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8"
|
checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"camino",
|
||||||
"cargo_metadata",
|
"cargo_metadata",
|
||||||
|
"clap",
|
||||||
"uniffi_bindgen",
|
"uniffi_bindgen",
|
||||||
"uniffi_core",
|
"uniffi_core",
|
||||||
"uniffi_macros",
|
"uniffi_macros",
|
||||||
|
|||||||
+6
-2
@@ -4,8 +4,11 @@ members = [
|
|||||||
"apps/desktop",
|
"apps/desktop",
|
||||||
"apps/tui",
|
"apps/tui",
|
||||||
"crates/apple",
|
"crates/apple",
|
||||||
|
"crates/android",
|
||||||
|
"crates/mobile-bridge",
|
||||||
"crates/storage",
|
"crates/storage",
|
||||||
"crates/watch-apple",
|
"crates/watch-apple",
|
||||||
|
"tools/android-build",
|
||||||
"tools/apple-release",
|
"tools/apple-release",
|
||||||
"tools/macos-packager",
|
"tools/macos-packager",
|
||||||
]
|
]
|
||||||
@@ -27,6 +30,7 @@ arboard = { version = "3.6", default-features = false, features = ["wayland-data
|
|||||||
ashpd = { version = "0.13", default-features = false, features = ["file_chooser", "tokio"] }
|
ashpd = { version = "0.13", default-features = false, features = ["file_chooser", "tokio"] }
|
||||||
cap-std = "4.0"
|
cap-std = "4.0"
|
||||||
cap-tempfile = "4.0"
|
cap-tempfile = "4.0"
|
||||||
|
rustix = { version = "1.1", features = ["fs"] }
|
||||||
clap = { version = "4.6", features = ["derive"] }
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
clap_complete = "4.6"
|
clap_complete = "4.6"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
@@ -50,9 +54,9 @@ qrcode = { version = "0.14", default-features = false }
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
regex = "1.13"
|
regex = "1.13"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
|
||||||
rustls = { version = "0.23", default-features = false }
|
rustls = { version = "0.23.46", default-features = false }
|
||||||
rfd = { version = "0.17", default-features = false }
|
rfd = { version = "0.17", default-features = false }
|
||||||
rqrr = { version = "0.10", default-features = false }
|
rqrr = { version = "0.11", default-features = false }
|
||||||
rpassword = "7.5"
|
rpassword = "7.5"
|
||||||
russh = { version = "=0.63.1", default-features = false, features = ["ring", "rsa"] }
|
russh = { version = "=0.63.1", default-features = false, features = ["ring", "rsa"] }
|
||||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
|
||||||
|
|||||||
+29
-1
@@ -31,7 +31,7 @@ The current direct dependencies are:
|
|||||||
| [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 |
|
| [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 |
|
||||||
| [tempfile 3](https://crates.io/crates/tempfile) | Permission-restricted CLI editor session directories and cleanup | MIT OR Apache-2.0 |
|
| [tempfile 3](https://crates.io/crates/tempfile) | Permission-restricted CLI editor session directories and cleanup | MIT OR Apache-2.0 |
|
||||||
| [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 |
|
| [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 |
|
||||||
| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 |
|
| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift and Kotlin bridge | MPL-2.0 |
|
||||||
| [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 |
|
| [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 |
|
||||||
|
|
||||||
The activated transitive graph has no dependency that forces a GPL or LGPL
|
The activated transitive graph has no dependency that forces a GPL or LGPL
|
||||||
@@ -100,3 +100,31 @@ The checked-in compatibility suite completes the earlier OpenPGP backend gate:
|
|||||||
`pgp` imports protected armored and binary exports, decrypts every GnuPG-audited
|
`pgp` imports protected armored and binary exports, decrypts every GnuPG-audited
|
||||||
fixture, emits independently decryptable single- and multi-recipient messages,
|
fixture, emits independently decryptable single- and multi-recipient messages,
|
||||||
and verifies the detached recipient signatures.
|
and verifies the detached recipient signatures.
|
||||||
|
|
||||||
|
## Android dependency policy
|
||||||
|
|
||||||
|
The approved Android library/API purposes, license review, maintenance checks,
|
||||||
|
version pinning and update gates are recorded in
|
||||||
|
[`docs/android/architecture.md`](docs/android/architecture.md#approved-dependencies-and-review-policy).
|
||||||
|
The Gradle catalog and resolved dependency/license records must identify actual
|
||||||
|
shipped versions before release; this approval matrix does not replace that audit.
|
||||||
|
|
||||||
|
The #89 scaffold records exact resolved build/test artifacts in
|
||||||
|
`android/gradle/verification-metadata.xml` and module Gradle lockfiles. The
|
||||||
|
version catalog pins stable AndroidX, Kotlin, Hilt/KSP and formatting tools.
|
||||||
|
Espresso 3.7.0 is pinned explicitly because Compose's older transitive Espresso
|
||||||
|
cannot initialize input injection on API 37. AndroidX Test is Apache-2.0;
|
||||||
|
JUnit 4.13.2 is EPL-1.0 and test-only. ktlint and its Gradle plugin are MIT,
|
||||||
|
build-only tools.
|
||||||
|
|
||||||
|
#92 packages [JNA 5.19.1](https://repo.maven.apache.org/maven2/net/java/dev/jna/jna/5.19.1/jna-5.19.1.pom)
|
||||||
|
as the Android AAR under its Apache-2.0 option (dual-licensed with LGPL-2.1-or-later).
|
||||||
|
It is the OS FFI runtime required by generated UniFFI Kotlin, not a competing
|
||||||
|
storage implementation. Its Android native libraries are subject to the same
|
||||||
|
three-ABI, ELF and 16 KB APK alignment checks as Rust. Exact resolved AAR/POM
|
||||||
|
checksums and locks are committed. cargo-ndk 4.1.2 (MIT OR Apache-2.0) is build-only;
|
||||||
|
NDK 30.0.16248370 and Rust 1.97.1 are pinned and checked. The build-only ELF/ZIP
|
||||||
|
auditor reuses locked object 0.36.7 (Apache-2.0 OR MIT) and zip 2.4.2 (MIT)
|
||||||
|
versions already present in the workspace. UniFFI's MPL-2.0 source remains available
|
||||||
|
at its recorded upstream crate version. Release attribution assembly is owned by
|
||||||
|
#112; the dependency policy and runtime matrix are in android/README.md.
|
||||||
@@ -75,6 +75,22 @@ Rollback-safe remove, move, and copy transactions, destination rules, and
|
|||||||
selective subtree reencryption are documented in
|
selective subtree reencryption are documented in
|
||||||
[`docs/tree-mutations.md`](docs/tree-mutations.md).
|
[`docs/tree-mutations.md`](docs/tree-mutations.md).
|
||||||
|
|
||||||
|
The native Android architecture, support policy and complete mobile parity/issue
|
||||||
|
map are recorded in [`docs/android/architecture.md`](docs/android/architecture.md)
|
||||||
|
and [`docs/android/parity.md`](docs/android/parity.md). Android implementation
|
||||||
|
belongs to milestone 06; the decision record does not claim a shipped client.
|
||||||
|
The structured Android viewer and its validation are documented in
|
||||||
|
[`docs/android/entry-viewer.md`](docs/android/entry-viewer.md).
|
||||||
|
Lifecycle, capture, input, and clipboard protections and platform limits are
|
||||||
|
documented in [`docs/android/presentation-security.md`](docs/android/presentation-security.md).
|
||||||
|
|
||||||
|
Name-only Android search and its state/security policy are documented in
|
||||||
|
[`docs/android/name-search.md`](docs/android/name-search.md).
|
||||||
|
Adaptive navigation, appearance, safe restoration and shell validation are documented in
|
||||||
|
[`docs/android/adaptive-shell.md`](docs/android/adaptive-shell.md).
|
||||||
|
Native entry creation, editing and encrypted draft recovery are documented in
|
||||||
|
[`docs/android/entry-editor.md`](docs/android/entry-editor.md).
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -84,6 +100,8 @@ apps/cli pass-compatible command-line frontend
|
|||||||
apps/tui Ratatui frontend
|
apps/tui Ratatui frontend
|
||||||
apps/desktop Iced desktop frontend for macOS, Windows, and Linux
|
apps/desktop Iced desktop frontend for macOS, Windows, and Linux
|
||||||
apple/ iPhone, AutoFill, and watchOS presentation targets
|
apple/ iPhone, AutoFill, and watchOS presentation targets
|
||||||
|
android/ native Android Compose project
|
||||||
|
tools/android-build Rust-owned Android fixtures and architecture checks
|
||||||
```
|
```
|
||||||
|
|
||||||
The Apple project is generated with XcodeGen:
|
The Apple project is generated with XcodeGen:
|
||||||
@@ -93,6 +111,9 @@ cd apple
|
|||||||
xcodegen generate
|
xcodegen generate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Android toolchain setup, module boundaries, build gates and simulator updates are
|
||||||
|
documented in [`android/README.md`](android/README.md).
|
||||||
|
|
||||||
Apple App Store metadata, archive verification, submission, and paired iPhone
|
Apple App Store metadata, archive verification, submission, and paired iPhone
|
||||||
and Watch release checks are documented in
|
and Watch release checks are documented in
|
||||||
[`apple/AppStore/DISTRIBUTION.md`](apple/AppStore/DISTRIBUTION.md).
|
[`apple/AppStore/DISTRIBUTION.md`](apple/AppStore/DISTRIBUTION.md).
|
||||||
|
|||||||
@@ -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