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:
parent
69f6fdac18
commit
5fe30399ab
4 changed files with 127 additions and 4 deletions
|
|
@ -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), выбор подложки →
|
||||
вариант «С фото». По явной просьбе.
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue