Background-audio handoff for video + repository/player refactor

Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
This commit is contained in:
2026-07-22 21:52:07 +02:00
parent 4e6ab017d4
commit 3fbf6afdbc
72 changed files with 6728 additions and 2338 deletions
+101 -47
View File
@@ -105,13 +105,21 @@ impl CredentialStore {
}
/// Save an access token for a user
pub fn save_token(&self, user_id: &str, token: &str) -> Result<CredentialResult, CredentialError> {
pub fn save_token(
&self,
user_id: &str,
token: &str,
) -> Result<CredentialResult, CredentialError> {
if self.using_keyring {
log::debug!("Saving token for user {} to keyring", user_id);
self.save_to_keyring(user_id, token)?;
Ok(CredentialResult::Keyring)
} else {
log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path);
log::debug!(
"Saving token for user {} to encrypted file at {:?}",
user_id,
self.credentials_path
);
self.save_to_file(user_id, token)?;
log::debug!("Successfully saved token to encrypted file");
Ok(CredentialResult::EncryptedFile)
@@ -124,7 +132,11 @@ impl CredentialStore {
log::debug!("Getting token for user {} from keyring", user_id);
self.get_from_keyring(user_id)
} else {
log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path);
log::debug!(
"Getting token for user {} from encrypted file at {:?}",
user_id,
self.credentials_path
);
let result = self.get_from_file(user_id);
if result.is_ok() {
log::debug!("Successfully retrieved token from encrypted file");
@@ -197,7 +209,7 @@ impl CredentialStore {
.arg("__nonexistent_test__")
.output()
{
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Err(_) => false, // Command not found or can't execute
}
}
@@ -232,8 +244,8 @@ impl CredentialStore {
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::{Command, Stdio};
use std::io::Write;
use std::process::{Command, Stdio};
let key = format!("access_token:{}", user_id);
let mut child = Command::new("secret-tool")
@@ -248,20 +260,27 @@ impl CredentialStore {
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e))
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(token.as_bytes())
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
stdin.write_all(token.as_bytes()).map_err(|e| {
CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e))
})?;
}
let status = child.wait()
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
let status = child.wait().map_err(|e| {
CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e))
})?;
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status)))
Err(CredentialError::Keyring(format!(
"secret-tool failed with status: {}",
status
)))
}
}
@@ -290,7 +309,11 @@ impl CredentialStore {
use std::process::Command;
let key = format!("access_token:{}", user_id);
log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key);
log::debug!(
"Looking up token with service={}, username={}",
SERVICE_NAME,
key
);
let output = Command::new("secret-tool")
.arg("lookup")
@@ -299,18 +322,29 @@ impl CredentialStore {
.arg("username")
.arg(&key)
.output()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
})?;
if output.status.success() {
log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len());
log::debug!(
"secret-tool lookup succeeded, token length: {}",
output.stdout.len()
);
let token = String::from_utf8(output.stdout)
.map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))?
.map_err(|e| {
CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e))
})?
.trim()
.to_string();
Ok(token)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr);
log::warn!(
"secret-tool lookup failed with status: {} stderr: {}",
output.status,
stderr
);
Err(CredentialError::NotFound)
}
}
@@ -348,13 +382,18 @@ impl CredentialStore {
.arg("username")
.arg(&key)
.status()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
})?;
// secret-tool clear returns success even if entry doesn't exist
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status)))
Err(CredentialError::Keyring(format!(
"secret-tool clear failed with status: {}",
status
)))
}
}
@@ -398,10 +437,7 @@ impl CredentialStore {
#[cfg(target_os = "android")]
{
// Try to read Android build properties from /system/build.prop
let build_prop_paths = [
"/system/build.prop",
"/vendor/build.prop",
];
let build_prop_paths = ["/system/build.prop", "/vendor/build.prop"];
for path in &build_prop_paths {
if let Ok(content) = fs::read_to_string(path) {
@@ -410,7 +446,8 @@ impl CredentialStore {
if line.starts_with("ro.build.fingerprint=")
|| line.starts_with("ro.serialno=")
|| line.starts_with("ro.build.id=")
|| line.starts_with("ro.product.model=") {
|| line.starts_with("ro.product.model=")
{
hasher.update(line.as_bytes());
}
}
@@ -439,8 +476,8 @@ impl CredentialStore {
return Ok(serde_json::json!({}));
}
let encrypted_data =
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
let encrypted_data = fs::read_to_string(&self.credentials_path)
.map_err(|e| CredentialError::Io(e.to_string()))?;
if encrypted_data.is_empty() {
return Ok(serde_json::json!({}));
@@ -456,19 +493,21 @@ impl CredentialStore {
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
}
let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let json =
serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let encrypted = self.encrypt(&json)?;
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
}
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Generate a random nonce
let mut nonce_bytes = [0u8; 12];
getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?;
getrandom::getrandom(&mut nonce_bytes)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
@@ -488,14 +527,16 @@ impl CredentialStore {
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
if combined.len() < 12 {
return Err(CredentialError::Encryption("Invalid encrypted data".to_string()));
return Err(CredentialError::Encryption(
"Invalid encrypted data".to_string(),
));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
@@ -686,32 +727,39 @@ mod android_keystore {
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let instance =
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let token_jstring = env
.new_string(token)
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
let token_jstring = env.new_string(token).map_err(|e| {
CredentialError::Keyring(format!("Failed to create token string: {}", e))
})?;
let result = env
.call_method(
instance,
"saveToken",
"(Ljava/lang/String;Ljava/lang/String;)Z",
&[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())],
&[
JValue::Object(&key_jstring.into()),
JValue::Object(&token_jstring.into()),
],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
})?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("saveToken returned false".to_string()))
Err(CredentialError::Keyring(
"saveToken returned false".to_string(),
))
}
}
@@ -725,8 +773,8 @@ mod android_keystore {
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let instance =
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
@@ -767,8 +815,8 @@ mod android_keystore {
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let instance =
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
@@ -784,19 +832,25 @@ mod android_keystore {
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
})?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("deleteToken returned false".to_string()))
Err(CredentialError::Keyring(
"deleteToken returned false".to_string(),
))
}
}
}
// Export Android keystore functions at the module level for easier access
#[cfg(target_os = "android")]
pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available};
pub use android_keystore::{
initialize_secure_storage, test_keystore_available as android_test_keystore_available,
};
#[cfg(test)]
mod tests {