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:
+101
-47
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user