diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 8d005deb0..1ae80b4c2 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:theme="@style/Theme.voltius" + android:allowBackup="false" + android:dataExtractionRules="@xml/data_extraction_rules" android:enableOnBackInvokedCallback="true" android:usesCleartextTraffic="${usesCleartextTraffic}"> = + dir.list().orEmpty().mapNotNull { QUARANTINED.find(it)?.groupValues?.get(1)?.toLongOrNull() } + /** Returns the stored value for [key], or null if absent. */ @JvmStatic fun get(ctx: Context, key: String): String? = prefs(ctx).getString(key, null) diff --git a/src-tauri/gen/android/app/src/main/res/xml/data_extraction_rules.xml b/src-tauri/gen/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..e83be5afb --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src-tauri/src/android_ctx.rs b/src-tauri/src/android_ctx.rs index 8b2b6e8b1..62287b8bd 100644 --- a/src-tauri/src/android_ctx.rs +++ b/src-tauri/src/android_ctx.rs @@ -11,7 +11,7 @@ use std::sync::OnceLock; -use jni::objects::{GlobalRef, JClass, JObject}; +use jni::objects::{GlobalRef, JClass, JObject, JString}; use jni::{JNIEnv, JavaVM}; struct AndroidCtx { @@ -59,7 +59,7 @@ pub extern "system" fn Java_com_voltius_app_VoltiusKeychain_nativeInit<'local>( /// Run `f` with an attached `JNIEnv` and the application `Context`. Returns `Err` (never /// panics) if `nativeInit` hasn't run or the attach fails. On a Java exception, describes it -/// to logcat and clears it so the VM stays usable. +/// to logcat, clears it so the VM stays usable, and names it in the error. pub fn with_env( op: &str, f: impl FnOnce(&mut JNIEnv, &JObject) -> Result, @@ -71,13 +71,51 @@ pub fn with_env( .vm .attach_current_thread() .map_err(|e| format!("{op}: attach: {e}"))?; - f(&mut env, ctx.context.as_obj()).map_err(|e| { - let _ = env.exception_describe(); - let _ = env.exception_clear(); - format!("{op}: {e}") + let result = f(&mut env, ctx.context.as_obj()); + result.map_err(|e| match take_java_exception(&mut env) { + Some(thrown) => format!("{op}: {thrown}"), + None => format!("{op}: {e}"), }) } +/// Clears the pending Java exception and renders it with its causes. +fn take_java_exception(env: &mut JNIEnv) -> Option { + if !env.exception_check().unwrap_or(false) { + return None; + } + let thrown = env.exception_occurred().ok(); + let _ = env.exception_describe(); + let _ = env.exception_clear(); + let mut current = JObject::from(thrown?); + let mut parts = Vec::new(); + for _ in 0..4 { + match throwable_text(env, ¤t) { + Some(text) => parts.push(text), + None => break, + } + let cause = env + .call_method(¤t, "getCause", "()Ljava/lang/Throwable;", &[]) + .and_then(|v| v.l()); + match cause { + Ok(next) if !next.is_null() && !env.is_same_object(&next, ¤t).unwrap_or(true) => { + current = next; + } + _ => break, + } + } + let _ = env.exception_clear(); + (!parts.is_empty()).then(|| parts.join(", caused by: ")) +} + +fn throwable_text(env: &mut JNIEnv, thrown: &JObject) -> Option { + let text = env + .call_method(thrown, "toString", "()Ljava/lang/String;", &[]) + .and_then(|v| v.l()) + .ok()?; + let text: String = env.get_string(&JString::from(text)).ok()?.into(); + Some(text.split_whitespace().collect::>().join(" ")) +} + /// Resolve an app class by dotted binary name (e.g. `com.voltius.app.VoltiusDownloads`) via /// the application class loader captured at `nativeInit`. Use this instead of /// `env.find_class`/`call_static_method(, ..)` from threads Rust attaches itself (async diff --git a/src/components/layout/SplashScreen.appLock.test.tsx b/src/components/layout/SplashScreen.appLock.test.tsx index d6c08082d..087fba808 100644 --- a/src/components/layout/SplashScreen.appLock.test.tsx +++ b/src/components/layout/SplashScreen.appLock.test.tsx @@ -8,9 +8,10 @@ const h = vi.hoisted(() => ({ available: true, exists: true, mode: null as string | null, + accountId: "acc-1" as string | null, launchLock: null as string | null, secret: "plain", - authProps: null as null | { isLocked: boolean; systemAuth?: boolean }, + authProps: null as null | { isLocked: boolean; systemAuth?: boolean; vaultUnreadable?: boolean }, })); vi.mock("react-i18next", () => ({ @@ -24,6 +25,7 @@ vi.mock("@/services/account", () => ({ setAppLock: vi.fn(async () => {}), isServerMode: vi.fn(async () => h.mode === "server"), getAccountMode: vi.fn(async () => h.mode), + getAccountId: vi.fn(async () => h.accountId), })); vi.mock("@/services/appLock", () => ({ systemAuthAvailable: vi.fn(async () => h.available), @@ -35,7 +37,7 @@ vi.mock("@/stores/securityStore", () => ({ })); vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: h.exists })) })); vi.mock("./AuthPage", () => ({ - default: (p: { isLocked: boolean; systemAuth?: boolean }) => { h.authProps = p; return
auth-page
; }, + default: (p: { isLocked: boolean; systemAuth?: boolean; vaultUnreadable?: boolean }) => { h.authProps = p; return
auth-page
; }, })); vi.mock("./LogoBadge", () => ({ default: () =>
})); @@ -47,6 +49,7 @@ beforeEach(() => { h.systemAuthUnlock = false; h.exists = true; h.mode = null; + h.accountId = "acc-1"; h.launchLock = null; h.secret = "plain"; h.authProps = null; @@ -154,3 +157,19 @@ test("a declined login on a cloud account with no vault file yet still gets the await advance(2000); expect(h.authProps).toMatchObject({ isLocked: true }); }); + +test("a vault file whose account id is gone from the keychain goes to the unreadable panel, not the password form", async () => { + h.autoLogin.mockResolvedValueOnce("declined"); + h.accountId = null; + render( {}} />); + await advance(2000); + expect(h.authProps).toMatchObject({ isLocked: true, vaultUnreadable: true }); +}); + +test("a vault lock over a vault whose account id is gone goes to the unreadable panel", async () => { + h.lock = "vault"; + h.accountId = null; + render( {}} />); + await advance(2000); + expect(h.authProps).toMatchObject({ isLocked: true, vaultUnreadable: true }); +}); diff --git a/src/components/layout/SplashScreen.keychainHint.test.tsx b/src/components/layout/SplashScreen.keychainHint.test.tsx index 12fa76625..6236b57eb 100644 --- a/src/components/layout/SplashScreen.keychainHint.test.tsx +++ b/src/components/layout/SplashScreen.keychainHint.test.tsx @@ -17,6 +17,7 @@ vi.mock("@/services/account", () => ({ setAppLock: vi.fn(async () => {}), isServerMode: vi.fn(async () => false), getAccountMode: vi.fn(async () => "local"), + getAccountId: vi.fn(async () => "acc-1"), })); vi.mock("@/services/appLock", () => ({ systemAuthAvailable: async () => false, lockOnLaunchIfIdle: async () => null })); vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: true })) })); diff --git a/src/components/layout/SplashScreen.tsx b/src/components/layout/SplashScreen.tsx index 4842cb858..94ac314b6 100644 --- a/src/components/layout/SplashScreen.tsx +++ b/src/components/layout/SplashScreen.tsx @@ -8,7 +8,7 @@ import { useFolderStore } from "@/stores/folderStore"; import { useSnippetStore } from "@/stores/snippetStore"; import { useSnippetFolderStore } from "@/stores/snippetFolderStore"; import { usePortForwardingStore } from "@/stores/portForwardingStore"; -import { autoLogin, getAccountMode, getAppLock, isServerMode, setAppLock } from "@/services/account"; +import { autoLogin, getAccountId, getAccountMode, getAppLock, isServerMode, setAppLock } from "@/services/account"; import { lockOnLaunchIfIdle, systemAuthAvailable } from "@/services/appLock"; import { secretState } from "@/services/vaultSecret"; import { useAppLockStore } from "@/stores/appLockStore"; @@ -44,9 +44,13 @@ function keepSwitcherFresh(): void { saveCurrentAccount().catch((e) => console.warn("[splash] could not save this account to the switcher:", e)); } +type UnlockPhase = "auth-locked" | "auth-unreadable" | "auth-first-launch"; + // An account can predate its vault file: a cloud one re-derives from the server, a local one never wrote a secret. -async function hasAccountToUnlock(): Promise { - return (await getVaultStatus()).exists || canLockVault(await getAccountMode()); +// A vault whose account id is gone from the keychain (restored without it) can't take a password. +async function unlockPhase(): Promise { + if ((await getVaultStatus()).exists) return (await getAccountId()) ? "auth-locked" : "auth-unreadable"; + return canLockVault(await getAccountMode()) ? "auth-locked" : "auth-first-launch"; } export default function SplashScreen({ onReady }: Props) { @@ -62,6 +66,12 @@ export default function SplashScreen({ onReady }: Props) { const setStep = (id: string, status: StepStatus, label?: string) => setSteps((prev) => prev.map((s) => s.id === id ? { ...s, status, ...(label ? { label } : {}) } : s)); + const showUnlockPhase = (next: UnlockPhase, lockedLabel: string) => { + if (next === "auth-unreadable") setStep("vault", "error", t("layout.splash.vaultUnreadable")); + else setStep("vault", "done", next === "auth-locked" ? lockedLabel : t("layout.splash.firstLaunch")); + setPhase(next); + }; + useEffect(() => { async function init() { setStep("init", "running"); @@ -75,9 +85,7 @@ export default function SplashScreen({ onReady }: Props) { const sealed = (await secretState().catch(() => "none")) === "sealed"; setSystemAuth(sealed || (useSecurityStore.getState().systemAuthUnlock && (await systemAuthAvailable()))); try { - const locked = await hasAccountToUnlock(); - setStep("vault", "done", locked ? t("layout.splash.vaultLocked") : t("layout.splash.firstLaunch")); - setPhase(locked ? "auth-locked" : "auth-first-launch"); + showUnlockPhase(await unlockPhase(), t("layout.splash.vaultLocked")); } catch { setStep("vault", "error", t("layout.splash.vaultCheckFailed")); setPhase("auth-first-launch"); @@ -110,9 +118,7 @@ export default function SplashScreen({ onReady }: Props) { // autoLogin failed — check if a vault already exists (locked) or first launch try { - const locked = await hasAccountToUnlock(); - setStep("vault", "done", locked ? t("layout.splash.vaultFound") : t("layout.splash.firstLaunch")); - setPhase(locked ? "auth-locked" : "auth-first-launch"); + showUnlockPhase(await unlockPhase(), t("layout.splash.vaultFound")); } catch { setStep("vault", "error", t("layout.splash.vaultCheckFailed")); setPhase("auth-first-launch"); diff --git a/src/services/account.ts b/src/services/account.ts index c97240dcb..1a949f651 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -587,6 +587,10 @@ export async function getAccountMode(): Promise { return keychainGet("mode"); } +export async function getAccountId(): Promise { + return keychainGet("account_id"); +} + export async function getCurrentUserEmail(): Promise { return keychainGet("email"); }