diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 60bd071d6d..b73901f9f7 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -36,7 +36,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:theme="@style/Theme.sable" - android:usesCleartextTraffic="${usesCleartextTraffic}"> + android:usesCleartextTraffic="${usesCleartextTraffic}" + android:networkSecurityConfig="@xml/network_security_config"> + + + 127.0.0.1 + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94593c8095..bdc834c96a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -446,6 +446,8 @@ pub fn run() { network::media_protocol::set_media_session, network::media_protocol::clear_media_session, network::media_protocol::set_media_encryption, + #[cfg(target_os = "android")] + network::media_protocol::prepare_loopback_video, sentry::set_native_sentry_enabled, share_inbox::share_inbox_drain, share_inbox::share_inbox_read, diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 3026bb018e..7b7375872a 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -13,11 +13,15 @@ use tauri::{ AppHandle, Manager, Runtime, State, UriSchemeContext, UriSchemeResponder, }; +#[cfg(target_os = "android")] +mod android_loopback; mod crypto; mod lane; mod response; mod session; +#[cfg(target_os = "android")] +use android_loopback::LoopbackMediaServer; use crypto::EncryptionStore; use lane::{LanePermit, LifoLane}; use response::{ @@ -63,6 +67,8 @@ pub struct MediaSessionState { download_lane: LifoLane, cache_miss_gates: Mutex>>>>, negative_cache: Mutex>, + #[cfg(target_os = "android")] + loopback: Option, } impl Default for MediaSessionState { @@ -75,6 +81,8 @@ impl Default for MediaSessionState { download_lane: LifoLane::new(MAX_CONCURRENT_DOWNLOAD_REQUESTS), cache_miss_gates: Mutex::new(HashMap::new()), negative_cache: Mutex::new(HashMap::new()), + #[cfg(target_os = "android")] + loopback: LoopbackMediaServer::start().ok(), } } } @@ -105,12 +113,26 @@ impl MediaSessionState { } fn set_session(&self, session: MediaSession) -> Result<(), String> { - self.session_store - .set(session, || self.forget_client_errors()) + self.session_store.set(session, || { + self.forget_client_errors(); + #[cfg(target_os = "android")] + self.clear_loopback_media(); + }) } fn clear_session(&self) -> Result<(), String> { - self.session_store.clear(|| self.forget_client_errors()) + self.session_store.clear(|| { + self.forget_client_errors(); + #[cfg(target_os = "android")] + self.clear_loopback_media(); + }) + } + + #[cfg(target_os = "android")] + fn clear_loopback_media(&self) { + if let Some(loopback) = &self.loopback { + loopback.clear(); + } } // Shared across requests so the connection pool and TLS sessions stay warm. @@ -240,6 +262,24 @@ pub fn set_media_encryption( .register(&url, &key, &iv, &sha256, &version, mime_type) } +#[cfg(target_os = "android")] +#[tauri::command] +pub async fn prepare_loopback_video( + app: AppHandle, + url: String, +) -> Result { + let uri = url.parse::().map_err(|err| err.to_string())?; + let response = handle_request(&app, uri, None, true) + .await + .map_err(|status| status.to_string())?; + response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + .ok_or_else(|| "loopback media server unavailable".to_owned()) +} + fn media_session_marker(uri: &Uri) -> Result, StatusCode> { let Some(query) = uri.query() else { return Ok(None); @@ -295,7 +335,7 @@ pub fn respond( let range = header_value(header::RANGE); let origin = header_value(header::ORIGIN); tauri::async_runtime::spawn(async move { - let mut response = handle_request(&app, uri, range) + let mut response = handle_request(&app, uri, range, false) .await .unwrap_or_else(error_response); apply_cors_headers(&mut response, origin.as_deref()); @@ -307,7 +347,11 @@ async fn handle_request( app: &AppHandle, uri: Uri, range: Option, + loopback: bool, ) -> Result>, StatusCode> { + #[cfg(not(target_os = "android"))] + let _ = loopback; + let target = percent_encoding::percent_decode_str(uri.path().trim_start_matches('/')) .decode_utf8() .map_err(|_| StatusCode::BAD_REQUEST)? @@ -348,6 +392,13 @@ async fn handle_request( let (content_type, in_memory_body, disk_path) = ensure_cached(&state, &session, &key, media_url, dir, temp_dir).await?; + #[cfg(target_os = "android")] + if loopback && in_memory_body.is_none() && content_type.starts_with("video/") { + if let Some(loopback) = &state.loopback { + return Ok(loopback.redirect_response(&session, &key, disk_path, &content_type)); + } + } + match (range, in_memory_body) { (Some(range_header), Some(body)) => { Ok(serve_range_memory(&body, &content_type, &range_header)) diff --git a/src-tauri/src/network/media_protocol/android_loopback.rs b/src-tauri/src/network/media_protocol/android_loopback.rs new file mode 100644 index 0000000000..c769857007 --- /dev/null +++ b/src-tauri/src/network/media_protocol/android_loopback.rs @@ -0,0 +1,239 @@ +use std::{ + collections::HashMap, + fs::File, + io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}, + net::{TcpListener, TcpStream}, + path::PathBuf, + sync::{Arc, RwLock}, + thread, +}; + +use sha2::{Digest, Sha256}; +use tauri::http::{header, Response, StatusCode}; + +use super::session::MediaSession; + +pub(super) struct LoopbackMediaServer { + origin: String, + routes: Arc>>, +} + +#[derive(Clone)] +struct CachedMedia { + path: PathBuf, + content_type: String, +} + +impl LoopbackMediaServer { + pub(super) fn start() -> std::io::Result { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let origin = format!("http://127.0.0.1:{}", listener.local_addr()?.port()); + let routes = Arc::new(RwLock::new(HashMap::::new())); + let server_routes = Arc::clone(&routes); + thread::Builder::new() + .name("sable-media-loopback".into()) + .spawn(move || { + for stream in listener.incoming().flatten() { + let routes = Arc::clone(&server_routes); + let _ = thread::Builder::new() + .name("sable-media-request".into()) + .spawn(move || serve(stream, routes)); + } + })?; + + Ok(Self { origin, routes }) + } + + pub(super) fn clear(&self) { + if let Ok(mut routes) = self.routes.write() { + routes.clear(); + } + } + + pub(super) fn redirect_response( + &self, + session: &MediaSession, + cache_key: &str, + path: PathBuf, + content_type: &str, + ) -> Response> { + let capability = capability(session, cache_key); + if let Ok(mut routes) = self.routes.write() { + routes.insert( + capability.clone(), + CachedMedia { + path, + content_type: content_type.to_owned(), + }, + ); + } + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, format!("{}/{}", self.origin, capability)) + .header(header::CACHE_CONTROL, "no-store") + .body(Vec::new()) + .unwrap_or_else(|_| Response::new(Vec::new())) + } +} + +fn capability(session: &MediaSession, cache_key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(session.token.as_bytes()); + hasher.update([0]); + hasher.update(session.scope.as_bytes()); + hasher.update([0]); + hasher.update(cache_key.as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn serve(mut stream: TcpStream, routes: Arc>>) { + let Ok((method, capability, range)) = parse_request(&stream) else { + let _ = write_status(&mut stream, 400, "Bad Request", &[]); + return; + }; + if method != "GET" && method != "HEAD" { + let _ = write_status(&mut stream, 405, "Method Not Allowed", &[]); + return; + } + let media = routes + .read() + .ok() + .and_then(|routes| routes.get(&capability).cloned()); + let Some(media) = media else { + let _ = write_status(&mut stream, 404, "Not Found", &[]); + return; + }; + let Ok(mut file) = File::open(media.path) else { + let _ = write_status(&mut stream, 404, "Not Found", &[]); + return; + }; + let Ok(total) = file.metadata().map(|metadata| metadata.len()) else { + let _ = write_status(&mut stream, 500, "Internal Server Error", &[]); + return; + }; + let selection = range.as_deref().and_then(|value| parse_range(value, total)); + if range.is_some() && selection.is_none() { + let _ = write_status( + &mut stream, + 416, + "Range Not Satisfiable", + &[("Content-Range", format!("bytes */{total}"))], + ); + return; + } + let (start, end, partial) = selection.unwrap_or((0, total.saturating_sub(1), false)); + let length = end.saturating_sub(start) + 1; + let mut headers = vec![ + ("Content-Type", media.content_type), + ("Content-Length", length.to_string()), + ("Accept-Ranges", "bytes".to_owned()), + ( + "Access-Control-Allow-Origin", + "https://tauri.localhost".to_owned(), + ), + ( + "Cache-Control", + "private, max-age=31536000, immutable".to_owned(), + ), + ]; + if partial { + headers.push(("Content-Range", format!("bytes {start}-{end}/{total}"))); + } + if write_status( + &mut stream, + if partial { 206 } else { 200 }, + if partial { "Partial Content" } else { "OK" }, + &headers, + ) + .is_err() + || method == "HEAD" + { + return; + } + if file.seek(SeekFrom::Start(start)).is_err() { + return; + } + let mut left = length; + let mut buffer = [0_u8; 64 * 1024]; + while left > 0 { + let want = left.min(buffer.len() as u64) as usize; + let Ok(read) = file.read(&mut buffer[..want]) else { + return; + }; + if read == 0 || stream.write_all(&buffer[..read]).is_err() { + return; + } + left -= read as u64; + } +} + +fn parse_request(stream: &TcpStream) -> Result<(String, String, Option), ()> { + let mut reader = BufReader::new(stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line).map_err(|_| ())?; + let mut parts = request_line.split_whitespace(); + let method = parts.next().ok_or(())?.to_owned(); + let path = parts.next().ok_or(())?; + if parts.next().is_none() || !path.starts_with('/') || path[1..].contains('/') { + return Err(()); + } + let mut range = None; + let mut line = String::new(); + loop { + line.clear(); + reader.read_line(&mut line).map_err(|_| ())?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line + .strip_prefix("Range:") + .or_else(|| line.strip_prefix("range:")) + { + range = Some(value.trim().to_owned()); + } + if line.len() > 8192 { + return Err(()); + } + } + Ok((method, path[1..].to_owned(), range)) +} + +fn parse_range(value: &str, total: u64) -> Option<(u64, u64, bool)> { + let spec = value.strip_prefix("bytes=")?; + if spec.contains(',') || total == 0 { + return None; + } + let (start, end) = spec.split_once('-')?; + if start.is_empty() { + let length = end.parse::().ok()?.min(total); + (length > 0).then_some((total - length, total - 1, true)) + } else { + let start = start.parse::().ok()?; + let end = if end.is_empty() { + total.checked_sub(1)? + } else { + end.parse::().ok()?.min(total.checked_sub(1)?) + }; + (start <= end && start < total).then_some((start, end, true)) + } +} + +fn write_status( + stream: &mut TcpStream, + status: u16, + reason: &str, + headers: &[(&str, String)], +) -> std::io::Result<()> { + write!( + stream, + "HTTP/1.1 {status} {reason}\r\nConnection: close\r\n" + )?; + for (name, value) in headers { + write!(stream, "{name}: {value}\r\n")?; + } + stream.write_all(b"\r\n") +} diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index f257da815a..38ed5e7607 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -16,7 +16,7 @@ import { import { TooltipProvider } from '$components/overlay-stack'; import { Eye, EyeSlash, menuIcon, sizedIcon, Play, Warning } from '$components/icons/phosphor'; import classNames from 'classnames'; -import { isTauri } from '@tauri-apps/api/core'; +import { invoke, isTauri } from '@tauri-apps/api/core'; import { BlurhashCanvas } from 'react-blurhash'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import type { IThumbnailContent, IVideoInfo } from '$types/matrix/common'; @@ -38,6 +38,7 @@ import { validBlurHash } from '$utils/blurHash'; import * as css from './style.css'; import { MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME } from '../../../../unstable/prefixes'; import { probeSWMediaAuthSupport } from '$utils/swMediaAuth'; +import { isAndroidTauri } from '$utils/platform'; type RenderVideoProps = { title: string; @@ -104,9 +105,14 @@ export const VideoContent = as<'div', VideoContentProps>( const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); + const prepareAndroidLoopback = (source: string) => + isAndroidTauri() + ? invoke('prepare_loopback_video', { url: source }) + : Promise.resolve(source); if (!encInfo) { if (isTauri()) { - return addTauriMediaRetryRevision(mediaUrl, retryRevisionRef.current); + const attemptedTarget = addTauriMediaRetryRevision(mediaUrl, retryRevisionRef.current); + return prepareAndroidLoopback(attemptedTarget); } // Stream through the service worker only after it proved media-auth // support; a stale SW build would otherwise serve the bare URL to @@ -118,7 +124,8 @@ export const VideoContent = as<'div', VideoContentProps>( const attemptedTarget = getTauriMediaRetryTarget(mediaUrl, retryRevisionRef.current) ?? mediaUrl; await setMediaEncryption(attemptedTarget, encInfo, mimeType); - return rewriteAuthenticatedMediaUrl(attemptedTarget)!; + const source = rewriteAuthenticatedMediaUrl(attemptedTarget)!; + return prepareAndroidLoopback(source); } return createObjectURL( downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index 73defdc454..d9c5d07353 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -66,6 +66,10 @@ export async function playNotificationSound(params: types.PlayNotificationSoundP return invoke('play_notification_sound', params); } +export async function prepareLoopbackVideo(params: types.PrepareLoopbackVideoParams): Promise { + return invoke('prepare_loopback_video', params); +} + export async function saveDownload(params: types.SaveDownloadParams): Promise { return invoke('save_download', params); } diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index cec6615286..f5b2a7ba78 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -107,6 +107,11 @@ export interface PlayNotificationSoundParams { [key: string]: unknown; } +export interface PrepareLoopbackVideoParams { + url: string; + [key: string]: unknown; +} + export interface SaveDownloadParams { filename: string; bytes: number[]; @@ -188,4 +193,3 @@ export interface UploadWriteChunkParams { chunk: string; [key: string]: unknown; } -