Share image Stage 2: native save/share + settings background picker

Rust export_image(filename,base64,action) + read_file_base64(path) (registered).
Kotlin exportimg: branch — base64-decode, then SAF-save the PNG (CreateDocument
image/png) or share it via FileProvider + ACTION_SEND chooser. Settings gets a
'Фон для картинки' row: pick a photo (pickLocalFile image/*), read it as base64,
downscale to <=1080 and store as a data URL in localStorage for the 'С фото'
variant. Rust compiles in tauri dev; native paths need an APK rebuild to test.
This commit is contained in:
4aiman 2026-09-14 10:38:29 +03:00
commit 5fe30399ab
4 changed files with 127 additions and 4 deletions

View file

@ -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), выбор подложки →
вариант «С фото». По явной просьбе.

View file

@ -6542,6 +6542,16 @@ async function renderSettings(container) {
</div>
<button class="btn-secondary" id="s-import-tiles" style="padding:6px 12px;font-size:13px;">Файл</button>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Фон для картинки «Похвастаться»</div>
<div class="settings-item-desc">Фото-подложка под форму трека и статы</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn-secondary" id="s-share-bg" style="padding:6px 12px;font-size:13px;">Выбрать</button>
<button class="btn-secondary" id="s-share-bg-clear" style="padding:6px 12px;font-size:13px;color:#cf6679">Сброс</button>
</div>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Очистить продукты без магазина</div>
@ -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 () => {

View file

@ -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:<save|share>:<filename> — 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)
}
}
}
}
}

View file

@ -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<String, String> {
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<String, String> {
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<String, String> {
@ -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");
}