added android client

This commit is contained in:
nvrl committed 2026-08-05 13:07:56 +02:00
1 parent 8b95cf7a05
commit 6db6909824
73 files changed
+9234 -106

No files matched your search

-1
View File
@@ -19,7 +19,6 @@ thiserror.workspace = true
tracing.workspace = true
directories.workspace = true
rusqlite.workspace = true
keyring.workspace = true
sha2.workspace = true
# The credential store, per platform. Without the target's feature `keyring`
+295 -5
View File
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
use crate::naming::{self, ParsedName};
/// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 5;
pub const SCHEMA_VERSION: i64 = 7;
const MIGRATION_V1: &str = r#"
CREATE TABLE nodes (
@@ -128,6 +128,46 @@ const MIGRATION_V5: &str = r#"
ALTER TABLE title_metadata ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
"#;
/// Content kept explicitly by the viewer. Unlike the block cache, these rows
/// describe complete local files and therefore survive a disconnected launch.
const MIGRATION_V6: &str = r#"
CREATE TABLE offline_files (
share_id TEXT NOT NULL, link_id TEXT NOT NULL, revision_id TEXT NOT NULL,
block_sizes TEXT NOT NULL,
PRIMARY KEY (share_id, link_id)
);
"#;
/// Language choices are a property of a show, not of an episode's muxing.
const MIGRATION_V7: &str = r#"
CREATE TABLE title_track_prefs (
title_key TEXT PRIMARY KEY, audio_language TEXT, subtitle_language TEXT,
subtitles INTEGER NOT NULL DEFAULT 1
);
"#;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfflineFile {
pub revision_id: String,
pub block_sizes: Vec<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TitleTrackPrefs {
pub audio_language: Option<String>,
pub subtitle_language: Option<String>,
pub subtitles: bool,
}
impl Default for TitleTrackPrefs {
fn default() -> Self {
Self {
audio_language: None,
subtitle_language: None,
subtitles: true,
}
}
}
/// What `season = -1` means in `episode_metadata`: no season at all.
const ABSOLUTE_SEASON: i64 = -1;
@@ -235,6 +275,18 @@ impl Catalog {
tx.pragma_update(None, "user_version", 5)?;
tx.commit()?;
}
if version < 6 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V6)?;
tx.pragma_update(None, "user_version", 6)?;
tx.commit()?;
}
if version < 7 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V7)?;
tx.pragma_update(None, "user_version", 7)?;
tx.commit()?;
}
Ok(())
}
@@ -245,6 +297,25 @@ impl Catalog {
/// intact rather than a half-replaced one. Watch state is keyed separately
/// and deliberately untouched — a recrawl must not lose where you were.
pub fn replace_share(&mut self, share_id: &str, nodes: &[CatalogNode]) -> Result<()> {
self.replace_share_inner(share_id, nodes, false)
}
/// Replace nodes while retaining stale offline path keys for explicit
/// filesystem cleanup by a front end.
pub fn replace_share_retaining_offline(
&mut self,
share_id: &str,
nodes: &[CatalogNode],
) -> Result<()> {
self.replace_share_inner(share_id, nodes, true)
}
fn replace_share_inner(
&mut self,
share_id: &str,
nodes: &[CatalogNode],
retain_offline: bool,
) -> Result<()> {
let tx = self.conn.transaction()?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
@@ -276,6 +347,10 @@ impl Catalog {
}
}
if !retain_offline {
tx.execute("DELETE FROM offline_files WHERE share_id = ?1 AND NOT EXISTS (SELECT 1 FROM nodes WHERE nodes.share_id = offline_files.share_id AND nodes.link_id = offline_files.link_id AND nodes.active_revision_id = offline_files.revision_id)", params![share_id])?;
}
tx.commit()?;
Ok(())
}
@@ -314,16 +389,141 @@ impl Catalog {
/// state alone — this is the viewer saying they are done with the share.
/// Keeping positions for files they can no longer reach would only leave
/// the "continue watching" row pointing at nothing.
pub fn remove_share(&self, share_id: &str) -> Result<()> {
self.conn
.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
self.conn.execute(
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute(
"DELETE FROM watch_state WHERE share_id = ?1",
params![share_id],
)?;
tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1",
params![share_id],
)?;
tx.commit()?;
Ok(())
}
pub fn offline_file(&self, share_id: &str, link_id: &str) -> Result<Option<OfflineFile>> {
self.conn.query_row("SELECT revision_id, block_sizes FROM offline_files WHERE share_id=?1 AND link_id=?2", params![share_id, link_id], |row| {
let sizes: String = row.get(1)?;
Ok(OfflineFile { revision_id: row.get(0)?, block_sizes: serde_json::from_str(&sizes).map_err(|e| rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)))? })
}).optional().map_err(Into::into)
}
pub fn set_offline_file(
&self,
share_id: &str,
link_id: &str,
file: &OfflineFile,
) -> Result<()> {
let sizes = serde_json::to_string(&file.block_sizes)
.map_err(|e| crate::error::Error::Config(e.to_string()))?;
self.conn.execute("INSERT INTO offline_files (share_id,link_id,revision_id,block_sizes) VALUES (?1,?2,?3,?4) ON CONFLICT(share_id,link_id) DO UPDATE SET revision_id=excluded.revision_id,block_sizes=excluded.block_sizes", params![share_id,link_id,file.revision_id,sizes])?;
Ok(())
}
/// Record a completed download and force its WAL transaction to stable
/// storage before the caller discards its recovery journal.
pub fn set_offline_file_durable(
&self,
share_id: &str,
link_id: &str,
file: &OfflineFile,
) -> Result<()> {
self.set_offline_file(share_id, link_id, file)?;
self.conn
.query_row("PRAGMA wal_checkpoint(FULL)", [], |_| Ok(()))?;
Ok(())
}
/// Every completed local revision, keyed like watch state.
pub fn all_offline_files(&self) -> Result<HashMap<(String, String), OfflineFile>> {
let mut statement = self
.conn
.prepare("SELECT share_id, link_id, revision_id, block_sizes FROM offline_files")?;
let rows = statement.query_map([], |row| {
let sizes: String = row.get(3)?;
let block_sizes = serde_json::from_str(&sizes).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
3,
rusqlite::types::Type::Text,
Box::new(error),
)
})?;
Ok((
(row.get(0)?, row.get(1)?),
OfflineFile {
revision_id: row.get(2)?,
block_sizes,
},
))
})?;
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
}
/// Completed local revisions belonging to one share.
///
/// Callers that replace or remove a share need the paths before deleting
/// the index rows. Keeping this query explicit prevents an SQL cascade
/// from orphaning opaque media files on disk.
pub fn offline_files_for_share(&self, share_id: &str) -> Result<HashMap<String, OfflineFile>> {
let mut statement = self.conn.prepare(
"SELECT link_id, revision_id, block_sizes FROM offline_files WHERE share_id=?1",
)?;
let rows = statement.query_map(params![share_id], |row| {
let sizes: String = row.get(2)?;
let block_sizes = serde_json::from_str(&sizes).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
Box::new(error),
)
})?;
Ok((
row.get(0)?,
OfflineFile {
revision_id: row.get(1)?,
block_sizes,
},
))
})?;
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
}
pub fn remove_offline_file(&self, share_id: &str, link_id: &str) -> Result<()> {
self.conn.execute(
"DELETE FROM offline_files WHERE share_id=?1 AND link_id=?2",
params![share_id, link_id],
)?;
Ok(())
}
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
self.conn.query_row("SELECT audio_language, subtitle_language, subtitles FROM title_track_prefs WHERE title_key=?1", params![title_key], |r| Ok(TitleTrackPrefs { audio_language:r.get(0)?, subtitle_language:r.get(1)?, subtitles:r.get::<_,i64>(2)? != 0 })).optional().map_err(Into::into)
}
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
self.conn.execute("INSERT INTO title_track_prefs (title_key,audio_language,subtitle_language,subtitles) VALUES (?1,?2,?3,?4) ON CONFLICT(title_key) DO UPDATE SET audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,subtitles=excluded.subtitles", params![title_key,prefs.audio_language,prefs.subtitle_language,prefs.subtitles as i64])?;
Ok(())
}
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
let mut statement = self.conn.prepare(
"SELECT title_key, audio_language, subtitle_language, subtitles FROM title_track_prefs",
)?;
let rows = statement.query_map([], |row| {
Ok((
row.get(0)?,
TitleTrackPrefs {
audio_language: row.get(1)?,
subtitle_language: row.get(2)?,
subtitles: row.get::<_, i64>(3)? != 0,
},
))
})?;
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
}
/// Every watch position there is, keyed by `(share_id, link_id)`.
///
/// What [`crate::library::Library::build`] joins against. A grid that asked
@@ -1497,4 +1697,94 @@ mod tests {
None
);
}
#[test]
fn removing_an_offline_record_keeps_the_online_catalog_and_other_downloads() {
let catalog = Catalog::in_memory().expect("open");
let first = OfflineFile {
revision_id: "revision-1".to_owned(),
block_sizes: vec![3, 5],
};
let second = OfflineFile {
revision_id: "revision-2".to_owned(),
block_sizes: vec![7],
};
catalog
.set_offline_file("share", "first", &first)
.expect("record first");
catalog
.set_offline_file("share", "second", &second)
.expect("record second");
catalog
.remove_offline_file("share", "first")
.expect("remove first");
assert_eq!(
catalog.offline_file("share", "first").expect("read first"),
None
);
assert_eq!(
catalog
.offline_file("share", "second")
.expect("read second"),
Some(second)
);
}
#[test]
fn recrawl_retains_stale_offline_index_until_bytes_can_be_removed() {
let mut catalog = Catalog::in_memory().expect("open");
let mut old = file("share", "episode", "Show.S01E01.mkv");
old.active_revision_id = Some("old".to_owned());
catalog.replace_share("share", &[old]).expect("first crawl");
let offline = OfflineFile {
revision_id: "old".to_owned(),
block_sizes: vec![4, 7],
};
catalog
.set_offline_file("share", "episode", &offline)
.expect("record offline revision");
let mut new = file("share", "episode", "Show.S01E01.mkv");
new.active_revision_id = Some("new".to_owned());
catalog
.replace_share_retaining_offline("share", &[new])
.expect("recrawl");
assert_eq!(
catalog
.offline_file("share", "episode")
.expect("read retained path key"),
Some(offline),
"filesystem owner must delete old bytes before dropping this path key"
);
}
#[test]
fn ordinary_recrawl_prunes_stale_offline_index_for_legacy_callers() {
let mut catalog = Catalog::in_memory().expect("open");
let mut old = file("share", "episode", "Show.S01E01.mkv");
old.active_revision_id = Some("old".to_owned());
catalog.replace_share("share", &[old]).expect("first crawl");
catalog
.set_offline_file(
"share",
"episode",
&OfflineFile {
revision_id: "old".to_owned(),
block_sizes: vec![4],
},
)
.expect("offline row");
let mut new = file("share", "episode", "Show.S01E01.mkv");
new.active_revision_id = Some("new".to_owned());
catalog.replace_share("share", &[new]).expect("recrawl");
assert_eq!(
catalog
.offline_file("share", "episode")
.expect("read offline row"),
None
);
}
}
+46 -2
View File
@@ -30,6 +30,25 @@ pub struct AppDirs {
}
impl AppDirs {
/// Use directories supplied by a platform host.
///
/// Android owns its application directories and exposes them through
/// `Context`; resolving them through desktop conventions would put state in
/// a path that does not exist inside the application sandbox.
pub fn from_paths(
config: impl Into<PathBuf>,
data: impl Into<PathBuf>,
cache: impl Into<PathBuf>,
) -> Result<Self> {
let dirs = Self {
config: config.into(),
data: data.into(),
cache: cache.into(),
};
dirs.create()?;
Ok(dirs)
}
/// Resolve the platform directories and create them.
pub fn ensure() -> Result<Self> {
let dirs =
@@ -43,10 +62,15 @@ impl AppDirs {
cache: dirs.cache_dir().to_path_buf(),
};
for path in [&dirs.config, &dirs.data, &dirs.cache] {
dirs.create()?;
Ok(dirs)
}
fn create(&self) -> Result<()> {
for path in [&self.config, &self.data, &self.cache] {
fs::create_dir_all(path)?;
}
Ok(dirs)
Ok(())
}
/// The share list.
@@ -64,6 +88,26 @@ impl AppDirs {
self.cache.join("blocks")
}
/// Complete files explicitly kept for disconnected playback.
pub fn offline_content(&self) -> PathBuf {
self.data.join("offline")
}
/// Opaque path for one complete offline revision. No media name or share
/// secret reaches the filesystem.
pub fn offline_file(&self, share_id: &str, link_id: &str, revision: &str) -> PathBuf {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
hash.update(share_id.as_bytes());
hash.update([0]);
hash.update(link_id.as_bytes());
hash.update([0]);
hash.update(revision.as_bytes());
let digest = hash.finalize();
let name: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
self.offline_content().join(format!("{name}.media"))
}
/// Where decrypted poster thumbnails are kept.
///
/// Separate from the block cache because it is cheap, tiny and wanted on
+2
View File
@@ -15,6 +15,7 @@ pub enum Error {
///
/// Boxed because `keyring::Error` is large and this variant is rare; an
/// un-boxed one would inflate every `Result` in the crate.
#[cfg(not(target_os = "android"))]
#[error("credential store: {0}")]
Keyring(#[from] Box<keyring::Error>),
@@ -31,6 +32,7 @@ pub enum Error {
NotFound(String),
}
#[cfg(not(target_os = "android"))]
impl From<keyring::Error> for Error {
fn from(error: keyring::Error) -> Self {
Self::Keyring(Box::new(error))
+3 -1
View File
@@ -16,7 +16,9 @@ pub mod prefs;
pub mod shares;
pub use error::{Error, Result};
pub use shares::{Share, ShareStore, SharedLibrary};
#[cfg(not(target_os = "android"))]
pub use shares::KeyringSecretStore;
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary};
pub use proton_drive_rs;
pub use proton_sdk;
+99 -25
View File
@@ -16,6 +16,7 @@
//! regardless of how many links it was given.
use std::collections::BTreeMap;
use std::sync::Arc;
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
use proton_sdk::config::ProtonClientConfiguration;
@@ -26,8 +27,55 @@ use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result};
/// The credential-store service name. One entry per share, keyed by share id.
#[cfg(not(target_os = "android"))]
const KEYRING_SERVICE: &str = "proton-stream";
/// Secret persistence supplied by the platform.
///
/// Desktop builds use [`KeyringSecretStore`]. Android supplies an
/// implementation backed by a non-exportable Android Keystore key through the
/// language bridge, keeping platform APIs out of this portable crate.
pub trait SecretStore: Send + Sync {
fn set(&self, key: &str, value: &str) -> Result<()>;
fn get(&self, key: &str) -> Result<Option<String>>;
fn delete(&self, key: &str) -> Result<()>;
}
/// The desktop OS credential store.
#[cfg(not(target_os = "android"))]
#[derive(Debug, Default)]
pub struct KeyringSecretStore;
#[cfg(not(target_os = "android"))]
impl KeyringSecretStore {
fn entry(key: &str) -> Result<keyring::Entry> {
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
}
}
#[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
Self::entry(key)?.set_password(value)?;
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
match Self::entry(key)?.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(error.into()),
}
}
fn delete(&self, key: &str) -> Result<()> {
match Self::entry(key)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(error.into()),
}
}
}
/// What the app records about a share, minus its secrets.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Share {
@@ -59,11 +107,17 @@ struct ShareSecrets {
/// The configured shares, and their secrets.
pub struct ShareStore {
dirs: AppDirs,
secrets: Arc<dyn SecretStore>,
}
impl ShareStore {
#[cfg(not(target_os = "android"))]
pub fn new(dirs: AppDirs) -> Self {
Self { dirs }
Self::with_secret_store(dirs, Arc::new(KeyringSecretStore))
}
pub fn with_secret_store(dirs: AppDirs, secrets: Arc<dyn SecretStore>) -> Self {
Self { dirs, secrets }
}
/// Every configured share, in the order they were added.
@@ -121,10 +175,7 @@ impl ShareStore {
write_json(&self.dirs.shares_file(), &shares)?;
// A missing entry is the desired end state, not a failure.
match self.entry(id)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
self.secrets.delete(id)
}
/// Open a share as a visitor.
@@ -143,27 +194,18 @@ impl ShareStore {
Ok(client)
}
fn entry(&self, id: &str) -> Result<keyring::Entry> {
Ok(keyring::Entry::new(KEYRING_SERVICE, id)?)
}
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
let encoded = serde_json::to_string(secrets)
.map_err(|e| Error::Config(format!("serialize share secrets: {e}")))?;
self.entry(id)?.set_password(&encoded)?;
Ok(())
self.secrets.set(id, &encoded)
}
fn load_secrets(&self, id: &str) -> Result<ShareSecrets> {
let encoded = match self.entry(id)?.get_password() {
Ok(encoded) => encoded,
Err(keyring::Error::NoEntry) => {
return Err(Error::NotFound(format!(
"share {id} has no stored credentials; remove and re-add it"
)));
}
Err(e) => return Err(e.into()),
};
let encoded = self.secrets.get(id)?.ok_or_else(|| {
Error::NotFound(format!(
"share {id} has no stored credentials; remove and re-add it"
))
})?;
serde_json::from_str(&encoded)
.map_err(|e| Error::Config(format!("stored share secrets are unreadable: {e}")))
}
@@ -206,6 +248,17 @@ impl SharedLibrary {
self.clients.keys().map(String::as_str)
}
/// Replay a public-link handshake before performing a user-requested
/// refresh. Visitor sessions are short-lived, so a cached client is still
/// useful for streaming but must not be assumed valid for a later crawl.
pub async fn refresh_session(&self, share_id: &str) -> Result<()> {
let client = self
.client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
client.refresh_session().await?;
Ok(())
}
/// Walk one share's whole subtree, depth first, yielding every node.
///
/// There is no recursion helper on the visitor path — the authenticated
@@ -266,7 +319,7 @@ fn client_configuration() -> ProtonClientConfiguration {
fn token_from_url(url: &str) -> Result<String> {
let (_, tail) = url
.split_once("/urls/")
.ok_or_else(|| Error::Config(format!("{url} is not a Proton share URL")))?;
.ok_or_else(|| Error::Config("not a Proton share URL".to_owned()))?;
let token = tail
.split(['#', '?'])
@@ -275,12 +328,14 @@ fn token_from_url(url: &str) -> Result<String> {
.trim_end_matches('/');
if token.is_empty() {
return Err(Error::Config(format!("{url} carries no share token")));
return Err(Error::Config(
"Proton share URL carries no share token".to_owned(),
));
}
if !url.contains('#') {
return Err(Error::Config(format!(
"{url} has no #password fragment; copy the full share link"
)));
return Err(Error::Config(
"Proton share URL has no #password fragment; copy the full share link".to_owned(),
));
}
Ok(token.to_string())
}
@@ -322,6 +377,25 @@ mod tests {
assert!(token_from_url("https://drive.proton.me/urls/#s3cr3t").is_err());
}
#[test]
fn malformed_share_errors_never_repeat_the_secret_url_fragment() {
const SECRET: &str = "fragment-secret-that-must-not-leak";
for url in [
format!("https://example.com/not-a-share#{SECRET}"),
format!("https://drive.proton.me/urls/#{SECRET}"),
] {
let error = token_from_url(&url).unwrap_err().to_string();
assert!(
!error.contains(SECRET),
"error exposed URL fragment: {error}"
);
assert!(
!error.contains(&url),
"error repeated the full URL: {error}"
);
}
}
/// The id must be derivable from the token alone — deriving it from the URL
/// would mix a secret into the config file.
#[test]