diff --git a/Cargo.lock b/Cargo.lock index 72e967498e..2cdc92a051 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,7 +1084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3126,7 +3126,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3185,7 +3185,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3196,9 +3196,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -4294,7 +4294,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6c0c3cf1e1..c9fff44522 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -4590,9 +4590,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 95325dd6ba..96d1ebe2ab 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -1,14 +1,3 @@ -//! NIP-AB device pairing — Tauri commands for the source (desktop) side. -//! -//! Architecture: a background tokio task owns the WebSocket connection. -//! Tauri commands communicate with it via an mpsc channel (JSON strings). -//! State changes are pushed to the React frontend via Tauri events. -//! -//! sprout-core depends on nostr 0.36 while the desktop uses nostr 0.37. -//! We import `nostr_compat` (aliased to nostr 0.36) for types that cross -//! the sprout-core boundary, and serialize events to JSON strings for the -//! mpsc channel to avoid mixing the two versions. - use std::sync::Arc; use std::time::Duration; @@ -28,9 +17,7 @@ use zeroize::Zeroizing; use crate::app_state::AppState; use crate::relay::{relay_api_base_url, relay_ws_url}; -use super::tokens::mint_token_internal; - -// ── Tauri event payloads ──────────────────────────────────────────────────── +use super::tokens::{mint_token_internal_with_auth_mode, MintTokenAuthMode}; #[derive(Serialize, Clone)] struct PairingSasPayload { @@ -47,8 +34,6 @@ struct PairingErrorPayload { message: String, } -// ── Managed state ─────────────────────────────────────────────────────────── - /// Managed Tauri state for an active pairing session. pub struct PairingHandle { session: Arc>>, @@ -74,14 +59,9 @@ impl PairingHandle { *self.cancel.lock().unwrap_or_else(|e| e.into_inner()) = None; *self.outbound_tx.lock().unwrap_or_else(|e| e.into_inner()) = None; *self.payload.lock().unwrap_or_else(|e| e.into_inner()) = None; - // Note: session is in an Arc and is cleared - // by the background task on exit. For immediate cleanup, callers - // should also lock and clear the session explicitly. } } -// ── Mobile scopes ─────────────────────────────────────────────────────────── - const MOBILE_SCOPES: &[&str] = &[ "messages:read", "messages:write", @@ -89,10 +69,17 @@ const MOBILE_SCOPES: &[&str] = &[ "channels:write", "users:read", "files:read", + "files:write", ]; const EXPIRES_IN_DAYS: u32 = 90; -// ── Commands ──────────────────────────────────────────────────────────────── +fn mobile_pairing_mint_auth_mode(state: &AppState) -> MintTokenAuthMode { + if state.configured_api_token.is_some() { + MintTokenAuthMode::BootstrapNip98 + } else { + MintTokenAuthMode::Auto + } +} /// Start a NIP-AB pairing session as the source device. /// @@ -104,19 +91,23 @@ pub async fn start_pairing( state: State<'_, AppState>, pairing: State<'_, PairingHandle>, ) -> Result { - // Cancel any existing session. if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } pairing.clear(); - // 1. Mint a mobile token. let scopes: Vec = MOBILE_SCOPES.iter().map(|s| s.to_string()).collect(); let token_name = format!("mobile-pairing-{}", chrono::Utc::now().timestamp()); - let mint_result = - mint_token_internal(&state, &token_name, &scopes, None, Some(EXPIRES_IN_DAYS)).await?; + let mint_result = mint_token_internal_with_auth_mode( + &state, + &token_name, + &scopes, + None, + Some(EXPIRES_IN_DAYS), + mobile_pairing_mint_auth_mode(&state), + ) + .await?; - // 2. Read user identity. let (nsec, pubkey_hex) = { let keys = state.keys.lock().map_err(|e| e.to_string())?; let nsec = keys @@ -127,15 +118,12 @@ pub async fn start_pairing( (nsec, pubkey) }; - // 3. Get relay URLs. let ws_url = relay_ws_url(); let http_url = relay_api_base_url(); - // 4. Create the pairing session. let (session, qr_payload) = PairingSession::new_source(ws_url.clone()); let qr_uri = encode_qr(&qr_payload); - // 5. Build the custom payload to send after SAS confirmation. let payload_json = serde_json::json!({ "relayUrl": http_url, "token": mint_result.token, @@ -143,7 +131,6 @@ pub async fn start_pairing( "nsec": nsec, }); - // 6. Store session + payload. { let mut s = pairing.session.lock().await; *s = Some(session); @@ -151,14 +138,12 @@ pub async fn start_pairing( *pairing.payload.lock().map_err(|e| e.to_string())? = Some(Zeroizing::new(payload_json.to_string())); - // 7. Create channel + cancellation token. let (outbound_tx, outbound_rx) = mpsc::channel::(16); let cancel = CancellationToken::new(); *pairing.outbound_tx.lock().map_err(|e| e.to_string())? = Some(outbound_tx); *pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone()); - // 8. Spawn background WS task. let session_arc = Arc::clone(&pairing.session); tauri::async_runtime::spawn(pairing_ws_task( ws_url, @@ -181,7 +166,6 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() .clone() .ok_or("no active pairing session")?; - // 1. Confirm SAS → get sas-confirm event, serialize to JSON. let sas_confirm_json = { let mut guard = pairing.session.lock().await; let session = guard.as_mut().ok_or("no active pairing session")?; @@ -193,7 +177,6 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() .await .map_err(|_| "failed to send sas-confirm")?; - // 2. Send payload (contains nsec — Zeroizing ensures cleanup). let payload = pairing .payload .lock() @@ -220,7 +203,6 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() /// Cancel the active pairing session. #[tauri::command] pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), String> { - // Try to send an abort event if we have a session and a channel. let abort_json = { let mut guard = pairing.session.lock().await; if let Some(session) = guard.as_mut() { @@ -245,13 +227,11 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str } } - // Cancel background task. if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } pairing.clear(); - // Clear session. { let mut s = pairing.session.lock().await; *s = None; @@ -260,8 +240,6 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str Ok(()) } -// ── Background WebSocket task ─────────────────────────────────────────────── - async fn pairing_ws_task( relay_url: String, session: Arc>>, @@ -274,7 +252,6 @@ async fn pairing_ws_task( { let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); } - // Clean up session on exit. let mut s = session.lock().await; *s = None; } @@ -286,16 +263,13 @@ async fn pairing_ws_task_inner( outbound_rx: &mut mpsc::Receiver, app: &AppHandle, ) -> Result<(), String> { - // Connect to relay. let (ws, _) = connect_async(relay_url) .await .map_err(|e| format!("WebSocket connection failed: {e}"))?; let (mut write, mut read) = ws.split(); - // Handle NIP-42 auth if required. handle_nip42_auth(&mut read, &mut write, session, relay_url).await?; - // Subscribe for kind:24134 events tagged to our ephemeral pubkey. let our_pk = { let guard = session.lock().await; guard.as_ref().ok_or("session gone")?.pubkey().to_hex() @@ -309,10 +283,8 @@ async fn pairing_ws_task_inner( .await .map_err(|e| format!("subscribe failed: {e}"))?; - // Wait for EOSE to confirm subscription is registered. wait_for_eose(&mut read, "pair", Duration::from_secs(10)).await?; - // Event loop. let hard_timeout = tokio::time::sleep(Duration::from_secs(130)); tokio::pin!(hard_timeout); @@ -326,7 +298,6 @@ async fn pairing_ws_task_inner( break; } Some(json_msg) = outbound_rx.recv() => { - // json_msg is already a ["EVENT", ...] JSON string. if let Err(e) = write.send(Message::Text(json_msg.into())).await { return Err(format!("publish failed: {e}")); } @@ -338,12 +309,10 @@ async fn pairing_ws_task_inner( let msg = msg.map_err(|e| format!("WS read error: {e}"))?; let Message::Text(text) = msg else { continue }; - // Parse relay EVENT message into sprout-core's nostr::Event. if let Some(event) = parse_relay_event(text.as_str(), "pair") { let mut guard = session.lock().await; let Some(s) = guard.as_mut() else { break }; - // Check for abort from peer. match s.handle_abort(&event) { Ok(reason) => { let _ = app.emit("pairing-aborted", PairingAbortedPayload { @@ -351,16 +320,14 @@ async fn pairing_ws_task_inner( }); break; } - Err(_) => {} // Not an abort — continue. + Err(_) => {} } - // Try handle_offer (source waits for this first). if let Ok(sas) = s.handle_offer(&event) { let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); continue; } - // Try handle_complete (source waits for this after sending payload). match s.handle_complete(&event) { Ok(()) => { let _ = app.emit("pairing-complete", serde_json::json!({})); @@ -372,7 +339,7 @@ async fn pairing_ws_task_inner( }); break; } - Err(_) => {} // Wrong state or wrong message — discard per NIP-AB. + Err(_) => {} } } } @@ -382,8 +349,6 @@ async fn pairing_ws_task_inner( Ok(()) } -// ── NIP-42 auth helper ────────────────────────────────────────────────────── - async fn handle_nip42_auth( read: &mut R, write: &mut W, @@ -394,7 +359,6 @@ where R: StreamExt> + Unpin, W: SinkExt + Unpin, { - // Wait up to 3 seconds for an AUTH challenge. Timeout is normal. let auth_result = tokio::time::timeout(Duration::from_secs(3), async { loop { let msg = read @@ -414,11 +378,9 @@ where let challenge: String = match auth_result { Ok(Ok(c)) => c, Ok(Err(e)) => return Err(e), - Err(_) => return Ok(()), // No AUTH challenge. + Err(_) => return Ok(()), }; - // Sign auth event with the session's ephemeral keys. - // Use sprout-core's nostr EventBuilder (0.36) via PairingSession::sign_event. let relay_url_parsed: url::Url = relay_url .parse() .map_err(|e| format!("invalid relay URL: {e}"))?; @@ -442,7 +404,6 @@ where .await .map_err(|e| format!("send auth: {e}"))?; - // Wait for OK response (up to 5 seconds). let _ = tokio::time::timeout(Duration::from_secs(5), async { loop { let msg = read @@ -462,8 +423,6 @@ where Ok(()) } -// ── Helper functions ──────────────────────────────────────────────────────── - /// Serialize a nostr 0.36 Event to `["EVENT", ]` JSON string. fn event_to_relay_json(event: &nostr_compat::Event) -> String { format!("[\"EVENT\",{}]", nostr_compat::JsonUtil::as_json(event)) @@ -522,3 +481,37 @@ where .await .map_err(|_| "timeout waiting for EOSE".to_string())? } + +#[cfg(test)] +mod tests { + use super::MOBILE_SCOPES; + use super::*; + use crate::app_state::build_app_state; + + #[test] + fn mobile_pairing_token_includes_file_write_scope() { + assert!(MOBILE_SCOPES.contains(&"files:write")); + } + + #[test] + fn mobile_pairing_uses_bootstrap_mint_when_configured_token_is_present() { + let mut state = build_app_state(); + state.configured_api_token = Some("desktop-token".to_string()); + + assert_eq!( + mobile_pairing_mint_auth_mode(&state), + MintTokenAuthMode::BootstrapNip98 + ); + } + + #[test] + fn mobile_pairing_keeps_auto_mint_without_configured_token() { + let mut state = build_app_state(); + state.configured_api_token = None; + + assert_eq!( + mobile_pairing_mint_auth_mode(&state), + MintTokenAuthMode::Auto + ); + } +} diff --git a/desktop/src-tauri/src/commands/tokens.rs b/desktop/src-tauri/src/commands/tokens.rs index a45998e4d0..3ff58635ce 100644 --- a/desktop/src-tauri/src/commands/tokens.rs +++ b/desktop/src-tauri/src/commands/tokens.rs @@ -10,6 +10,22 @@ use crate::{ }, }; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MintTokenAuthMode { + Auto, + BootstrapNip98, +} + +impl MintTokenAuthMode { + fn uses_configured_bearer_token(self, state: &AppState) -> bool { + matches!(self, Self::Auto) && state.configured_api_token.is_some() + } + + fn should_store_minted_session_token(self, state: &AppState) -> bool { + matches!(self, Self::Auto) && state.configured_api_token.is_none() + } +} + #[tauri::command] pub async fn list_tokens(state: State<'_, AppState>) -> Result { let request = @@ -17,6 +33,31 @@ pub async fn list_tokens(state: State<'_, AppState>) -> Result, + auth_mode: MintTokenAuthMode, +) -> Result { + if auth_mode.uses_configured_bearer_token(state) { + return Ok( + build_authed_request(&state.http_client, Method::POST, "/api/tokens", state)? + .json(body), + ); + } + + let url = format!("{}{}", relay_api_base_url(), "/api/tokens"); + let body_bytes = + serde_json::to_vec(body).map_err(|error| format!("serialize failed: {error}"))?; + let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; + + Ok(state + .http_client + .request(Method::POST, url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .body(body_bytes)) +} + /// Internal token minting logic, callable from other modules (e.g. pairing). pub async fn mint_token_internal( state: &AppState, @@ -24,6 +65,25 @@ pub async fn mint_token_internal( scopes: &[String], channel_ids: Option<&[String]>, expires_in_days: Option, +) -> Result { + mint_token_internal_with_auth_mode( + state, + name, + scopes, + channel_ids, + expires_in_days, + MintTokenAuthMode::Auto, + ) + .await +} + +pub async fn mint_token_internal_with_auth_mode( + state: &AppState, + name: &str, + scopes: &[String], + channel_ids: Option<&[String]>, + expires_in_days: Option, + auth_mode: MintTokenAuthMode, ) -> Result { let body = MintTokenBody { name, @@ -32,24 +92,10 @@ pub async fn mint_token_internal( expires_in_days, owner_pubkey: None, }; - let request = if state.configured_api_token.is_some() { - build_authed_request(&state.http_client, Method::POST, "/api/tokens", state)?.json(&body) - } else { - let url = format!("{}{}", relay_api_base_url(), "/api/tokens"); - let body_bytes = - serde_json::to_vec(&body).map_err(|error| format!("serialize failed: {error}"))?; - let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; - - state - .http_client - .request(Method::POST, url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - }; + let request = build_mint_token_request(state, &body, auth_mode)?; let response: MintTokenResponse = send_json_request(request).await?; - if state.configured_api_token.is_none() { + if auth_mode.should_store_minted_session_token(state) { let mut token = state .session_token .lock() @@ -94,3 +140,68 @@ pub async fn revoke_all_tokens( build_token_management_request(&state.http_client, Method::DELETE, "/api/tokens", &state)?; send_json_request(request).await } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_state::build_app_state; + + fn test_body<'a>(scopes: &'a [String]) -> MintTokenBody<'a> { + MintTokenBody { + name: "test-token", + scopes, + channel_ids: None, + expires_in_days: Some(7), + owner_pubkey: None, + } + } + + #[test] + fn auto_mint_uses_configured_bearer_when_present() { + let mut state = build_app_state(); + state.configured_api_token = Some("desktop-token".to_string()); + let scopes = vec!["messages:read".to_string()]; + + let request = + build_mint_token_request(&state, &test_body(&scopes), MintTokenAuthMode::Auto) + .expect("request should build") + .build() + .expect("request should finalize"); + + assert_eq!( + request + .headers() + .get("Authorization") + .expect("auth header") + .to_str() + .expect("auth header should be valid utf-8"), + "Bearer desktop-token" + ); + } + + #[test] + fn bootstrap_mint_ignores_configured_bearer_token() { + let mut state = build_app_state(); + state.configured_api_token = Some("desktop-token".to_string()); + let scopes = vec!["messages:read".to_string(), "files:write".to_string()]; + + let request = build_mint_token_request( + &state, + &test_body(&scopes), + MintTokenAuthMode::BootstrapNip98, + ) + .expect("request should build") + .build() + .expect("request should finalize"); + + let auth_header = request + .headers() + .get("Authorization") + .expect("auth header") + .to_str() + .expect("auth header should be valid utf-8"); + + assert!(auth_header.starts_with("Nostr ")); + assert_ne!(auth_header, "Bearer desktop-token"); + } +} diff --git a/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt index 1b65180dc0..455c9b4bed 100644 --- a/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt @@ -1,5 +1,165 @@ package com.sprout.sprout_mobile +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.os.Build import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + private var mediaUploadChannel: MethodChannel? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + mediaUploadChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + MEDIA_UPLOAD_CHANNEL, + ).also { channel -> + channel.setMethodCallHandler { call, result -> + when (call.method) { + SANITIZE_IMAGE_FOR_UPLOAD_METHOD -> { + handleSanitizeImageForUpload(call.arguments, result) + } + TRANSCODE_IMAGE_TO_JPEG_METHOD -> { + handleTranscodeImageToJpeg(call.arguments, result) + } + else -> result.notImplemented() + } + } + } + } + + private fun handleSanitizeImageForUpload( + arguments: Any?, + result: MethodChannel.Result, + ) { + val payload = arguments as? Map<*, *> ?: run { + invalidArguments(result, "Expected image bytes and mime type.") + return + } + val bytes = payload["bytes"] as? ByteArray ?: run { + invalidArguments(result, "Expected raw image bytes.") + return + } + val mimeType = payload["mimeType"] as? String ?: run { + invalidArguments(result, "Expected image mime type.") + return + } + + val format = sanitizeCompressFormatFor(mimeType) + if (format == null) { + result.error( + "sanitize_failed", + "Unable to sanitize picked image.", + mimeType, + ) + return + } + + transformImageBytes( + bytes = bytes, + result = result, + format = format, + errorCode = "sanitize_failed", + encodeFailureMessage = "Unable to sanitize picked image.", + errorDetails = mimeType, + ) + } + + private fun handleTranscodeImageToJpeg( + arguments: Any?, + result: MethodChannel.Result, + ) { + val bytes = arguments as? ByteArray ?: run { + invalidArguments(result, "Expected raw image bytes.") + return + } + + transformImageBytes( + bytes = bytes, + result = result, + format = Bitmap.CompressFormat.JPEG, + errorCode = "transcode_failed", + encodeFailureMessage = "Unable to convert picked image to JPEG.", + ) + } + + private fun decodeBitmap(bytes: ByteArray): Bitmap? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + runCatching { + val source = ImageDecoder.createSource(ByteBuffer.wrap(bytes)) + ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + }.getOrNull()?.let { return it } + } + + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + + private fun encodeBitmap( + bitmap: Bitmap, + format: Bitmap.CompressFormat, + ): ByteArray? { + val output = ByteArrayOutputStream() + val encoded = bitmap.compress(format, 100, output) + return if (encoded) output.toByteArray() else null + } + + private fun sanitizeCompressFormatFor( + mimeType: String, + ): Bitmap.CompressFormat? { + return when (mimeType) { + "image/jpeg" -> Bitmap.CompressFormat.JPEG + "image/png" -> Bitmap.CompressFormat.PNG + else -> null + } + } + + private fun transformImageBytes( + bytes: ByteArray, + result: MethodChannel.Result, + format: Bitmap.CompressFormat, + errorCode: String, + encodeFailureMessage: String, + errorDetails: Any? = null, + ) { + val bitmap = decodeBitmap(bytes) ?: run { + result.error( + errorCode, + "Unable to decode picked image.", + null, + ) + return + } + + val transformedBytes = encodeBitmap(bitmap, format) ?: run { + result.error( + errorCode, + encodeFailureMessage, + errorDetails, + ) + return + } + + result.success(transformedBytes) + } + + private fun invalidArguments( + result: MethodChannel.Result, + message: String, + ) { + result.error("invalid_arguments", message, null) + } + + companion object { + private const val MEDIA_UPLOAD_CHANNEL = "sprout/media_upload" + private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload" + private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg" + } +} diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 6956775f66..add9f7a698 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -4,6 +4,8 @@ PODS: - Flutter (1.0.0) - flutter_secure_storage (6.0.0): - Flutter + - image_picker_ios (0.0.1): + - Flutter - mobile_scanner (7.0.0): - Flutter - FlutterMacOS @@ -12,14 +14,19 @@ PODS: - FlutterMacOS - url_launcher_ios (0.0.1): - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) EXTERNAL SOURCES: connectivity_plus: @@ -28,20 +35,26 @@ EXTERNAL SOURCES: :path: Flutter flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" SPEC CHECKSUMS: connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2 diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c30b367ec0..db858f3998 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -3,6 +3,8 @@ import UIKit @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + private var mediaUploadChannel: FlutterMethodChannel? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -12,5 +14,97 @@ import UIKit func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + mediaUploadChannel = FlutterMethodChannel( + name: "sprout/media_upload", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in + self?.handleMediaUploadMethodCall(call, result: result) + } + } + + private func handleMediaUploadMethodCall( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + switch call.method { + case "sanitizeImageForUpload": + guard + let arguments = call.arguments as? [String: Any], + let typedData = arguments["bytes"] as? FlutterStandardTypedData, + let mimeType = arguments["mimeType"] as? String + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected image bytes and mime type.", + details: nil + ) + ) + return + } + + guard let image = UIImage(data: typedData.data) else { + result( + FlutterError( + code: "sanitize_failed", + message: "Unable to decode picked image.", + details: nil + ) + ) + return + } + + let sanitizedData: Data? + switch mimeType { + case "image/png": + sanitizedData = image.pngData() + case "image/jpeg": + sanitizedData = image.jpegData(compressionQuality: 1.0) + default: + sanitizedData = nil + } + + guard let sanitizedData else { + result( + FlutterError( + code: "sanitize_failed", + message: "Unable to sanitize picked image.", + details: mimeType + ) + ) + return + } + + result(FlutterStandardTypedData(bytes: sanitizedData)) + case "transcodeImageToJpeg": + guard let typedData = call.arguments as? FlutterStandardTypedData else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected raw image bytes.", + details: nil + ) + ) + return + } + + guard let image = UIImage(data: typedData.data), + let jpegData = image.jpegData(compressionQuality: 1.0) + else { + result( + FlutterError( + code: "transcode_failed", + message: "Unable to convert picked image to JPEG.", + details: nil + ) + ) + return + } + + result(FlutterStandardTypedData(bytes: jpegData)) + default: + result(FlutterMethodNotImplemented) + } } } diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 0a49ba75d1..39c1bc4cab 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -26,6 +26,8 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSPhotoLibraryUsageDescription + Sprout needs photo library access so you can attach images to messages. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index d1e729768e..f2fdbee7d6 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -186,13 +186,19 @@ class ChannelDetailPage extends HookConsumerWidget { ComposeBar( channelId: channel.id, channelName: resolvedChannel.isDm ? '' : resolvedChannel.name, - onSend: (content, mentionPubkeys) => ref - .read(sendMessageProvider) - .call( - channelId: channel.id, - content: content, - mentionPubkeys: mentionPubkeys, - ), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) => ref + .read(sendMessageProvider) + .call( + channelId: channel.id, + content: content, + mentionPubkeys: mentionPubkeys, + mediaTags: mediaTags, + ), ) else if (!resolvedChannel.isDm && (!resolvedChannel.isMember || resolvedChannel.isArchived)) @@ -654,6 +660,7 @@ class _MessageBubble extends ConsumerWidget { content: message.content, mentionNames: mentionNames, channelNames: channelNames, + tags: message.tags, onChannelTap: (channelId) { if (channelId == currentChannelId) return; final channelsAsync = ref.read(channelsProvider); diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 93d715db29..124d8d8f7e 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -14,12 +14,18 @@ import 'channel_management_provider.dart'; /// Rich compose bar with @mention autocomplete, emoji picker, and a markdown /// formatting toolbar. Used in both channel and thread views — the caller /// provides an [onSend] callback that handles actual message submission. +typedef ComposeBarOnSend = + Future Function( + String content, + List mentionPubkeys, { + List> mediaTags, + }); + class ComposeBar extends HookConsumerWidget { final String channelId; final String channelName; final String? hintText; - final Future Function(String content, List mentionPubkeys) - onSend; + final ComposeBarOnSend onSend; /// Optional thread IDs for thread-scoped typing indicators. final String? threadHeadId; @@ -41,6 +47,11 @@ class ComposeBar extends HookConsumerWidget { final focusNode = useFocusNode(); final isSending = useState(false); final showFormatting = useState(false); + final attachments = useState>([]); + final uploadError = useState(null); + final uploadingCount = useState(0); + final hasAttachments = attachments.value.isNotEmpty; + final hasPendingUploads = uploadingCount.value > 0; final resolvedHint = hintText ?? @@ -170,10 +181,28 @@ class ComposeBar extends HookConsumerWidget { focusNode.requestFocus(); } + void clearComposer() { + controller.clear(); + attachments.value = []; + mentionMap.value.clear(); + mentionQuery.value = null; + showFormatting.value = false; + uploadError.value = null; + focusNode.requestFocus(); + } + + void removeAttachment(String url) { + attachments.value = _withoutAttachment(attachments.value, url); + } + // Send the message. Future send() async { final text = controller.text.trim(); - if (text.isEmpty || isSending.value) return; + if ((text.isEmpty && !hasAttachments) || + isSending.value || + hasPendingUploads) { + return; + } // Extract pubkeys for mentions present in the final text. final pubkeys = [ @@ -181,21 +210,43 @@ class ComposeBar extends HookConsumerWidget { if (text.contains('@${entry.key}')) entry.value, ]; + final payload = _ComposeDraftPayload.fromDraft( + text: text, + attachments: attachments.value, + ); + isSending.value = true; try { - await onSend(text, pubkeys); + await onSend(payload.content, pubkeys, mediaTags: payload.mediaTags); if (context.mounted) { - controller.clear(); - mentionMap.value.clear(); - mentionQuery.value = null; - showFormatting.value = false; - focusNode.requestFocus(); + clearComposer(); } } finally { if (context.mounted) isSending.value = false; } } + Future pickAndUploadAttachment() async { + uploadError.value = null; + uploadingCount.value += 1; + try { + final uploaded = await ref + .read(mediaUploadServiceProvider) + .pickAndUploadImage(); + if (uploaded != null && context.mounted) { + attachments.value = [...attachments.value, uploaded]; + } + } catch (error) { + if (context.mounted) { + uploadError.value = _formatUploadError(error); + } + } finally { + if (context.mounted) { + uploadingCount.value -= 1; + } + } + } + // Insert an emoji at the cursor. void insertEmoji(String emoji) { final text = controller.text; @@ -284,6 +335,28 @@ class ComposeBar extends HookConsumerWidget { if (showFormatting.value) _FormattingToolbar(onFormat: applyFormat), + if (hasAttachments || hasPendingUploads) ...[ + _AttachmentStrip( + attachments: attachments.value, + uploadingCount: uploadingCount.value, + onRemove: removeAttachment, + ), + const SizedBox(height: Grid.xxs), + ], + + if (uploadError.value case final error?) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + const SizedBox(height: Grid.xxs), + ], + // Row 1 — text input (full width, grows). TextField( controller: controller, @@ -316,7 +389,7 @@ class ComposeBar extends HookConsumerWidget { children: [ _ComposeAction( icon: LucideIcons.paperclip, - onTap: () {}, // attachment placeholder + onTap: pickAndUploadAttachment, ), _ComposeAction( icon: LucideIcons.smilePlus, @@ -335,7 +408,11 @@ class ComposeBar extends HookConsumerWidget { onTap: () => showFormatting.value = !showFormatting.value, ), const Spacer(), - _SendButton(isSending: isSending.value, onTap: send), + _SendButton( + isDisabled: hasPendingUploads, + isSending: isSending.value, + onTap: send, + ), ], ), ], @@ -902,11 +979,146 @@ class _ComposeAction extends StatelessWidget { } } +@immutable +class _ComposeDraftPayload { + final String content; + final List> mediaTags; + + const _ComposeDraftPayload({required this.content, required this.mediaTags}); + + factory _ComposeDraftPayload.fromDraft({ + required String text, + required List attachments, + }) { + var content = text; + final mediaTags = >[]; + for (final attachment in attachments) { + mediaTags.add(attachment.toImetaTag()); + content += '\n${attachment.toMarkdownImage()}'; + } + return _ComposeDraftPayload(content: content, mediaTags: mediaTags); + } +} + +List _withoutAttachment( + List attachments, + String url, +) { + return [ + for (final attachment in attachments) + if (attachment.url != url) attachment, + ]; +} + +class _AttachmentStrip extends StatelessWidget { + final List attachments; + final int uploadingCount; + final void Function(String url) onRemove; + + const _AttachmentStrip({ + required this.attachments, + required this.uploadingCount, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + final thumbWidth = 72.0; + final thumbHeight = 72.0; + + return SizedBox( + height: thumbHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: attachments.length + uploadingCount, + separatorBuilder: (_, _) => const SizedBox(width: Grid.half), + itemBuilder: (context, index) { + if (index >= attachments.length) { + return Container( + width: thumbWidth, + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all(color: context.colors.outlineVariant), + ), + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + final attachment = attachments[index]; + final previewUrl = attachment.thumb ?? attachment.url; + return Container( + key: ValueKey('compose-attachment:${attachment.url}'), + width: thumbWidth, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all(color: context.colors.outlineVariant), + ), + child: Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(Radii.md), + child: Image.network( + previewUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => ColoredBox( + color: context.colors.surface, + child: Icon( + LucideIcons.image, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + Positioned( + top: Grid.quarter, + right: Grid.quarter, + child: SizedBox( + width: 24, + height: 24, + child: IconButton( + onPressed: () => onRemove(attachment.url), + tooltip: 'Remove attachment', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: context.colors.surface.withValues( + alpha: 0.92, + ), + minimumSize: const Size(24, 24), + maximumSize: const Size(24, 24), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: Icon( + LucideIcons.x, + size: 14, + color: context.colors.onSurface, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} + class _SendButton extends StatelessWidget { final bool isSending; + final bool isDisabled; final VoidCallback onTap; - const _SendButton({required this.isSending, required this.onTap}); + const _SendButton({ + required this.isSending, + required this.onTap, + this.isDisabled = false, + }); @override Widget build(BuildContext context) { @@ -914,7 +1126,7 @@ class _SendButton extends StatelessWidget { width: 36, height: 36, child: IconButton( - onPressed: isSending ? null : onTap, + onPressed: (isSending || isDisabled) ? null : onTap, style: IconButton.styleFrom( backgroundColor: context.colors.primary, disabledBackgroundColor: context.colors.primary.withValues( @@ -943,3 +1155,7 @@ class _SendButton extends StatelessWidget { ); } } + +String _formatUploadError(Object error) { + return error.toString().replaceFirst('Exception: ', ''); +} diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart new file mode 100644 index 0000000000..d513adcc6a --- /dev/null +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -0,0 +1,506 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:video_player/video_player.dart'; + +import '../../shared/theme/theme.dart'; + +const _imageViewerPushDuration = Duration(milliseconds: 280); +const _imageViewerPopDuration = Duration(milliseconds: 220); +const _imageViewerTransitionOffset = Offset(0, 0.08); +const _identityTransformEpsilon = 0.0001; +final List _identityTransformStorage = List.unmodifiable( + Matrix4.identity().storage, +); + +PageRoute buildImageViewerRoute({ + required String imageUrl, + required Object heroTag, + String? semanticLabel, +}) { + return PageRouteBuilder( + transitionDuration: _imageViewerPushDuration, + reverseTransitionDuration: _imageViewerPopDuration, + pageBuilder: (context, animation, secondaryAnimation) => + MediaImageViewerPage( + imageUrl: imageUrl, + heroTag: heroTag, + semanticLabel: semanticLabel, + ), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + _ImageViewerRouteTransition(animation: animation, child: child), + ); +} + +void openImageViewer( + BuildContext context, { + required String imageUrl, + required Object heroTag, + String? semanticLabel, +}) { + Navigator.of(context).push( + buildImageViewerRoute( + imageUrl: imageUrl, + heroTag: heroTag, + semanticLabel: semanticLabel, + ), + ); +} + +void openVideoViewer( + BuildContext context, { + required String videoUrl, + String? posterUrl, +}) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + MediaVideoViewerPage(videoUrl: videoUrl, posterUrl: posterUrl), + ), + ); +} + +class _ImageViewerRouteTransition extends StatelessWidget { + final Animation animation; + final Widget child; + + const _ImageViewerRouteTransition({ + required this.animation, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final fade = CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ); + final slide = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + return FadeTransition( + opacity: fade, + child: SlideTransition( + position: Tween( + begin: _imageViewerTransitionOffset, + end: Offset.zero, + ).animate(slide), + child: child, + ), + ); + } +} + +class MediaImageViewerPage extends StatefulWidget { + final String imageUrl; + final Object heroTag; + final String? semanticLabel; + + const MediaImageViewerPage({ + super.key, + required this.imageUrl, + required this.heroTag, + this.semanticLabel, + }); + + @override + State createState() => _MediaImageViewerPageState(); +} + +class _MediaImageViewerPageState extends State { + late final TransformationController _transformationController; + bool _isTransformed = false; + bool _disableHeroOnDismiss = false; + + @override + void initState() { + super.initState(); + _transformationController = TransformationController(); + _transformationController.addListener(_handleTransformChanged); + } + + @override + void dispose() { + _transformationController.removeListener(_handleTransformChanged); + _transformationController.dispose(); + super.dispose(); + } + + void _handleTransformChanged() { + final isTransformed = _hasImageTransform(_transformationController.value); + if (isTransformed == _isTransformed) { + return; + } + + setState(() { + _isTransformed = isTransformed; + }); + } + + bool get _canDismissWithHero => !_isTransformed || _disableHeroOnDismiss; + + Future _prepareHeroFallbackDismiss() async { + if (_canDismissWithHero) { + return; + } + + setState(() { + _disableHeroOnDismiss = true; + }); + + await WidgetsBinding.instance.endOfFrame; + } + + Future _dismiss() async { + await _prepareHeroFallbackDismiss(); + if (!mounted) { + return; + } + Navigator.of(context).maybePop(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: _canDismissWithHero, + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + return; + } + unawaited(_dismiss()); + }, + child: Scaffold( + key: const ValueKey('message-media-image-viewer'), + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + transformationController: _transformationController, + minScale: 1, + maxScale: 4, + child: Center( + child: HeroMode( + key: const ValueKey('message-media-image-viewer-hero-mode'), + enabled: !_disableHeroOnDismiss, + child: Hero( + tag: widget.heroTag, + child: Image.network( + widget.imageUrl, + fit: BoxFit.contain, + semanticLabel: widget.semanticLabel, + errorBuilder: (_, _, _) => const _MediaLoadFailure( + message: 'Failed to load image', + icon: LucideIcons.imageOff, + ), + ), + ), + ), + ), + ), + ), + PositionedDirectional( + top: Grid.sm, + end: Grid.sm, + child: SafeArea( + child: DecoratedBox( + decoration: const BoxDecoration( + color: Color.fromRGBO(0, 0, 0, 0.56), + shape: BoxShape.circle, + ), + child: IconButton( + key: const ValueKey('message-media-image-viewer-close'), + onPressed: _dismiss, + tooltip: 'Close image viewer', + icon: const Icon(LucideIcons.x, color: Colors.white), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +bool _hasImageTransform(Matrix4 transform) { + final storage = transform.storage; + for (var index = 0; index < storage.length; index++) { + if ((storage[index] - _identityTransformStorage[index]).abs() > + _identityTransformEpsilon) { + return true; + } + } + return false; +} + +class MediaVideoViewerPage extends StatefulWidget { + final String videoUrl; + final String? posterUrl; + + const MediaVideoViewerPage({ + super.key, + required this.videoUrl, + this.posterUrl, + }); + + @override + State createState() => _MediaVideoViewerPageState(); +} + +class _MediaVideoViewerPageState extends State { + late final VideoPlayerController _controller; + late final Future _initializeFuture; + String? _error; + + @override + void initState() { + super.initState(); + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); + _initializeFuture = _controller + .initialize() + .then((_) async { + await _controller.play(); + if (mounted) { + setState(() {}); + } + }) + .catchError((Object error) { + if (mounted) { + setState(() { + _error = error.toString(); + }); + } + }); + } + + @override + void dispose() { + unawaited(_controller.dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _MediaViewerScaffold( + scaffoldKey: const ValueKey('message-media-video-viewer'), + title: 'Video', + child: Center( + child: FutureBuilder( + future: _initializeFuture, + builder: (context, snapshot) { + if (_error != null || snapshot.hasError) { + return const _MediaLoadFailure( + message: 'Failed to load video', + icon: LucideIcons.videoOff, + ); + } + + if (!_controller.value.isInitialized) { + return _VideoLoadingPoster(posterUrl: widget.posterUrl); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ), + const SizedBox(height: Grid.sm), + _VideoTransportBar(controller: _controller), + ], + ); + }, + ), + ), + ); + } +} + +class _MediaViewerScaffold extends StatelessWidget { + final Key scaffoldKey; + final String title; + final Widget child; + + const _MediaViewerScaffold({ + required this.scaffoldKey, + required this.title, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: scaffoldKey, + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + scrolledUnderElevation: 0, + surfaceTintColor: Colors.transparent, + iconTheme: const IconThemeData(color: Colors.white), + title: Text(title), + ), + body: SafeArea(child: child), + ); + } +} + +class _VideoLoadingPoster extends StatelessWidget { + final String? posterUrl; + + const _VideoLoadingPoster({required this.posterUrl}); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + if (posterUrl != null) + Image.network( + posterUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _videoPlaceholder(context), + ) + else + _videoPlaceholder(context), + const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.24)), + const Center(child: CircularProgressIndicator()), + ], + ), + ); + } + + Widget _videoPlaceholder(BuildContext context) { + return ColoredBox( + color: context.colors.surfaceContainerHighest, + child: Icon( + LucideIcons.video, + size: 40, + color: context.colors.onSurfaceVariant, + ), + ); + } +} + +class _VideoTransportBar extends StatefulWidget { + final VideoPlayerController controller; + + const _VideoTransportBar({required this.controller}); + + @override + State<_VideoTransportBar> createState() => _VideoTransportBarState(); +} + +class _VideoTransportBarState extends State<_VideoTransportBar> { + @override + void initState() { + super.initState(); + widget.controller.addListener(_handleTick); + } + + @override + void didUpdateWidget(covariant _VideoTransportBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller == widget.controller) return; + oldWidget.controller.removeListener(_handleTick); + widget.controller.addListener(_handleTick); + } + + @override + void dispose() { + widget.controller.removeListener(_handleTick); + super.dispose(); + } + + void _handleTick() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final value = widget.controller.value; + final durationMs = value.duration.inMilliseconds; + final positionMs = value.position.inMilliseconds.clamp(0, durationMs); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + if (value.isPlaying) { + widget.controller.pause(); + } else { + widget.controller.play(); + } + }, + tooltip: value.isPlaying ? 'Pause video' : 'Play video', + icon: Icon( + value.isPlaying ? LucideIcons.pause : LucideIcons.play, + color: Colors.white, + ), + ), + SizedBox( + width: 220, + child: Slider( + value: durationMs == 0 ? 0 : positionMs.toDouble(), + min: 0, + max: durationMs == 0 ? 1 : durationMs.toDouble(), + onChanged: durationMs == 0 + ? null + : (next) => widget.controller.seekTo( + Duration(milliseconds: next.round()), + ), + ), + ), + ], + ); + } +} + +class _MediaLoadFailure extends StatelessWidget { + final String message; + final IconData icon; + + const _MediaLoadFailure({required this.message, required this.icon}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final showMessage = constraints.maxWidth >= 120; + final iconSize = constraints.biggest.shortestSide + .clamp(0.0, 36.0) + .toDouble(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white70, size: iconSize), + if (showMessage) ...[ + const SizedBox(height: Grid.xxs), + Text( + message, + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white70, + ), + textAlign: TextAlign.center, + ), + ], + ], + ); + }, + ); + } +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 9d04f2d0d9..02eb05469f 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,13 +1,20 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../shared/theme/theme.dart'; +import 'media_viewer_page.dart'; +import 'message_media.dart'; -/// Renders message content with markdown formatting, @mentions, and -/// #channel links using [GptMarkdown] plus custom inline components for -/// Sprout-specific tokens. +const _messageMediaMaxInlineWidth = 320.0; +const _messageMediaMaxImageHeight = 240.0; + +/// Renders message content with markdown formatting, @mentions, #channel links, +/// and media-aware markdown images/videos. class MessageContent extends StatelessWidget { final String content; @@ -19,6 +26,9 @@ class MessageContent extends StatelessWidget { /// names, values are channel IDs. final Map channelNames; + /// Raw event tags, used for `imeta` media metadata lookups. + final List> tags; + /// Called when a #channel link is tapped. final void Function(String channelId)? onChannelTap; @@ -29,6 +39,7 @@ class MessageContent extends StatelessWidget { required this.content, this.mentionNames = const {}, this.channelNames = const {}, + this.tags = const [], this.onChannelTap, this.baseStyle, }); @@ -38,6 +49,7 @@ class MessageContent extends StatelessWidget { final style = baseStyle ?? context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); + final imetaByUrl = parseImetaTags(tags); return GptMarkdown( content, @@ -45,6 +57,8 @@ class MessageContent extends StatelessWidget { followLinkColor: false, linkBuilder: (context, linkText, url, linkStyle) => _buildLink(context, linkText, url, linkStyle, style), + imageBuilder: (context, imageUrl) => + _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), inlineComponents: [ _MentionMd(mentionNames: mentionNames), _ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap), @@ -53,6 +67,18 @@ class MessageContent extends StatelessWidget { ); } + Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) { + final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta); + if (mediaKind == MessageMediaKind.video) { + return _MessageVideoPreview(url: imageUrl, imeta: imeta); + } + return _MessageImagePreview( + url: imageUrl, + imeta: imeta, + semanticLabel: imeta?.alt ?? 'Message image', + ); + } + // --------------------------------------------------------------------------- // Link builder (URL scheme validation) // --------------------------------------------------------------------------- @@ -93,6 +119,265 @@ class MessageContent extends StatelessWidget { } } +class _MessageImagePreview extends StatefulWidget { + final String url; + final ImetaEntry? imeta; + final String semanticLabel; + + const _MessageImagePreview({ + required this.url, + required this.imeta, + required this.semanticLabel, + }); + + @override + State<_MessageImagePreview> createState() => _MessageImagePreviewState(); +} + +class _MessageImagePreviewState extends State<_MessageImagePreview> { + late final Object _heroTag = Object(); + + @override + Widget build(BuildContext context) { + final layout = _resolveImagePreviewLayout( + context, + widget.imeta?.aspectRatio, + ); + + return Padding( + padding: const EdgeInsets.only(top: Grid.half), + child: GestureDetector( + onTap: () => openImageViewer( + context, + imageUrl: widget.url, + heroTag: _heroTag, + semanticLabel: widget.semanticLabel, + ), + child: _MessageMediaPreviewFrame( + previewKey: ValueKey('message-media-image-preview:${widget.url}'), + backgroundColor: context.colors.surfaceContainerHighest, + width: layout.width, + height: layout.height, + constraints: layout.constraints, + child: Hero( + tag: _heroTag, + child: Image.network( + widget.url, + fit: layout.fit, + semanticLabel: widget.semanticLabel, + errorBuilder: (_, _, _) => _MediaPreviewFallback( + icon: LucideIcons.imageOff, + label: 'Image unavailable', + ), + ), + ), + ), + ), + ); + } +} + +class _MessageVideoPreview extends StatelessWidget { + final String url; + final ImetaEntry? imeta; + + const _MessageVideoPreview({required this.url, required this.imeta}); + + @override + Widget build(BuildContext context) { + final rawAspectRatio = imeta?.aspectRatio ?? (16 / 9); + final aspectRatio = rawAspectRatio.clamp(0.75, 1.91); + final posterUrl = imeta?.posterUrl; + + return Padding( + padding: const EdgeInsets.only(top: Grid.half), + child: GestureDetector( + onTap: () => + openVideoViewer(context, videoUrl: url, posterUrl: posterUrl), + child: _MessageMediaPreviewFrame( + previewKey: ValueKey('message-media-video-preview:$url'), + backgroundColor: Colors.black, + child: AspectRatio( + aspectRatio: aspectRatio.toDouble(), + child: Stack( + fit: StackFit.expand, + children: [ + if (posterUrl != null) + Image.network( + posterUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const _MediaPreviewFallback( + icon: LucideIcons.video, + label: 'Video preview unavailable', + ), + ) + else + const _MediaPreviewFallback( + icon: LucideIcons.video, + label: 'Video attachment', + ), + const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.28)), + Center( + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color.fromRGBO(0, 0, 0, 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.play, + color: Colors.white, + size: 24, + ), + ), + ), + Positioned( + left: Grid.xxs, + right: Grid.xxs, + bottom: Grid.xxs, + child: Text( + 'Video', + style: context.textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _MessageMediaPreviewFrame extends StatelessWidget { + final Key previewKey; + final Color backgroundColor; + final double? width; + final double? height; + final BoxConstraints? constraints; + final Widget child; + + const _MessageMediaPreviewFrame({ + required this.previewKey, + required this.backgroundColor, + this.width, + this.height, + this.constraints, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final resolvedWidth = constraints == null + ? (width ?? _messageMediaMaxWidth(context)) + : width; + + return Container( + key: previewKey, + width: resolvedWidth, + height: height, + constraints: constraints, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all(color: context.colors.outlineVariant), + ), + child: child, + ); + } +} + +double _messageMediaMaxWidth(BuildContext context) { + return math + .min(MediaQuery.sizeOf(context).width * 0.72, _messageMediaMaxInlineWidth) + .toDouble(); +} + +_ImagePreviewLayout _resolveImagePreviewLayout( + BuildContext context, + double? aspectRatio, +) { + if (aspectRatio == null) { + return _ImagePreviewLayout( + constraints: BoxConstraints( + maxWidth: _messageMediaMaxWidth(context), + maxHeight: _messageMediaMaxImageHeight, + ), + fit: BoxFit.contain, + ); + } + + final previewSize = _imagePreviewSize(context, aspectRatio); + return _ImagePreviewLayout( + width: previewSize.width, + height: previewSize.height, + fit: BoxFit.cover, + ); +} + +Size _imagePreviewSize(BuildContext context, double? aspectRatio) { + final maxWidth = _messageMediaMaxWidth(context); + final safeAspectRatio = (aspectRatio ?? 1.0).clamp(0.2, 4.0).toDouble(); + + var width = maxWidth; + var height = width / safeAspectRatio; + if (height > _messageMediaMaxImageHeight) { + height = _messageMediaMaxImageHeight; + width = height * safeAspectRatio; + } + + return Size(width, height); +} + +class _ImagePreviewLayout { + final double? width; + final double? height; + final BoxConstraints? constraints; + final BoxFit fit; + + const _ImagePreviewLayout({ + this.width, + this.height, + this.constraints, + required this.fit, + }); +} + +class _MediaPreviewFallback extends StatelessWidget { + final IconData icon; + final String label; + + const _MediaPreviewFallback({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: context.colors.surfaceContainerHighest, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: context.colors.onSurfaceVariant), + const SizedBox(height: Grid.quarter), + Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + class _MentionMd extends InlineMd { final Map mentionNames; late final RegExp _exp = _buildPrefixPattern( diff --git a/mobile/lib/features/channels/message_media.dart b/mobile/lib/features/channels/message_media.dart new file mode 100644 index 0000000000..f46a6614e2 --- /dev/null +++ b/mobile/lib/features/channels/message_media.dart @@ -0,0 +1,114 @@ +import 'package:flutter/foundation.dart'; + +enum MessageMediaKind { image, video } + +@immutable +class ImetaEntry { + final String url; + final String? mimeType; + final String? dimensions; + final String? thumb; + final String? image; + final String? alt; + + const ImetaEntry({ + required this.url, + this.mimeType, + this.dimensions, + this.thumb, + this.image, + this.alt, + }); + + bool get isVideo => mimeType?.startsWith('video/') == true; + + String? get posterUrl => image ?? thumb; + + double? get aspectRatio { + final parts = dimensions?.split('x'); + if (parts == null || parts.length != 2) return null; + final width = double.tryParse(parts[0]); + final height = double.tryParse(parts[1]); + if (width == null || height == null || width <= 0 || height <= 0) { + return null; + } + return width / height; + } +} + +Map parseImetaTags(List> tags) { + final byUrl = {}; + for (final tag in tags) { + if (tag.isEmpty || tag.first != 'imeta') continue; + + String? url; + String? mimeType; + String? dimensions; + String? thumb; + String? image; + String? alt; + + for (final part in tag.skip(1)) { + final separator = part.indexOf(' '); + if (separator <= 0) continue; + final key = part.substring(0, separator); + final value = part.substring(separator + 1); + switch (key) { + case 'url': + url = value; + case 'm': + mimeType = value; + case 'dim': + dimensions = value; + case 'thumb': + thumb = value; + case 'image': + image = value; + case 'alt': + alt = value; + } + } + + if (url == null || url.isEmpty) continue; + byUrl[url] = ImetaEntry( + url: url, + mimeType: mimeType, + dimensions: dimensions, + thumb: thumb, + image: image, + alt: alt, + ); + } + return byUrl; +} + +MessageMediaKind? classifyMediaUrl(String url, {ImetaEntry? imeta}) { + final mimeType = imeta?.mimeType; + if (mimeType != null) { + if (mimeType == 'video/mp4') return MessageMediaKind.video; + if (mimeType.startsWith('image/')) return MessageMediaKind.image; + if (mimeType.startsWith('video/')) return null; + } + + final path = (Uri.tryParse(url)?.path ?? url).toLowerCase(); + if (path.endsWith(_mp4Extension)) { + return MessageMediaKind.video; + } + if (_imageExtensions.any(path.endsWith)) { + return MessageMediaKind.image; + } + return null; +} + +const _imageExtensions = { + '.jpg', + '.jpeg', + '.png', + '.webp', + '.bmp', + '.heic', + '.heif', + '.avif', +}; + +const _mp4Extension = '.mp4'; diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index ab28e9ab5f..0df422b571 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -25,13 +25,15 @@ class SendMessage { /// For thread replies, pass [parentEventId] and optionally [rootEventId]. /// If [rootEventId] is null it defaults to [parentEventId] (direct reply to /// thread head). Tags are built to match the desktop's `buildReplyTags` - /// convention with `root` / `reply` markers. + /// convention with `root` / `reply` markers. Pass [mediaTags] to append + /// relay-validated `imeta` tags for uploaded media. Future call({ required String channelId, required String content, String? parentEventId, String? rootEventId, List? mentionPubkeys, + List> mediaTags = const [], }) async { // Use explicitly passed pubkeys, or resolve @mentions against // channel members to avoid matching the wrong user. @@ -52,6 +54,7 @@ class SendMessage { ['h', channelId], if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId), for (final pk in normalizedMentions) ['p', pk], + ...mediaTags, ]; await _signedEventRelay.submit( diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0d2c3b0d9f..5d57220caf 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -183,15 +183,21 @@ class ThreadDetailPage extends HookConsumerWidget { hintText: 'Reply in thread\u2026', threadHeadId: threadHead.id, rootId: effectiveRootId, - onSend: (content, mentionPubkeys) => ref - .read(sendMessageProvider) - .call( - channelId: channelId, - content: content, - mentionPubkeys: mentionPubkeys, - parentEventId: threadHead.id, - rootEventId: effectiveRootId, - ), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) => ref + .read(sendMessageProvider) + .call( + channelId: channelId, + content: content, + mentionPubkeys: mentionPubkeys, + parentEventId: threadHead.id, + rootEventId: effectiveRootId, + mediaTags: mediaTags, + ), ), ], ), @@ -457,6 +463,7 @@ class _ThreadMessage extends ConsumerWidget { content: message.content, mentionNames: mentionNames, channelNames: channelNames, + tags: message.tags, onChannelTap: (_) {}, ), if (message.reactions.isNotEmpty) diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index f931f0f035..afb3125851 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -133,6 +133,7 @@ class TimelineMessage { final String pubkey; final int createdAt; final String content; + final List> tags; final bool isSystem; final bool edited; final SystemEvent? systemEvent; @@ -154,6 +155,7 @@ class TimelineMessage { required this.pubkey, required this.createdAt, required this.content, + this.tags = const [], this.isSystem = false, this.edited = false, this.systemEvent, @@ -269,6 +271,7 @@ List formatTimeline( pubkey: event.pubkey, createdAt: event.createdAt, content: event.content, + tags: event.tags, isSystem: true, systemEvent: systemEvent, ), @@ -311,6 +314,7 @@ List formatTimeline( pubkey: event.pubkey, createdAt: event.createdAt, content: edit?.content ?? event.content, + tags: event.tags, edited: edit != null, mentionPubkeys: mentions, reactions: reactions, diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 308c651413..d91cf49452 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -114,6 +114,7 @@ class ForumPostCard extends ConsumerWidget { child: MessageContent( content: preview, mentionNames: mentionNames, + tags: post.tags, ), ), ), diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index 21962471ec..4f13c73257 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -121,15 +121,21 @@ class ForumPostsView extends HookConsumerWidget { ComposeBar( channelId: channel.id, hintText: 'Write your post\u2026', - onSend: (content, mentionPubkeys) async { - await createForumPost( - ref, - channelId: channel.id, - content: content, - mentionPubkeys: mentionPubkeys, - ); - if (context.mounted) isComposing.value = false; - }, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + await createForumPost( + ref, + channelId: channel.id, + content: content, + mentionPubkeys: mentionPubkeys, + mediaTags: mediaTags, + ); + if (context.mounted) isComposing.value = false; + }, ), ], ], diff --git a/mobile/lib/features/forum/forum_provider.dart b/mobile/lib/features/forum/forum_provider.dart index 64c05cdbe0..081cb3b951 100644 --- a/mobile/lib/features/forum/forum_provider.dart +++ b/mobile/lib/features/forum/forum_provider.dart @@ -44,6 +44,7 @@ Future createForumPost( required String channelId, required String content, List mentionPubkeys = const [], + List> mediaTags = const [], }) async { final config = ref.read(relayConfigProvider); final client = ref.read(relayClientProvider); @@ -62,6 +63,7 @@ Future createForumPost( tags: [ ['h', channelId], for (final pk in normalizedMentions) ['p', pk], + ...mediaTags, ], ); ref.invalidate(forumPostsProvider(channelId)); @@ -74,6 +76,7 @@ Future createForumReply( required String parentEventId, required String content, List mentionPubkeys = const [], + List> mediaTags = const [], }) async { final config = ref.read(relayConfigProvider); final client = ref.read(relayClientProvider); @@ -93,6 +96,7 @@ Future createForumReply( ['h', channelId], ['e', parentEventId, '', 'reply'], for (final pk in normalizedMentions) ['p', pk], + ...mediaTags, ], ); ref.invalidate(forumPostsProvider(channelId)); diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 9f8f81b4bb..cadaa45f02 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -267,13 +267,19 @@ class _ThreadContent extends HookConsumerWidget { ComposeBar( channelId: channelId, hintText: 'Reply to this post\u2026', - onSend: (content, mentionPubkeys) => createForumReply( - ref, - channelId: channelId, - parentEventId: post.eventId, - content: content, - mentionPubkeys: mentionPubkeys, - ), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) => createForumReply( + ref, + channelId: channelId, + parentEventId: post.eventId, + content: content, + mentionPubkeys: mentionPubkeys, + mediaTags: mediaTags, + ), ), ], ); @@ -327,7 +333,11 @@ class _OriginalPost extends ConsumerWidget { ], ), const SizedBox(height: Grid.xxs), - MessageContent(content: post.content, mentionNames: mentionNames), + MessageContent( + content: post.content, + mentionNames: mentionNames, + tags: post.tags, + ), ], ), ); @@ -410,6 +420,7 @@ class _ReplyRow extends ConsumerWidget { child: MessageContent( content: reply.content, mentionNames: mentionNames, + tags: reply.tags, ), ), ], diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart new file mode 100644 index 0000000000..d29ad9ff71 --- /dev/null +++ b/mobile/lib/shared/relay/media_upload.dart @@ -0,0 +1,553 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; + +import 'relay_provider.dart'; + +const _mediaUploadPath = '/media/upload'; +const _mediaUploadPlatformChannelName = 'sprout/media_upload'; +const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; +const _transcodeImageToJpegMethod = 'transcodeImageToJpeg'; +const _uploadAuthKind = 24242; +const _uploadAuthLifetimeSeconds = 300; +const _heicBrands = { + 'heic', + 'heix', + 'hevc', + 'hevx', + 'heim', + 'heis', + 'mif1', + 'msf1', +}; +final _mediaUploadPlatformChannel = MethodChannel( + _mediaUploadPlatformChannelName, +); + +const _allowedImageMimeTypes = {'image/jpeg', 'image/png', 'image/webp'}; +const _unsupportedAnimatedImageMimeTypes = {'image/gif'}; +const _unsupportedGifUploadMessage = + 'GIF uploads are not supported on mobile yet'; +const _unsupportedAnimatedPngUploadMessage = + 'Animated PNG uploads are not supported on mobile yet'; +const _unsupportedAnimatedWebpUploadMessage = + 'Animated WebP uploads are not supported on mobile yet'; + +typedef PickGalleryImage = Future Function(); +typedef SanitizeImageBytes = + Future Function(Uint8List bytes, String mimeType); +typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); + +@immutable +class _PreparedUploadImage { + final Uint8List bytes; + final String mimeType; + + const _PreparedUploadImage({required this.bytes, required this.mimeType}); +} + +@immutable +class BlobDescriptor { + final String url; + final String sha256; + final int size; + final String type; + final int uploaded; + final String? dim; + final String? blurhash; + final String? thumb; + final double? duration; + final String? image; + + const BlobDescriptor({ + required this.url, + required this.sha256, + required this.size, + required this.type, + required this.uploaded, + this.dim, + this.blurhash, + this.thumb, + this.duration, + this.image, + }); + + factory BlobDescriptor.fromJson(Map json) => BlobDescriptor( + url: json['url'] as String, + sha256: json['sha256'] as String, + size: (json['size'] as num).toInt(), + type: json['type'] as String, + uploaded: (json['uploaded'] as num).toInt(), + dim: json['dim'] as String?, + blurhash: json['blurhash'] as String?, + thumb: json['thumb'] as String?, + duration: (json['duration'] as num?)?.toDouble(), + image: json['image'] as String?, + ); + + List toImetaTag() => [ + 'imeta', + 'url $url', + 'm $type', + 'x $sha256', + 'size $size', + if (dim != null) 'dim $dim', + if (blurhash != null) 'blurhash $blurhash', + if (thumb != null) 'thumb $thumb', + if (duration != null) 'duration $duration', + if (image != null) 'image $image', + ]; + + String toMarkdownImage() => + type.startsWith('video/') ? '![video]($url)' : '![image]($url)'; +} + +class MediaUploadService { + final String _baseUrl; + final String? _apiToken; + final String? _nsec; + final PickGalleryImage _pickGalleryImage; + final SanitizeImageBytes _sanitizeImageBytes; + final TranscodeImageToJpeg _transcodeImageToJpeg; + final DateTime Function() _now; + final http.Client _http; + final bool _ownsHttpClient; + + MediaUploadService({ + required String baseUrl, + required String? apiToken, + required String? nsec, + required PickGalleryImage pickGalleryImage, + SanitizeImageBytes? sanitizeImageBytes, + TranscodeImageToJpeg? transcodeImageToJpeg, + DateTime Function()? now, + http.Client? httpClient, + }) : _baseUrl = baseUrl, + _apiToken = apiToken, + _nsec = nsec, + _pickGalleryImage = pickGalleryImage, + _sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes, + _transcodeImageToJpeg = + transcodeImageToJpeg ?? _transcodePickedImageToJpeg, + _now = now ?? DateTime.now, + _http = httpClient ?? http.Client(), + _ownsHttpClient = httpClient == null; + + void dispose() { + if (_ownsHttpClient) { + _http.close(); + } + } + + Future pickAndUploadImage() async { + final pickedImage = await _pickGalleryImage(); + if (pickedImage == null) return null; + final preparedImage = await _prepareUploadImage(pickedImage); + return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType); + } + + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + }) async { + _validateUpload(bytes, mimeType); + if (!_allowedImageMimeTypes.contains(mimeType)) { + throw Exception('unsupported file type: $mimeType'); + } + + final sha256 = _sha256Hex(bytes); + final request = _buildUploadRequest( + bytes: bytes, + mimeType: mimeType, + sha256: sha256, + ); + + final streamed = await _http.send(request); + final response = await http.Response.fromStream(streamed); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception( + 'upload failed (${response.statusCode}): ${response.body}', + ); + } + + return BlobDescriptor.fromJson( + jsonDecode(response.body) as Map, + ); + } + + http.Request _buildUploadRequest({ + required Uint8List bytes, + required String mimeType, + required String sha256, + }) { + final request = http.Request( + 'PUT', + Uri.parse(_baseUrl).resolve(_mediaUploadPath), + ); + request.bodyBytes = bytes; + request.headers.addAll( + _buildUploadHeaders(mimeType: mimeType, sha256: sha256), + ); + return request; + } + + Map _buildUploadHeaders({ + required String mimeType, + required String sha256, + }) { + final headers = { + 'Authorization': _buildUploadAuthHeader(sha256), + 'Content-Type': mimeType, + 'X-SHA-256': sha256, + }; + if (_apiToken case final token? when token.isNotEmpty) { + headers['X-Auth-Token'] = token; + } + return headers; + } + + String _buildUploadAuthHeader(String sha256) { + final authEvent = _buildUploadAuthEvent(sha256); + final authJson = jsonEncode(authEvent.toJson()); + final encoded = base64Url.encode(utf8.encode(authJson)).replaceAll('=', ''); + return 'Nostr $encoded'; + } + + nostr.Event _buildUploadAuthEvent(String sha256) { + final nsec = _nsec; + if (nsec == null || nsec.isEmpty) { + throw Exception('Cannot upload media: no signing key available'); + } + + final privkeyHex = nostr.Nip19.decodePrivkey(nsec); + if (privkeyHex.isEmpty) { + throw Exception('Invalid nsec'); + } + + final expiration = + (_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds; + final tags = >[ + ['t', 'upload'], + ['x', sha256], + ['expiration', '$expiration'], + if (_extractServerAuthority(_baseUrl) case final authority?) + ['server', authority], + ]; + + return nostr.Event.from( + kind: _uploadAuthKind, + content: 'Upload sprout-media', + tags: tags, + privkey: privkeyHex, + verify: false, + ); + } + + Future<_PreparedUploadImage> _prepareUploadImage(XFile pickedImage) async { + final bytes = await pickedImage.readAsBytes(); + final detectedMimeType = _tryDetectImageMimeType(bytes); + if (detectedMimeType case final mimeType?) { + return _prepareDetectedUploadImage(bytes, mimeType); + } + + if (_shouldTranscodePickedImage(pickedImage, bytes)) { + return _prepareTranscodedUploadImage(bytes); + } + + throw Exception('unsupported file type'); + } + + Future<_PreparedUploadImage> _prepareDetectedUploadImage( + Uint8List bytes, + String mimeType, + ) async { + _validateUpload(bytes, mimeType); + final preparedBytes = await _sanitizeImageBytesIfNeeded(bytes, mimeType); + return _buildPreparedUploadImage(preparedBytes); + } + + Future<_PreparedUploadImage> _prepareTranscodedUploadImage( + Uint8List bytes, + ) async { + final transcodedBytes = await _transcodeImageToJpeg(bytes); + return _buildPreparedUploadImage(transcodedBytes); + } + + _PreparedUploadImage _buildPreparedUploadImage(Uint8List bytes) { + return _PreparedUploadImage( + bytes: bytes, + mimeType: _detectImageMimeType(bytes), + ); + } + + Future _sanitizeImageBytesIfNeeded( + Uint8List bytes, + String mimeType, + ) async { + if (!_shouldSanitizePickedImage(mimeType)) { + return bytes; + } + + final sanitizedBytes = await _sanitizeImageBytes(bytes, mimeType); + if (sanitizedBytes.isEmpty) { + throw Exception('failed to sanitize image for upload'); + } + return sanitizedBytes; + } +} + +String _sha256Hex(Uint8List bytes) { + final digest = SHA256Digest().process(bytes); + return digest.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); +} + +String? _tryDetectImageMimeType(Uint8List bytes) { + try { + return _detectImageMimeType(bytes); + } on Exception { + return null; + } +} + +void _validateUpload(Uint8List bytes, String mimeType) { + if (_unsupportedAnimatedImageMimeTypes.contains(mimeType)) { + throw Exception(_unsupportedGifUploadMessage); + } + if (mimeType == 'image/png' && _isAnimatedPng(bytes)) { + throw Exception(_unsupportedAnimatedPngUploadMessage); + } + if (mimeType == 'image/webp' && _isAnimatedWebp(bytes)) { + throw Exception(_unsupportedAnimatedWebpUploadMessage); + } +} + +String _detectImageMimeType(Uint8List bytes) { + if (_startsWith(bytes, const [0xff, 0xd8, 0xff])) { + return 'image/jpeg'; + } + if (_startsWith(bytes, const [ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ])) { + return 'image/png'; + } + if (_startsWith(bytes, ascii.encode('GIF87a')) || + _startsWith(bytes, ascii.encode('GIF89a'))) { + return 'image/gif'; + } + if (_startsWith(bytes, ascii.encode('RIFF')) && + bytes.length >= 12 && + ascii.decode(bytes.sublist(8, 12), allowInvalid: true) == 'WEBP') { + return 'image/webp'; + } + throw Exception('unsupported file type'); +} + +bool _shouldTranscodePickedImage(XFile pickedImage, Uint8List bytes) { + return _supportsNativeUploadImageProcessing() && + (_hasHeicFileExtension(pickedImage) || _looksLikeHeicOrHeif(bytes)); +} + +bool _isAnimatedPng(Uint8List bytes) { + if (!_startsWith(bytes, const [ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ])) { + return false; + } + + var offset = 8; + while (offset + 12 <= bytes.length) { + final chunkSize = _readUint32BigEndian(bytes, offset); + if (offset + 12 + chunkSize > bytes.length) { + return false; + } + + if (_matchesAscii(bytes, offset + 4, 'acTL')) { + return true; + } + + offset += 12 + chunkSize; + } + + return false; +} + +bool _isAnimatedWebp(Uint8List bytes) { + if (!_startsWith(bytes, ascii.encode('RIFF')) || + bytes.length < 12 || + ascii.decode(bytes.sublist(8, 12), allowInvalid: true) != 'WEBP') { + return false; + } + + var offset = 12; + while (offset + 8 <= bytes.length) { + final chunkSize = _readUint32LittleEndian(bytes, offset + 4); + final payloadOffset = offset + 8; + if (payloadOffset + chunkSize > bytes.length) { + return false; + } + + if (_matchesAscii(bytes, offset, 'ANIM') || + _matchesAscii(bytes, offset, 'ANMF')) { + return true; + } + if (_matchesAscii(bytes, offset, 'VP8X') && + chunkSize >= 1 && + (bytes[payloadOffset] & 0x02) != 0) { + return true; + } + + offset = payloadOffset + chunkSize + (chunkSize.isOdd ? 1 : 0); + } + + return false; +} + +bool _shouldSanitizePickedImage(String mimeType) { + return _supportsNativeUploadImageProcessing() && + (mimeType == 'image/jpeg' || mimeType == 'image/png'); +} + +bool _supportsNativeUploadImageProcessing() { + return switch (defaultTargetPlatform) { + TargetPlatform.android || TargetPlatform.iOS => true, + _ => false, + }; +} + +bool _hasHeicFileExtension(XFile pickedImage) { + for (final candidate in [pickedImage.name, pickedImage.path]) { + final normalizedCandidate = candidate.toLowerCase(); + if (normalizedCandidate.endsWith('.heic') || + normalizedCandidate.endsWith('.heif')) { + return true; + } + } + return false; +} + +bool _looksLikeHeicOrHeif(Uint8List bytes) { + if (bytes.length < 12 || !_matchesAscii(bytes, 4, 'ftyp')) { + return false; + } + + final upperBound = bytes.length < 32 ? bytes.length : 32; + for (var offset = 8; offset + 4 <= upperBound; offset += 4) { + final brand = ascii.decode( + bytes.sublist(offset, offset + 4), + allowInvalid: true, + ); + if (_heicBrands.contains(brand.toLowerCase())) { + return true; + } + } + + return false; +} + +bool _startsWith(Uint8List bytes, List prefix) { + if (bytes.length < prefix.length) return false; + for (var i = 0; i < prefix.length; i++) { + if (bytes[i] != prefix[i]) return false; + } + return true; +} + +bool _matchesAscii(Uint8List bytes, int offset, String value) { + final codeUnits = ascii.encode(value); + if (bytes.length < offset + codeUnits.length) return false; + for (var i = 0; i < codeUnits.length; i++) { + if (bytes[offset + i] != codeUnits[i]) return false; + } + return true; +} + +int _readUint32BigEndian(Uint8List bytes, int offset) { + return (bytes[offset] << 24) | + (bytes[offset + 1] << 16) | + (bytes[offset + 2] << 8) | + bytes[offset + 3]; +} + +int _readUint32LittleEndian(Uint8List bytes, int offset) { + return bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24); +} + +String? _extractServerAuthority(String baseUrl) { + final uri = Uri.parse(baseUrl); + if (uri.host.isEmpty) return null; + final host = uri.host.contains(':') ? '[${uri.host}]' : uri.host; + return uri.hasPort ? '$host:${uri.port}' : host; +} + +Future _transcodePickedImageToJpeg(Uint8List bytes) async { + return _invokeRequiredPlatformBytesMethod( + _transcodeImageToJpegMethod, + arguments: bytes, + errorMessage: 'failed to convert image for upload', + ); +} + +Future _sanitizePickedImageBytes( + Uint8List bytes, + String mimeType, +) async { + return _invokeRequiredPlatformBytesMethod( + _sanitizeImageForUploadMethod, + arguments: {'bytes': bytes, 'mimeType': mimeType}, + errorMessage: 'failed to sanitize image for upload', + ); +} + +Future _invokeRequiredPlatformBytesMethod( + String method, { + Object? arguments, + required String errorMessage, +}) async { + final result = await _mediaUploadPlatformChannel.invokeMethod( + method, + arguments, + ); + if (result == null || result.isEmpty) { + throw Exception(errorMessage); + } + return result; +} + +final mediaUploadServiceProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final picker = ImagePicker(); + final service = MediaUploadService( + baseUrl: config.baseUrl, + apiToken: config.apiToken, + nsec: config.nsec, + pickGalleryImage: () => picker.pickImage( + source: ImageSource.gallery, + requestFullMetadata: false, + ), + ); + ref.onDispose(service.dispose); + return service; +}); diff --git a/mobile/lib/shared/relay/relay.dart b/mobile/lib/shared/relay/relay.dart index 139aa984b4..a7b90dd066 100644 --- a/mobile/lib/shared/relay/relay.dart +++ b/mobile/lib/shared/relay/relay.dart @@ -1,4 +1,5 @@ export 'app_lifecycle_provider.dart'; +export 'media_upload.dart'; export 'nostr_models.dart'; export 'relay_client.dart'; export 'relay_provider.dart'; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7b5a9e265e..f3bb61645e 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -185,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -193,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" custom_lint: dependency: "direct dev" description: @@ -257,6 +273,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -294,6 +342,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_riverpod: dependency: transitive description: @@ -416,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: @@ -440,6 +504,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622" + url: "https://pub.dev" + source: hosted + version: "0.8.13+16" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" io: dependency: transitive description: @@ -1101,6 +1229,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e + url: "https://pub.dev" + source: hosted + version: "2.9.4" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + url: "https://pub.dev" + source: hosted + version: "6.6.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 211dd8f497..830af7c4af 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -22,6 +22,8 @@ dependencies: pointycastle: ^3.7.3 url_launcher: ^6.3.2 gpt_markdown: ^1.1.6 + image_picker: ^1.1.2 + video_player: ^2.10.1 dev_dependencies: flutter_test: diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 60db8b6c55..ce449ee0d7 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -40,6 +40,7 @@ NostrEvent _textMsg({ required String pubkey, required String content, int createdAt = 1000, + List> extraTags = const [], }) => NostrEvent( id: id, pubkey: pubkey, @@ -47,6 +48,7 @@ NostrEvent _textMsg({ kind: EventKind.streamMessage, tags: [ ['h', _channelId], + ...extraTags, ], content: content, sig: '', @@ -205,6 +207,45 @@ void main() { expect(find.text('Message…'), findsNothing); }); + testWidgets('renders video attachments from imeta tags in the timeline', ( + tester, + ) async { + const videoUrl = 'https://example.com/media/clip.mp4'; + + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'video-1', + pubkey: 'alice', + content: '![video]($videoUrl)', + extraTags: const [ + [ + 'imeta', + 'url https://example.com/media/clip.mp4', + 'm video/mp4', + 'image https://example.com/media/poster.jpg', + ], + ], + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey( + 'message-media-video-preview:https://example.com/media/clip.mp4', + ), + ), + findsOneWidget, + ); + }); + testWidgets('members sheet shows roles and manage controls for owners', ( tester, ) async { diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart new file mode 100644 index 0000000000..cc8ec74a4d --- /dev/null +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -0,0 +1,387 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:image_picker/image_picker.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:sprout_mobile/features/channels/channel_management_provider.dart'; +import 'package:sprout_mobile/features/channels/compose_bar.dart'; +import 'package:sprout_mobile/shared/relay/relay.dart'; +import 'package:sprout_mobile/shared/theme/theme.dart'; + +final _pngBytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, +]); + +final _gifBytes = Uint8List.fromList([ + 0x47, + 0x49, + 0x46, + 0x38, + 0x39, + 0x61, + 0x01, + 0x00, + 0x01, + 0x00, +]); + +final _apngBytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x08, + 0x61, + 0x63, + 0x54, + 0x4c, + 0x00, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4e, + 0x44, + 0x00, + 0x00, + 0x00, + 0x00, +]); + +const _mediaUploadPlatformChannel = MethodChannel('sprout/media_upload'); + +void _setMockMediaUploadPlatformHandler( + Future Function(MethodCall call)? handler, +) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_mediaUploadPlatformChannel, handler); +} + +Widget _buildComposeBar({ + required MediaUploadService uploadService, + required ComposeBarOnSend onSend, +}) { + return ProviderScope( + overrides: [ + mediaUploadServiceProvider.overrideWithValue(uploadService), + currentPubkeyProvider.overrideWith((ref) => null), + channelMembersProvider( + 'channel-1', + ).overrideWith((ref) async => const []), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + ], + child: MaterialApp( + theme: AppTheme.lightTheme, + home: Scaffold( + body: SafeArea( + child: ComposeBar(channelId: 'channel-1', onSend: onSend), + ), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + _setMockMediaUploadPlatformHandler((call) async { + switch (call.method) { + case 'sanitizeImageForUpload': + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + case 'transcodeImageToJpeg': + return _pngBytes; + default: + return null; + } + }); + }); + + tearDownAll(() { + _setMockMediaUploadPlatformHandler(null); + }); + + group('ComposeBar', () { + testWidgets('uploads an image and sends markdown plus imeta tags', ( + tester, + ) async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: 'sprout_test_token', + nsec: nsec, + httpClient: http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + 'thumb': 'https://relay.example/media/test.thumb.jpg', + }), + 200, + ); + }), + pickGalleryImage: () async => + XFile.fromData(_pngBytes, name: 'tiny.png'), + ); + + String? sentContent; + List> sentMediaTags = const []; + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + sentMediaTags = mediaTags; + }, + ), + ); + + await tester.tap(find.byIcon(LucideIcons.paperclip)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Remove attachment'), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(sentContent, '\n![image](https://relay.example/media/test.png)'); + expect(sentMediaTags, hasLength(1)); + expect(sentMediaTags.first.first, 'imeta'); + expect( + sentMediaTags.first, + contains('url https://relay.example/media/test.png'), + ); + expect(find.byTooltip('Remove attachment'), findsNothing); + }); + + testWidgets('keeps the remove button pinned to the attachment corner', ( + tester, + ) async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: 'sprout_test_token', + nsec: nsec, + httpClient: http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ); + }), + pickGalleryImage: () async => + XFile.fromData(_pngBytes, name: 'tiny.png'), + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await tester.tap(find.byIcon(LucideIcons.paperclip)); + await tester.pump(); + await tester.pumpAndSettle(); + + final attachmentFinder = find.byKey( + const ValueKey( + 'compose-attachment:https://relay.example/media/test.png', + ), + ); + final removeButtonFinder = find.byTooltip('Remove attachment'); + + expect(attachmentFinder, findsOneWidget); + expect(removeButtonFinder, findsOneWidget); + + final attachmentTopRight = tester.getTopRight(attachmentFinder); + final attachmentTopLeft = tester.getTopLeft(attachmentFinder); + final removeButtonCenter = tester.getCenter(removeButtonFinder); + + expect( + attachmentTopRight.dx - removeButtonCenter.dx, + lessThanOrEqualTo(16), + ); + expect( + removeButtonCenter.dy - attachmentTopLeft.dy, + lessThanOrEqualTo(16), + ); + }); + + testWidgets('shows an upload error when gallery upload fails', ( + tester, + ) async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: 'sprout_test_token', + nsec: nsec, + httpClient: http_testing.MockClient((request) async { + return http.Response('bad upload', 401); + }), + pickGalleryImage: () async => + XFile.fromData(_pngBytes, name: 'tiny.png'), + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await tester.tap(find.byIcon(LucideIcons.paperclip)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.textContaining('upload failed'), findsOneWidget); + }); + + testWidgets('shows a clean error when a GIF is picked', (tester) async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: 'sprout_test_token', + nsec: nsec, + pickGalleryImage: () async => + XFile.fromData(_gifBytes, name: 'animated.gif'), + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await tester.tap(find.byIcon(LucideIcons.paperclip)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + find.textContaining('GIF uploads are not supported on mobile yet'), + findsOneWidget, + ); + }); + + testWidgets('shows a clean error when an animated PNG is picked', ( + tester, + ) async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: 'sprout_test_token', + nsec: nsec, + pickGalleryImage: () async => + XFile.fromData(_apngBytes, name: 'animated.png'), + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await tester.tap(find.byIcon(LucideIcons.paperclip)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Animated PNG uploads are not supported on mobile'), + findsOneWidget, + ); + }); + }); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index d08ed3530d..efa85b7aaa 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:sprout_mobile/features/channels/message_content.dart'; +import 'package:sprout_mobile/features/channels/media_viewer_page.dart'; import 'package:sprout_mobile/shared/theme/theme.dart'; Widget _testable(Widget child) { @@ -10,6 +12,50 @@ Widget _testable(Widget child) { ); } +void _setSurfaceSize(WidgetTester tester, Size size) { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = size; +} + +Finder _imagePreview(String imageUrl) { + return find.byKey(ValueKey('message-media-image-preview:$imageUrl')); +} + +Finder _imageViewerHeroMode() { + return find.byKey(const ValueKey('message-media-image-viewer-hero-mode')); +} + +Future _openImageViewer( + WidgetTester tester, + String imageUrl, +) async { + await tester.tap(_imagePreview(imageUrl)); + await tester.pumpAndSettle(); + + final interactiveViewer = tester.widget( + find.byType(InteractiveViewer), + ); + final transformationController = interactiveViewer.transformationController; + + expect(transformationController, isNotNull); + return transformationController!; +} + +void _applyImageViewerTransform( + TransformationController controller, { + required double dx, + required double dy, + required double scale, +}) { + controller.value = Matrix4.identity() + ..translateByDouble(dx, dy, 0, 1) + ..scaleByDouble(scale, scale, scale, 1); +} + +bool _isImageViewerHeroEnabled(WidgetTester tester) { + return tester.widget(_imageViewerHeroMode()).enabled; +} + /// Extracts all plain text from all RichText widgets in the tree. String _allRichText(WidgetTester tester) { final richTexts = tester.widgetList(find.byType(RichText)); @@ -84,6 +130,20 @@ bool _spanHasStyle( void main() { group('MessageContent', () { + test('buildImageViewerRoute uses modal-style page route builder', () { + final route = buildImageViewerRoute( + imageUrl: 'https://example.com/media/image.png', + heroTag: Object(), + ); + + expect(route, isA>()); + expect(route.transitionDuration, const Duration(milliseconds: 280)); + expect( + route.reverseTransitionDuration, + const Duration(milliseconds: 220), + ); + }); + group('plain text', () { testWidgets('renders simple text', (tester) async { await tester.pumpWidget( @@ -195,6 +255,356 @@ void main() { }); }); + group('media attachments', () { + testWidgets( + 'renders image markdown as a media preview and opens viewer', + (tester) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Look\n![image](https://example.com/media/image.png)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/image.png', + 'm image/png', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final preview = find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/media/image.png', + ), + ); + expect(preview, findsOneWidget); + + await tester.tap(preview); + await tester.pumpAndSettle(); + + final viewer = tester.widget( + find.byKey(const ValueKey('message-media-image-viewer')), + ); + + expect( + find.byKey(const ValueKey('message-media-image-viewer')), + findsOneWidget, + ); + expect(viewer.backgroundColor, Colors.black); + expect(find.byType(AppBar), findsNothing); + expect( + find.byKey(const ValueKey('message-media-image-viewer-close')), + findsOneWidget, + ); + + await tester.tap( + find.byKey(const ValueKey('message-media-image-viewer-close')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('message-media-image-viewer')), + findsNothing, + ); + }, + ); + + testWidgets('uses unique hero tags for repeated identical image urls', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: ''' +![image](https://example.com/media/repeated.png) +![image](https://example.com/media/repeated.png) +''', + tags: [ + [ + 'imeta', + 'url https://example.com/media/repeated.png', + 'm image/png', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final heroes = tester.widgetList(find.byType(Hero)).toList(); + final heroTags = heroes.map((hero) => hero.tag).toSet(); + + expect(heroes, hasLength(2)); + expect(heroTags, hasLength(2)); + + await tester.tap(find.byType(Image).first); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('message-media-image-viewer')), + findsOneWidget, + ); + }); + + testWidgets( + 'disables hero on close after the fullscreen image is transformed', + (tester) async { + const imageUrl = 'https://example.com/media/transformed.png'; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: + 'Look\n![image](https://example.com/media/transformed.png)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/transformed.png', + 'm image/png', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final transformationController = await _openImageViewer( + tester, + imageUrl, + ); + + expect(_isImageViewerHeroEnabled(tester), isTrue); + + _applyImageViewerTransform( + transformationController, + dx: 24.0, + dy: 18.0, + scale: 1.5, + ); + await tester.pump(); + + await tester.tap( + find.byKey(const ValueKey('message-media-image-viewer-close')), + ); + await tester.pump(); + + expect(_isImageViewerHeroEnabled(tester), isFalse); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('message-media-image-viewer')), + findsNothing, + ); + }, + ); + + testWidgets( + 'disables hero on back navigation after the fullscreen image is transformed', + (tester) async { + const imageUrl = 'https://example.com/media/transformed-back.png'; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: + 'Look\n![image](https://example.com/media/transformed-back.png)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/transformed-back.png', + 'm image/png', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final transformationController = await _openImageViewer( + tester, + imageUrl, + ); + + _applyImageViewerTransform( + transformationController, + dx: 32.0, + dy: 20.0, + scale: 1.4, + ); + await tester.pump(); + + final popRouteFuture = tester.binding.handlePopRoute(); + await tester.pump(); + + await popRouteFuture; + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('message-media-image-viewer')), + findsNothing, + ); + }, + ); + + testWidgets('caps tall image previews to a bounded inline size', ( + tester, + ) async { + _setSurfaceSize(tester, const Size(400, 800)); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![image](https://example.com/media/tall.png)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/tall.png', + 'm image/png', + 'dim 1200x2400', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final preview = find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/media/tall.png', + ), + ); + final size = tester.getSize(preview); + + expect(size.height, closeTo(240, 0.1)); + expect(size.width, closeTo(120, 0.1)); + }); + + testWidgets( + 'keeps no-dim image previews max-bounded without fixed crop', + (tester) async { + _setSurfaceSize(tester, const Size(400, 800)); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + const previewKey = ValueKey( + 'message-media-image-preview:https://example.com/media/no-dim.png', + ); + + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![image](https://example.com/media/no-dim.png)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/no-dim.png', + 'm image/png', + ], + ], + ), + ), + ); + await tester.pump(); + + final preview = tester.widget(find.byKey(previewKey)); + final image = tester.widget( + find.descendant( + of: find.byKey(previewKey), + matching: find.byType(Image), + ), + ); + + expect(preview.constraints, isNotNull); + expect(preview.constraints!.minWidth, 0); + expect(preview.constraints!.minHeight, 0); + expect(preview.constraints!.maxWidth, closeTo(288, 0.1)); + expect(preview.constraints!.maxHeight, closeTo(240, 0.1)); + expect(image.fit, BoxFit.contain); + }, + ); + + testWidgets('renders video markdown as a video preview', (tester) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![video](https://example.com/media/clip.mp4)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/clip.mp4', + 'm video/mp4', + 'image https://example.com/media/poster.jpg', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey( + 'message-media-video-preview:https://example.com/media/clip.mp4', + ), + ), + findsOneWidget, + ); + expect(find.byIcon(LucideIcons.play), findsOneWidget); + }); + + testWidgets('treats only mp4 fallback URLs as videos', (tester) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: ''' +![mp4](https://example.com/media/clip.mp4) +![mov](https://example.com/media/clip.mov) +''', + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey( + 'message-media-video-preview:https://example.com/media/clip.mp4', + ), + ), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey( + 'message-media-video-preview:https://example.com/media/clip.mov', + ), + ), + findsNothing, + ); + expect( + find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/media/clip.mov', + ), + ), + findsOneWidget, + ); + }); + }); + group('blockquotes', () { testWidgets('renders blockquote with left border', (tester) async { await tester.pumpWidget( diff --git a/mobile/test/features/channels/message_media_test.dart b/mobile/test/features/channels/message_media_test.dart new file mode 100644 index 0000000000..14342c8f53 --- /dev/null +++ b/mobile/test/features/channels/message_media_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sprout_mobile/features/channels/message_media.dart'; + +void main() { + group('classifyMediaUrl', () { + test('treats only mp4 URLs as video fallback', () { + expect( + classifyMediaUrl('https://example.com/media/clip.mp4'), + MessageMediaKind.video, + ); + expect(classifyMediaUrl('https://example.com/media/clip.mov'), isNull); + expect(classifyMediaUrl('https://example.com/media/clip.webm'), isNull); + }); + + test('does not treat non-mp4 video mimetypes as video UI', () { + expect( + classifyMediaUrl( + 'https://example.com/media/clip.mov', + imeta: const ImetaEntry( + url: 'https://example.com/media/clip.mov', + mimeType: 'video/quicktime', + ), + ), + isNull, + ); + expect( + classifyMediaUrl( + 'https://example.com/media/clip.mp4', + imeta: const ImetaEntry( + url: 'https://example.com/media/clip.mp4', + mimeType: 'video/mp4', + ), + ), + MessageMediaKind.video, + ); + }); + }); +} diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index fc0649ab22..1228e9b9e9 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -36,6 +36,9 @@ ForumPost _makePost({ String pubkey = 'alice', String content = 'Hello forum', int createdAt = 1000, + List> tags = const [ + ['h', 'forum-channel'], + ], ForumThreadSummary? threadSummary, }) => ForumPost( eventId: eventId, @@ -44,14 +47,17 @@ ForumPost _makePost({ kind: 45001, createdAt: createdAt, channelId: _channelId, - tags: const [ - ['h', 'forum-channel'], - ], + tags: tags, threadSummary: threadSummary, ); const _aliceProfile = UserProfile(pubkey: 'alice', displayName: 'Alice'); +void _setSurfaceSize(WidgetTester tester, Size size) { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = size; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -234,6 +240,46 @@ void main() { expect(tapped, isTrue); }); + testWidgets('keeps media previews non-interactive in the post list', ( + tester, + ) async { + var tapped = false; + const imageUrl = 'https://example.com/media/card.png'; + + await tester.pumpWidget( + _buildPostCard( + post: _makePost( + content: '![image]($imageUrl)', + tags: const [ + ['h', _channelId], + [ + 'imeta', + 'url https://example.com/media/card.png', + 'm image/png', + ], + ], + ), + onTap: () => tapped = true, + ), + ); + await tester.pumpAndSettle(); + + final preview = find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/media/card.png', + ), + ); + + await tester.tapAt(tester.getCenter(preview)); + await tester.pumpAndSettle(); + + expect(tapped, isTrue); + expect( + find.byKey(const ValueKey('message-media-image-viewer')), + findsNothing, + ); + }); + testWidgets('long press opens action sheet with Copy text', (tester) async { await tester.pumpWidget(_buildPostCard(post: _makePost())); await tester.pumpAndSettle(); @@ -466,6 +512,86 @@ void main() { expect(find.text('Reply to this post\u2026'), findsOneWidget); }); + testWidgets('renders media previews for forum posts', (tester) async { + const imageUrl = 'https://example.com/media/forum.png'; + + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost( + content: '![image]($imageUrl)', + tags: const [ + ['h', _channelId], + [ + 'imeta', + 'url https://example.com/media/forum.png', + 'm image/png', + ], + ], + ), + replies: const [], + totalReplies: 0, + ), + users: const {'alice': _aliceProfile}, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/media/forum.png', + ), + ), + findsOneWidget, + ); + }); + + testWidgets('keeps tall forum image previews bounded inline', ( + tester, + ) async { + _setSurfaceSize(tester, const Size(400, 800)); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + const imageUrl = 'https://example.com/media/forum-tall.png'; + + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost( + content: '![image]($imageUrl)', + tags: const [ + ['h', _channelId], + [ + 'imeta', + 'url https://example.com/media/forum-tall.png', + 'm image/png', + 'dim 1200x2400', + ], + ], + ), + replies: const [], + totalReplies: 0, + ), + users: const {'alice': _aliceProfile}, + ), + ); + await tester.pumpAndSettle(); + + final preview = find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/media/forum-tall.png', + ), + ); + final size = tester.getSize(preview); + + expect(size.height, closeTo(240, 0.1)); + expect(size.width, closeTo(120, 0.1)); + }); + testWidgets('hides compose bar for non-members', (tester) async { await tester.pumpWidget( _buildThreadPage( diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart new file mode 100644 index 0000000000..951bb785a7 --- /dev/null +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -0,0 +1,764 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:image_picker/image_picker.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:sprout_mobile/shared/relay/media_upload.dart'; + +final _pngBytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, +]); + +final _jpegBytes = Uint8List.fromList([ + 0xff, + 0xd8, + 0xff, + 0xdb, + 0x00, + 0x43, + 0x00, + 0x01, +]); + +final _heicBytes = Uint8List.fromList([ + 0x00, + 0x00, + 0x00, + 0x18, + 0x66, + 0x74, + 0x79, + 0x70, + 0x68, + 0x65, + 0x69, + 0x63, + 0x00, + 0x00, + 0x00, + 0x00, + 0x6d, + 0x69, + 0x66, + 0x31, + 0x68, + 0x65, + 0x69, + 0x63, +]); + +final _gifBytes = Uint8List.fromList([ + 0x47, + 0x49, + 0x46, + 0x38, + 0x39, + 0x61, + 0x01, + 0x00, + 0x01, + 0x00, +]); + +final _apngBytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x08, + 0x61, + 0x63, + 0x54, + 0x4c, + 0x00, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4e, + 0x44, + 0x00, + 0x00, + 0x00, + 0x00, +]); + +final _staticPngWithActlPayloadBytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x04, + 0x49, + 0x44, + 0x41, + 0x54, + 0x61, + 0x63, + 0x54, + 0x4c, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4e, + 0x44, + 0x00, + 0x00, + 0x00, + 0x00, +]); + +final _animatedWebpBytes = Uint8List.fromList([ + 0x52, + 0x49, + 0x46, + 0x46, + 0x16, + 0x00, + 0x00, + 0x00, + 0x57, + 0x45, + 0x42, + 0x50, + 0x56, + 0x50, + 0x38, + 0x58, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, +]); + +const _mediaUploadPlatformChannel = MethodChannel('sprout/media_upload'); + +void _setMockMediaUploadPlatformHandler( + Future Function(MethodCall call)? handler, +) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_mediaUploadPlatformChannel, handler); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + _setMockMediaUploadPlatformHandler((call) async { + switch (call.method) { + case 'sanitizeImageForUpload': + final arguments = call.arguments as Map; + return arguments['bytes'] as Uint8List; + case 'transcodeImageToJpeg': + return _jpegBytes; + default: + return null; + } + }); + }); + + tearDownAll(() { + _setMockMediaUploadPlatformHandler(null); + }); + + group('MediaUploadService', () { + test('signs Blossom auth and uploads gallery image bytes', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + + http.Request? capturedRequest; + final client = http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + 'thumb': 'https://relay.example/media/test.thumb.jpg', + }), + 200, + ); + }); + + final service = MediaUploadService( + baseUrl: 'https://relay.example:8443', + apiToken: 'sprout_test_token', + nsec: nsec, + httpClient: client, + pickGalleryImage: () async => + XFile.fromData(_pngBytes, name: 'tiny.png'), + now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), + ); + + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/png'); + expect(capturedRequest, isNotNull); + expect( + capturedRequest!.url.toString(), + 'https://relay.example:8443/media/upload', + ); + expect(capturedRequest!.headers['Content-Type'], 'image/png'); + expect(capturedRequest!.headers['X-Auth-Token'], 'sprout_test_token'); + expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty); + expect(capturedRequest!.bodyBytes, _pngBytes); + + final authHeader = capturedRequest!.headers['Authorization']; + expect(authHeader, isNotNull); + expect(authHeader, startsWith('Nostr ')); + final encoded = authHeader!.substring('Nostr '.length); + final decoded = utf8.decode( + base64Url.decode(base64Url.normalize(encoded)), + ); + final authEvent = jsonDecode(decoded) as Map; + final tags = (authEvent['tags'] as List) + .map((tag) => (tag as List).cast()) + .toList(); + + expect(authEvent['kind'], 24242); + expect(authEvent['pubkey'], keychain.public); + expect(tags, anyElement(equals(['t', 'upload']))); + expect( + tags, + anyElement( + equals(['x', capturedRequest!.headers['X-SHA-256']!]), + ), + ); + expect(tags, anyElement(equals(['expiration', '1700000300']))); + expect( + tags, + anyElement(equals(['server', 'relay.example:8443'])), + ); + }); + + test('returns null when the gallery picker is cancelled', () async { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: null, + pickGalleryImage: () async => null, + ); + + final result = await service.pickAndUploadImage(); + expect(result, isNull); + }); + + test('uses a bracketed IPv6 server tag in Blossom auth', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + + http.Request? capturedRequest; + final client = http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'http://[::1]:3000/media/test.png', + 'sha256': + '2222222222222222222222222222222222222222222222222222222222222222', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ); + }); + + final service = MediaUploadService( + baseUrl: 'http://[::1]:3000', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryImage: () async => + XFile.fromData(_pngBytes, name: 'tiny.png'), + ); + + await service.pickAndUploadImage(); + + expect(capturedRequest, isNotNull); + final authHeader = capturedRequest!.headers['Authorization']; + expect(authHeader, isNotNull); + final encoded = authHeader!.substring('Nostr '.length); + final decoded = utf8.decode( + base64Url.decode(base64Url.normalize(encoded)), + ); + final authEvent = jsonDecode(decoded) as Map; + final tags = (authEvent['tags'] as List) + .map((tag) => (tag as List).cast()) + .toList(); + + expect(tags, anyElement(equals(['server', '[::1]:3000']))); + }); + + test('transcodes HEIC gallery files on iOS before upload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() { + debugDefaultTargetPlatformOverride = previousPlatform; + }); + + Uint8List? transcodedInput; + http.Request? capturedRequest; + final client = http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.jpg', + 'sha256': + 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', + 'size': _jpegBytes.length, + 'type': 'image/jpeg', + 'uploaded': 1, + }), + 200, + ); + }); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryImage: () async => + XFile.fromData(_heicBytes, name: 'photo.heic'), + transcodeImageToJpeg: (bytes) async { + transcodedInput = bytes; + return _jpegBytes; + }, + ); + + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/jpeg'); + expect(transcodedInput, _heicBytes); + expect(capturedRequest, isNotNull); + expect(capturedRequest!.headers['Content-Type'], 'image/jpeg'); + expect(capturedRequest!.bodyBytes, _jpegBytes); + }); + + test('sanitizes iOS JPEG gallery files before upload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() { + debugDefaultTargetPlatformOverride = previousPlatform; + }); + + Uint8List? sanitizedInput; + String? sanitizedMimeType; + http.Request? capturedRequest; + final client = http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.jpg', + 'sha256': + 'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', + 'size': _jpegBytes.length, + 'type': 'image/jpeg', + 'uploaded': 1, + }), + 200, + ); + }); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryImage: () async => + XFile.fromData(_jpegBytes, name: 'photo.jpg'), + sanitizeImageBytes: (bytes, mimeType) async { + sanitizedInput = bytes; + sanitizedMimeType = mimeType; + return _jpegBytes; + }, + ); + + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/jpeg'); + expect(sanitizedInput, _jpegBytes); + expect(sanitizedMimeType, 'image/jpeg'); + expect(capturedRequest, isNotNull); + expect(capturedRequest!.headers['Content-Type'], 'image/jpeg'); + expect(capturedRequest!.bodyBytes, _jpegBytes); + }); + + test('transcodes HEIC gallery files on Android before upload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() { + debugDefaultTargetPlatformOverride = previousPlatform; + }); + + Uint8List? transcodedInput; + http.Request? capturedRequest; + final client = http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.jpg', + 'sha256': + '1234512345123451234512345123451234512345123451234512345123451234', + 'size': _jpegBytes.length, + 'type': 'image/jpeg', + 'uploaded': 1, + }), + 200, + ); + }); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryImage: () async => + XFile.fromData(_heicBytes, name: 'photo.heic'), + transcodeImageToJpeg: (bytes) async { + transcodedInput = bytes; + return _jpegBytes; + }, + ); + + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/jpeg'); + expect(transcodedInput, _heicBytes); + expect(capturedRequest, isNotNull); + expect(capturedRequest!.headers['Content-Type'], 'image/jpeg'); + expect(capturedRequest!.bodyBytes, _jpegBytes); + }); + + test('sanitizes Android JPEG gallery files before upload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() { + debugDefaultTargetPlatformOverride = previousPlatform; + }); + + Uint8List? sanitizedInput; + String? sanitizedMimeType; + http.Request? capturedRequest; + final client = http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.jpg', + 'sha256': + 'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', + 'size': _jpegBytes.length, + 'type': 'image/jpeg', + 'uploaded': 1, + }), + 200, + ); + }); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryImage: () async => + XFile.fromData(_jpegBytes, name: 'photo.jpg'), + sanitizeImageBytes: (bytes, mimeType) async { + sanitizedInput = bytes; + sanitizedMimeType = mimeType; + return _jpegBytes; + }, + ); + + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/jpeg'); + expect(sanitizedInput, _jpegBytes); + expect(sanitizedMimeType, 'image/jpeg'); + expect(capturedRequest, isNotNull); + expect(capturedRequest!.headers['Content-Type'], 'image/jpeg'); + expect(capturedRequest!.bodyBytes, _jpegBytes); + }); + + test('sanitizes Android PNG gallery files before upload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() { + debugDefaultTargetPlatformOverride = previousPlatform; + }); + + Uint8List? sanitizedInput; + String? sanitizedMimeType; + http.Request? capturedRequest; + final client = http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.png', + 'sha256': + '9999999999999999999999999999999999999999999999999999999999999999', + 'size': _pngBytes.length, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ); + }); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryImage: () async => + XFile.fromData(_pngBytes, name: 'photo.png'), + sanitizeImageBytes: (bytes, mimeType) async { + sanitizedInput = bytes; + sanitizedMimeType = mimeType; + return _pngBytes; + }, + ); + + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/png'); + expect(sanitizedInput, _pngBytes); + expect(sanitizedMimeType, 'image/png'); + expect(capturedRequest, isNotNull); + expect(capturedRequest!.headers['Content-Type'], 'image/png'); + expect(capturedRequest!.bodyBytes, _pngBytes); + }); + + test('rejects GIF gallery files before upload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + pickGalleryImage: () async => + XFile.fromData(_gifBytes, name: 'animated.gif'), + ); + + expect( + service.pickAndUploadImage(), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('GIF uploads are not supported on mobile yet'), + ), + ), + ); + }); + + test('rejects animated PNG gallery files before upload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: http_testing.MockClient( + (request) async => http.Response('{}', 200), + ), + pickGalleryImage: () async => + XFile.fromData(_apngBytes, name: 'animated.png'), + ); + + expect( + service.pickAndUploadImage(), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('Animated PNG uploads are not supported on mobile yet'), + ), + ), + ); + }); + + test('uploads static PNG when acTL appears only in chunk payload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + + http.Request? capturedRequest; + final client = http_testing.MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/static.png', + 'sha256': + '1111111111111111111111111111111111111111111111111111111111111111', + 'size': _staticPngWithActlPayloadBytes.length, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ); + }); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryImage: () async => + XFile.fromData(_staticPngWithActlPayloadBytes, name: 'static.png'), + ); + + final descriptor = await service.pickAndUploadImage(); + + expect(descriptor, isNotNull); + expect(descriptor!.type, 'image/png'); + expect(capturedRequest, isNotNull); + expect(capturedRequest!.headers['Content-Type'], 'image/png'); + expect(capturedRequest!.bodyBytes, _staticPngWithActlPayloadBytes); + }); + + test('rejects animated WebP gallery files before upload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: http_testing.MockClient( + (request) async => http.Response('{}', 200), + ), + pickGalleryImage: () async => + XFile.fromData(_animatedWebpBytes, name: 'animated.webp'), + ); + + expect( + service.pickAndUploadImage(), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('Animated WebP uploads are not supported on mobile yet'), + ), + ), + ); + }); + + test('rejects unsupported gallery files before upload', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + pickGalleryImage: () async => XFile.fromData( + Uint8List.fromList(utf8.encode('not an image')), + name: 'note.txt', + ), + ); + + expect( + service.pickAndUploadImage(), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('unsupported file type'), + ), + ), + ); + }); + }); +}