tauri-plugin-android-fs 28.2.1

Android file system API for Tauri.
Documentation
use crate::*;
use super::*;
use tauri::{http, Manager as _};


pub const URI_SCHEME: &'static str = "android-fs-thumbnail";

pub fn protocol<R: tauri::Runtime>(
    ctx: tauri::UriSchemeContext<'_, R>, 
    request: tauri::http::Request<Vec<u8>>, 
    responder: tauri::UriSchemeResponder
) {
    
    let app = ctx.app_handle().clone();

    tauri::async_runtime::spawn(async move {
        responder.respond(match create_response(app, request).await {
            Ok(ProtocolResponse::Ok { body, content_type, content_len }) => http::Response::builder()
                .status(http::StatusCode::OK)
			    .header(http::header::CONTENT_TYPE, content_type.as_ref())
                .header(http::header::CONTENT_LENGTH, content_len)
                .body(body)
                .unwrap_or_default(),

            Err(ProtocolError::MethodNotAllowed { allow }) => http::Response::builder()
                .status(http::StatusCode::METHOD_NOT_ALLOWED)
                .header(http::header::ALLOW, allow)
                .header(http::header::CONTENT_LENGTH, 0)
                .body(Vec::new())
                .unwrap_or_default(),

            Err(ProtocolError::BadRequest { msg }) => http::Response::builder()
                .status(http::StatusCode::BAD_REQUEST)
                .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
			    .header(http::header::CONTENT_LENGTH, msg.len())
                .body(msg.to_string().into_bytes())
                .unwrap_or_default(),

            Err(ProtocolError::InternalServerError { msg }) => http::Response::builder()
                .status(http::StatusCode::INTERNAL_SERVER_ERROR)
                .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
			    .header(http::header::CONTENT_LENGTH, msg.len())
                .body(msg.to_string().into_bytes())
                .unwrap_or_default(),

            Err(ProtocolError::Forbidden) => http::Response::builder()
                .status(http::StatusCode::FORBIDDEN)
                .header(http::header::CONTENT_LENGTH, 0)
                .body(Vec::new())
                .unwrap_or_default(),

            Err(ProtocolError::NotFound) => http::Response::builder()
                .status(http::StatusCode::NOT_FOUND)
                .header(http::header::CONTENT_LENGTH, 0)
                .body(Vec::new())
                .unwrap_or_default(),
        });
    });
}


enum ProtocolResponse {
	Ok {
		body: Vec<u8>,
		content_type: std::borrow::Cow<'static, str>,
        content_len: u64,
	},
}

enum ProtocolError {
    MethodNotAllowed {
	    allow: String,
	},
    BadRequest {
        msg: std::borrow::Cow<'static, str>,
    },
    InternalServerError {
        msg: std::borrow::Cow<'static, str>,
    },
    Forbidden,
    NotFound,
}

async fn create_response<R: tauri::Runtime>(
    app: tauri::AppHandle<R>,
    request: http::Request<Vec<u8>>,
) -> std::result::Result<ProtocolResponse, ProtocolError> {

    let Some(config): Option<ProtocolConfigState> = app.try_state() else {
        return Err(ProtocolError::InternalServerError { 
            msg: "Missing protocol config state".into()
        })
    };

    let config = &config.thumbnail;

    if !config.enable {
        return Err(ProtocolError::Forbidden)
    }

    let Some(uri) = percent_encoding::percent_decode_str(request.uri().path().trim_start_matches('/'))
        .decode_utf8().ok()
        .and_then(|s| serde_json::from_str::<AfsUriOrFsPath>(&s).ok())
        .and_then(|s| s.try_into_content_or_safe_file_scheme_uri().ok()) else {

        return Err(ProtocolError::BadRequest {
            msg: "Bad URI format".into()
        })
    };
    
    if let Some(path) = uri.to_path() {
        if !config.scope.as_ref().is_some_and(|s| s.is_allowed(path)) {
            return Err(ProtocolError::Forbidden)
        }
    }

    let is_head_method = match request.method() {
        &http::Method::GET => false,
        &http::Method::HEAD => true,
        _ => return Err(ProtocolError::MethodNotAllowed { 
            allow: resolve_allow_header([http::Method::GET, http::Method::HEAD]) 
        })
    };

    let query = request.uri()
        .query()
        .unwrap_or("")
        .split('&')
        .filter_map(|v| v.split_once('='))
        .collect::<std::collections::HashMap<&str, &str>>();

    let width = query
        .get("w")
        .and_then(|s| s.parse().ok())
        .map(|n| f64::ceil(n))
        .and_then(|n| f64_to_u32_for_size(n))
        .map(|n| u32::min(n, 1024));
        
    let height = query
        .get("h")
        .and_then(|s| s.parse().ok())
        .map(|n| f64::ceil(n))
        .and_then(|n| f64_to_u32_for_size(n))
        .map(|n| u32::min(n, 1024));

    let (width, height) = match (width, height) {
        (Some(width), Some(height)) => (width, height),
        (Some(width), None) => (width, width),
        (None, Some(height)) => (height, height),
        (None, None) => (256, 256)
    };

    let format = query
        .get("f")
        .and_then(|s| ImageFormat::from_name(&s))
        .unwrap_or(ImageFormat::Webp);

    let Some(thumbnail) = app
        .android_fs_async()
        .impls()
        .get_file_thumbnail(&uri, Size { width, height }, format).await
        .map_err(|_| ProtocolError::NotFound)? else {

        return Err(ProtocolError::NotFound)
    };

    Ok(ProtocolResponse::Ok { 
        content_type: format.mime_type().into(), 
        content_len: thumbnail.len() as u64, 
        body: match is_head_method {
            true => Vec::new(),
            false => thumbnail
        }, 
    })
}

fn f64_to_u32_for_size(v: f64) -> Option<u32> {
    if v.is_finite() && 0.0 <= v && v <= u32::MAX as f64 {
        Some(v as u32)
    } 
    else {
        None
    }
}