Add thumbnail size classes and grid zoom; fix the scrub ordinal
The grid now zooms, which needs thumbnails at two resolutions rather than one, and exposed a scrub that landed in the wrong place. **Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and `Large` (1024px, ~45 KB), with the class part of the store key so both coexist. Storing everything large would take the reference library from ~200 MB to ~860 MB, and shards sync, so that is transfer cost on every device rather than only disk. A store written before the class existed migrates in place: its entries are all grid-sized, which is what the column defaults to, so nothing already fetched is discarded. `forget` now drops every size for an image. Reading a single row left the other class's bytes on the shard's tally for good, sealing it early on space nothing occupied. **Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in geometric steps, so the gesture feels the same at either end where a fixed pixel step would be imperceptible at 400px and violent at 90px. Crossing 256px switches to the large class, so a zoomed cell is sharp rather than upscaled. Columns and window capacity already derived from cell size, so the grid reflows for free. **The scrub landed about half a library too high.** It counted only dated images while the grid shows all of them — 10,733 dated against 19,841 rows — and ignored `shadowed_by`. Verified against the live catalog: the old formula gave 10,887, the new one 10,732, the true grid position 10,732. The scrub's count and the grid's window must use identical predicates and ordering; a test now fails if they diverge. **Timeline gestures are continuous.** Scrub and pan were quantised to whole buckets, so a slow drag did nothing until it crossed a boundary and then jumped a month. Both work in fractions of the visible span now, and pinch-to-zoom arrives for tablet, where there is no wheel to reach the axis with. The pinch accumulator was wrong on first writing: it took at most one step per update, so an 8x spread — three doublings — yielded one zoom level. `log2().trunc()` now extracts every whole doubling and carries the remainder. The original test asserted the wrong number and defended it in a comment, which is worth remembering: a test can entrench a bug as readily as catch one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,5 +13,9 @@ log.workspace = true
|
||||
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies]
|
||||
keyring.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android-native-keyring-store.workspace = true
|
||||
keyring-core.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
|
||||
+111
-12
@@ -166,38 +166,137 @@ fn map_err(e: keyring::Error) -> SecretError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder for platforms without an implementation yet.
|
||||
/// Keystore-backed implementation (FR-PLAT-AND-1).
|
||||
///
|
||||
/// Android needs Keystore-backed storage via JNI (FR-PLAT-AND-1). Failing
|
||||
/// loudly is deliberate: a silent no-op store would look like it worked and
|
||||
/// then lose the credential.
|
||||
#[cfg(not(all(unix, not(target_os = "android"))))]
|
||||
pub struct PlatformSecretStore;
|
||||
/// `android-native-keyring-store` encrypts each secret with an AES-GCM key
|
||||
/// held in `AndroidKeyStore` and files the ciphertext in SharedPreferences.
|
||||
/// The key never leaves the Keystore, so the preferences file is useless on
|
||||
/// its own. This is the current approach rather than the deprecated
|
||||
/// `EncryptedSharedPreferences` (REQ §11).
|
||||
///
|
||||
/// It finds the JavaVM and Context through `ndk-context`, which
|
||||
/// `android-activity` initialises before `android_main` is called. Nothing
|
||||
/// here is usable before that point — hence the lazy handle below.
|
||||
#[cfg(target_os = "android")]
|
||||
pub struct PlatformSecretStore {
|
||||
/// Built on first use, not in `new()`: construction needs the ndk-context
|
||||
/// to be live, and `new()` may run early. Cached because store names are
|
||||
/// unique — building one per call would fail on the second call.
|
||||
store: std::sync::OnceLock<Result<std::sync::Arc<android_native_keyring_store::Store>, String>>,
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "android"))))]
|
||||
#[cfg(target_os = "android")]
|
||||
impl PlatformSecretStore {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
store: std::sync::OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn store(&self) -> Result<&std::sync::Arc<android_native_keyring_store::Store>, SecretError> {
|
||||
self.store
|
||||
.get_or_init(|| {
|
||||
android_native_keyring_store::Store::new().map_err(|e| e.to_string())
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(|e| SecretError::Unavailable(e.clone()))
|
||||
}
|
||||
|
||||
/// A credential specifier for one secret. Filed under the same
|
||||
/// service/key pair as the Linux path, so the two platforms agree on
|
||||
/// naming even though the backing stores differ.
|
||||
fn entry(
|
||||
&self,
|
||||
r: &SecretRef,
|
||||
) -> Result<keyring_core::Entry, SecretError> {
|
||||
use keyring_core::api::CredentialStoreApi;
|
||||
|
||||
self.store()?
|
||||
.build(SERVICE, &r.entry_key(), None)
|
||||
.map_err(map_err)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "android"))))]
|
||||
#[cfg(target_os = "android")]
|
||||
impl Default for PlatformSecretStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "android"))))]
|
||||
#[cfg(target_os = "android")]
|
||||
impl SecretStore for PlatformSecretStore {
|
||||
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> {
|
||||
use keyring_core::api::CredentialApi;
|
||||
|
||||
self.entry(secret_ref)?.set_password(secret).map_err(map_err)
|
||||
}
|
||||
|
||||
fn retrieve(&self, secret_ref: &SecretRef) -> Result<String, SecretError> {
|
||||
use keyring_core::api::CredentialApi;
|
||||
|
||||
self.entry(secret_ref)?.get_password().map_err(map_err)
|
||||
}
|
||||
|
||||
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> {
|
||||
use keyring_core::api::CredentialApi;
|
||||
|
||||
match self.entry(secret_ref)?.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
// Logout must be idempotent, as on Linux.
|
||||
Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(map_err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
// Unlike Linux there is no daemon to be absent: if the store builds,
|
||||
// Keystore is there. Building is the whole probe.
|
||||
self.store().is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn map_err(e: keyring_core::Error) -> SecretError {
|
||||
match e {
|
||||
keyring_core::Error::NoEntry => SecretError::NotFound,
|
||||
keyring_core::Error::NoStorageAccess(e) => SecretError::Unavailable(e.to_string()),
|
||||
keyring_core::Error::PlatformFailure(e) => SecretError::Unavailable(e.to_string()),
|
||||
other => SecretError::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder for platforms without an implementation yet.
|
||||
///
|
||||
/// Failing loudly is deliberate: a silent no-op store would look like it
|
||||
/// worked and then lose the credential.
|
||||
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
|
||||
pub struct PlatformSecretStore;
|
||||
|
||||
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
|
||||
impl PlatformSecretStore {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
|
||||
impl Default for PlatformSecretStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
|
||||
impl SecretStore for PlatformSecretStore {
|
||||
fn store(&self, _r: &SecretRef, _s: &str) -> Result<(), SecretError> {
|
||||
Err(SecretError::Unavailable(
|
||||
"Keystore-backed storage is not implemented on this platform yet".into(),
|
||||
"no secret store is implemented for this platform".into(),
|
||||
))
|
||||
}
|
||||
fn retrieve(&self, _r: &SecretRef) -> Result<String, SecretError> {
|
||||
Err(SecretError::Unavailable(
|
||||
"Keystore-backed storage is not implemented on this platform yet".into(),
|
||||
"no secret store is implemented for this platform".into(),
|
||||
))
|
||||
}
|
||||
fn delete(&self, _r: &SecretRef) -> Result<(), SecretError> {
|
||||
|
||||
Reference in New Issue
Block a user