diff --git a/docs/session-logs/2026-09-14_share-image.md b/docs/session-logs/2026-09-14_share-image.md index 3531810..463e581 100644 --- a/docs/session-logs/2026-09-14_share-image.md +++ b/docs/session-logs/2026-09-14_share-image.md @@ -19,9 +19,21 @@ base64 (Rust). Canvas в WebView доступен. `invoke("export_image")` (нативной команды пока нет — на десктопе тост-ошибка, это ок). - Кнопка `#sd-share` «Похвастаться» в детали трека. CSS `.share-modal` (шахматка под прозрачность). +## Сделано — Стадия 2 (нативный вывод + подложка) +- Rust `export_image(filename, base64, action)` (пишет base64 + команду `exportimg:` в мост) и + `read_file_base64(path)` (для чтения выбранного фото); зарегистрированы. +- Kotlin: ветка `exportimg:` — Base64-декод; `save` → `saveImageLauncher` (CreateDocument + image/png, байты), `share` → запись в `cacheDir/shared`, FileProvider URI, `ACTION_SEND` + image/png через чузер. +- Настройки: строка «Фон для картинки» (Выбрать/Сброс): `pickLocalFile("image/*")` → + `read_file_base64` → даунскейл ≤1080 → `localStorage.baldav_share_bg` (jpeg 0.85). + ## Проверка -- Синтаксис main.js OK. Рендер модалки на десктопе (Xvfb) — следующий шаг. +- Стадия 1 рендер — ПРОВЕРЕНО на десктопе (Xvfb): модалка рисует форму трека + Distance/Time/ + Speed + ↑набор/ккал/дата, прозрачный PNG (шахматка), переключатель/кнопки. Скрин. +- lib.rs пересобрался в `tauri dev` без ошибок (команды компилируются/регистрируются). +- Нативный save/share/чтение фото и Kotlin — ТОЛЬКО на теле после `./build.sh android -d -i`. ## Дальше -- Стадия 2: Rust `export_image`/`read_file_base64`, Kotlin `exportimg:` (SAF-сейв PNG + - ACTION_SEND), выбор подложки в настройках; пересборка APK; тест на теле. +- Пересборка APK и тест на теле: «Поделиться» (чузер), «Сохранить» (SAF), выбор подложки → + вариант «С фото». По явной просьбе. diff --git a/frontend/js/main.js b/frontend/js/main.js index 21bbf0a..998226a 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -6542,6 +6542,16 @@ async function renderSettings(container) { +
+
+
Фон для картинки «Похвастаться»
+
Фото-подложка под форму трека и статы
+
+
+ + +
+
Очистить продукты без магазина
@@ -6913,6 +6923,40 @@ async function renderSettings(container) { } }; + // Share-image background: pick a photo, downscale, store as a data URL in localStorage. + let sbgBtn = container.querySelector("#s-share-bg"); + if (sbgBtn) sbgBtn.onclick = async () => { + let prev = sbgBtn.textContent, path; + if (/Android/i.test(navigator.userAgent)) { + sbgBtn.disabled = true; sbgBtn.textContent = "Выбор..."; + try { path = await pickLocalFile("image/*"); } catch (e) { showToast("Ошибка выбора: " + (e.message || e)); } + sbgBtn.disabled = false; sbgBtn.textContent = prev; + } else { + path = await appPrompt("Путь к фото-подложке:", ""); + } + if (!path) return; + sbgBtn.disabled = true; sbgBtn.textContent = "Загрузка..."; + try { + let b64 = await window.__TAURI__.core.invoke("read_file_base64", { path }); + let mime = /\.png$/i.test(path) ? "image/png" : "image/jpeg"; + await new Promise((res, rej) => { + let img = new Image(); + img.onload = () => { + let s = Math.min(1, 1080 / img.width); + let c = document.createElement("canvas"); c.width = Math.round(img.width * s); c.height = Math.round(img.height * s); + c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); + try { localStorage.setItem("baldav_share_bg", c.toDataURL("image/jpeg", 0.85)); res(); } catch (e) { rej(e); } + }; + img.onerror = () => rej(new Error("не удалось прочитать изображение")); + img.src = "data:" + mime + ";base64," + b64; + }); + showToast("Подложка сохранена"); + } catch (e) { showToast("Подложка: " + (e.message || e)); } + sbgBtn.disabled = false; sbgBtn.textContent = prev; + }; + let sbgClear = container.querySelector("#s-share-bg-clear"); + if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); }; + // Download tiles from router — the list comes from the router's directory // listing, so a newly uploaded .db shows up without rebuilding the app. container.querySelector("#s-download-tiles").onclick = async () => { diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index 21b769b..f69328f 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -24,6 +24,7 @@ import javax.net.ssl.HttpsURLConnection class MainActivity : TauriActivity() { private var commandObserver: FileObserver? = null private var pendingSaveContent: String? = null + private var pendingImageBytes: ByteArray? = null private var backWebView: WebView? = null // Disable WryActivity's generated back handler: its mWebView.goBack() does NOT @@ -66,6 +67,33 @@ class MainActivity : TauriActivity() { pendingSaveContent = null } + // SAF "create document" for the share PNG (bytes, image/png mime). + private val saveImageLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("image/png") + ) { uri: Uri? -> + if (uri != null && pendingImageBytes != null) { + try { contentResolver.openOutputStream(uri)?.use { it.write(pendingImageBytes!!) } } + catch (e: Exception) { Log.e("BalDav", "Failed to save image", e) } + } + pendingImageBytes = null + } + + // Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND). + private fun shareImage(bytes: ByteArray, filename: String) { + try { + val dir = File(cacheDir, "shared"); dir.mkdirs() + val f = File(dir, if (filename.isBlank()) "baldav.png" else filename) + f.writeBytes(bytes) + val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.fileprovider", f) + val send = Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(Intent.createChooser(send, "Поделиться").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + } catch (e: Exception) { Log.e("BalDav", "share image failed", e) } + } + // SAF "open document" picker. A content:// URI has no filesystem path, so the // picked file is copied into the app dir and its path is written to // open_file_result.txt, which the JS side polls via the Rust get_picked_file. @@ -472,6 +500,21 @@ class MainActivity : TauriActivity() { Log.d("BalDav", "Opening file picker for mime=$mime") openFileLauncher.launch(arrayOf(mime)) } + cmd.startsWith("exportimg:") -> { + // exportimg:: — decode export_image.b64, then SAF-save or share. + val rest = cmd.substringAfter("exportimg:") + val action = rest.substringBefore(":") + val filename = rest.substringAfter(":") + val b64File = File(appDataDir, "export_image.b64") + if (b64File.exists()) { + val bytes = try { android.util.Base64.decode(b64File.readText().trim(), android.util.Base64.DEFAULT) } catch (e: Exception) { null } + b64File.delete() + if (bytes != null) { + if (action == "save") { pendingImageBytes = bytes; saveImageLauncher.launch(filename) } + else shareImage(bytes, filename) + } + } + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8aa0ab3..c2602a5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -533,6 +533,30 @@ fn save_file(app: tauri::AppHandle, filename: String, content: String) -> Result Ok("save_requested".to_string()) } +// Export a PNG (base64) via the file bridge → Kotlin decodes + SAF-saves ("save") or shares it +// via a chooser ("share"). Mirrors save_file but carries binary as base64. +#[tauri::command] +fn export_image(app: tauri::AppHandle, filename: String, base64: String, action: String) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + std::fs::create_dir_all(&data_dir).ok(); + let root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()); + let write_both = |name: &str, content: &str| { + let _ = std::fs::write(data_dir.join(name), content); + if let Some(r) = &root { let _ = std::fs::write(r.join(name), content); } + }; + write_both("export_image.b64", &base64); + write_both("gps_command.txt", &format!("exportimg:{}:{}", action, filename)); + Ok("export_requested".to_string()) +} + +// Read any file and return its bytes base64-encoded (for loading a picked image into the WebView). +#[tauri::command] +fn read_file_base64(path: String) -> Result { + use base64::Engine; + let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + // Read tiles from a SQLite file in batches for import into IndexedDB #[tauri::command] fn import_tiles(path: String, offset: u32, batch_size: u32) -> Result { @@ -997,7 +1021,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }