Description
@FeodorFitsner Thank you for the update to version 0.28.3.
I'm re-opening the issue regarding FilePicker.save_file() on Android. While the previous issue of the save dialog not appearing (in v0.28.2) seems to be resolved, there's a new behavior in v0.28.3.
Current Behavior in Flet v0.28.3 on Android:
When using FilePicker.save_file() in an APK built with flet build apk:
The native Android file save dialog now appears correctly.
I can choose a location and filename.
However, the file is saved as a 0KB file (empty).
Additionally, I encounter the following error when trying to save the file (as seen in an error message within the app, and potentially in logs):
Could not save graph: [Errno 2] No such file or directory: '/document/primary:Podcasts/drag_curve_comparison.png' (The path might vary depending on where the user tries to save).
This indicates that while the dialog interaction is happening, the actual file writing process is failing or not receiving the correct data/permissions.
Updated Reproduction Code:
I've prepared a more comprehensive example that generates a matplotlib graph and attempts to save it. This code demonstrates the 0KB file issue on Android when built as an APK.
2025-05-23.004003.mp4
import flet as ft
import matplotlib
matplotlib.use('Agg') # For non-GUI environments
from pandas import DataFrame
from matplotlib.pyplot import subplots, legend, close as plt_close
import base64
import io
# Sample Drag Tables
TableG1 = [
{'Mach': 0.00, 'CD': 0.2629}, {'Mach': 0.05, 'CD': 0.2558}, {'Mach': 0.10, 'CD': 0.2487},
{'Mach': 0.15, 'CD': 0.2413}, {'Mach': 0.20, 'CD': 0.2344}, {'Mach': 0.25, 'CD': 0.2278},
{'Mach': 0.30, 'CD': 0.2214}, {'Mach': 0.35, 'CD': 0.2155}, {'Mach': 0.40, 'CD': 0.2104},
{'Mach': 0.45, 'CD': 0.2061}, {'Mach': 0.50, 'CD': 0.2032}, {'Mach': 0.55, 'CD': 0.2020},
{'Mach': 0.60, 'CD': 0.2034}, {'Mach': 0.70, 'CD': 0.2165}, {'Mach': 0.725, 'CD': 0.2230},
{'Mach': 0.75, 'CD': 0.2313}, {'Mach': 0.775, 'CD': 0.2417}, {'Mach': 0.80, 'CD': 0.2546},
{'Mach': 0.825, 'CD': 0.2706}, {'Mach': 0.85, 'CD': 0.2901}, {'Mach': 0.875, 'CD': 0.3136},
{'Mach': 0.90, 'CD': 0.3415}, {'Mach': 0.925, 'CD': 0.3734}, {'Mach': 0.95, 'CD': 0.4084},
{'Mach': 0.975, 'CD': 0.4448}, {'Mach': 1.0, 'CD': 0.4805}, {'Mach': 1.025, 'CD': 0.5136},
{'Mach': 1.05, 'CD': 0.5427}, {'Mach': 1.075, 'CD': 0.5677}, {'Mach': 1.10, 'CD': 0.5883},
{'Mach': 1.125, 'CD': 0.6053}, {'Mach': 1.15, 'CD': 0.6191}, {'Mach': 1.20, 'CD': 0.6393},
{'Mach': 1.25, 'CD': 0.6518}, {'Mach': 1.30, 'CD': 0.6589}, {'Mach': 1.35, 'CD': 0.6621},
{'Mach': 1.40, 'CD': 0.6625}, {'Mach': 1.45, 'CD': 0.6607}, {'Mach': 1.50, 'CD': 0.6573},
{'Mach': 1.55, 'CD': 0.6528}, {'Mach': 1.60, 'CD': 0.6474}, {'Mach': 1.65, 'CD': 0.6413},
{'Mach': 1.70, 'CD': 0.6347}, {'Mach': 1.75, 'CD': 0.6280}, {'Mach': 1.80, 'CD': 0.6210},
{'Mach': 1.85, 'CD': 0.6141}, {'Mach': 1.90, 'CD': 0.6072}, {'Mach': 1.95, 'CD': 0.6003},
{'Mach': 2.00, 'CD': 0.5934}, {'Mach': 2.05, 'CD': 0.5867}, {'Mach': 2.10, 'CD': 0.5804},
{'Mach': 2.15, 'CD': 0.5743}, {'Mach': 2.20, 'CD': 0.5685}, {'Mach': 2.25, 'CD': 0.5630},
{'Mach': 2.30, 'CD': 0.5577}, {'Mach': 2.35, 'CD': 0.5527}, {'Mach': 2.40, 'CD': 0.5481},
{'Mach': 2.45, 'CD': 0.5438}, {'Mach': 2.50, 'CD': 0.5397}, {'Mach': 2.60, 'CD': 0.5325},
{'Mach': 2.70, 'CD': 0.5264}, {'Mach': 2.80, 'CD': 0.5211}, {'Mach': 2.90, 'CD': 0.5168},
{'Mach': 3.00, 'CD': 0.5133}, {'Mach': 3.10, 'CD': 0.5105}, {'Mach': 3.20, 'CD': 0.5084},
{'Mach': 3.30, 'CD': 0.5067}, {'Mach': 3.40, 'CD': 0.5054}, {'Mach': 3.50, 'CD': 0.5040},
{'Mach': 3.60, 'CD': 0.5030}, {'Mach': 3.70, 'CD': 0.5022}, {'Mach': 3.80, 'CD': 0.5016},
{'Mach': 3.90, 'CD': 0.5010}, {'Mach': 4.00, 'CD': 0.5006}, {'Mach': 4.20, 'CD': 0.4998},
{'Mach': 4.40, 'CD': 0.4995}, {'Mach': 4.60, 'CD': 0.4992}, {'Mach': 4.80, 'CD': 0.4990},
{'Mach': 5.00, 'CD': 0.4988}
]
TableG7 = [
{'Mach': 0.00, 'CD': 0.1198}, {'Mach': 0.05, 'CD': 0.1197}, {'Mach': 0.10, 'CD': 0.1196},
{'Mach': 0.15, 'CD': 0.1194}, {'Mach': 0.20, 'CD': 0.1193}, {'Mach': 0.25, 'CD': 0.1194},
{'Mach': 0.30, 'CD': 0.1194}, {'Mach': 0.35, 'CD': 0.1194}, {'Mach': 0.40, 'CD': 0.1193},
{'Mach': 0.45, 'CD': 0.1193}, {'Mach': 0.50, 'CD': 0.1194}, {'Mach': 0.55, 'CD': 0.1193},
{'Mach': 0.60, 'CD': 0.1194}, {'Mach': 0.65, 'CD': 0.1197}, {'Mach': 0.70, 'CD': 0.1202},
{'Mach': 0.725, 'CD': 0.1207}, {'Mach': 0.75, 'CD': 0.1215}, {'Mach': 0.775, 'CD': 0.1226},
{'Mach': 0.80, 'CD': 0.1242}, {'Mach': 0.825, 'CD': 0.1266}, {'Mach': 0.85, 'CD': 0.1306},
{'Mach': 0.875, 'CD': 0.1368}, {'Mach': 0.90, 'CD': 0.1464}, {'Mach': 0.925, 'CD': 0.1660},
{'Mach': 0.95, 'CD': 0.2054}, {'Mach': 0.975, 'CD': 0.2993}, {'Mach': 1.0, 'CD': 0.3803},
{'Mach': 1.025, 'CD': 0.4015}, {'Mach': 1.05, 'CD': 0.4043}, {'Mach': 1.075, 'CD': 0.4034},
{'Mach': 1.10, 'CD': 0.4014}, {'Mach': 1.125, 'CD': 0.3987}, {'Mach': 1.15, 'CD': 0.3955},
{'Mach': 1.20, 'CD': 0.3884}, {'Mach': 1.25, 'CD': 0.3810}, {'Mach': 1.30, 'CD': 0.3732},
{'Mach': 1.35, 'CD': 0.3657}, {'Mach': 1.40, 'CD': 0.3580}, {'Mach': 1.50, 'CD': 0.3440},
{'Mach': 1.55, 'CD': 0.3376}, {'Mach': 1.60, 'CD': 0.3315}, {'Mach': 1.65, 'CD': 0.3260},
{'Mach': 1.70, 'CD': 0.3209}, {'Mach': 1.75, 'CD': 0.3160}, {'Mach': 1.80, 'CD': 0.3117},
{'Mach': 1.85, 'CD': 0.3078}, {'Mach': 1.90, 'CD': 0.3042}, {'Mach': 1.95, 'CD': 0.3010},
{'Mach': 2.00, 'CD': 0.2980}, {'Mach': 2.05, 'CD': 0.2951}, {'Mach': 2.10, 'CD': 0.2922},
{'Mach': 2.15, 'CD': 0.2892}, {'Mach': 2.20, 'CD': 0.2864}, {'Mach': 2.25, 'CD': 0.2835},
{'Mach': 2.30, 'CD': 0.2807}, {'Mach': 2.35, 'CD': 0.2779}, {'Mach': 2.40, 'CD': 0.2752},
{'Mach': 2.45, 'CD': 0.2725}, {'Mach': 2.50, 'CD': 0.2697}, {'Mach': 2.55, 'CD': 0.2670},
{'Mach': 2.60, 'CD': 0.2643}, {'Mach': 2.65, 'CD': 0.2615}, {'Mach': 2.70, 'CD': 0.2588},
{'Mach': 2.75, 'CD': 0.2561}, {'Mach': 2.80, 'CD': 0.2533}, {'Mach': 2.85, 'CD': 0.2506},
{'Mach': 2.90, 'CD': 0.2479}, {'Mach': 2.95, 'CD': 0.2451}, {'Mach': 3.00, 'CD': 0.2424},
{'Mach': 3.10, 'CD': 0.2368}, {'Mach': 3.20, 'CD': 0.2313}, {'Mach': 3.30, 'CD': 0.2258},
{'Mach': 3.40, 'CD': 0.2205}, {'Mach': 3.50, 'CD': 0.2154}, {'Mach': 3.60, 'CD': 0.2106},
{'Mach': 3.70, 'CD': 0.2060}, {'Mach': 3.80, 'CD': 0.2017}, {'Mach': 3.90, 'CD': 0.1975},
{'Mach': 4.00, 'CD': 0.1935}, {'Mach': 4.20, 'CD': 0.1861}, {'Mach': 4.40, 'CD': 0.1793},
{'Mach': 4.60, 'CD': 0.1730}, {'Mach': 4.80, 'CD': 0.1672}, {'Mach': 5.00, 'CD': 0.1618},
]
drag_tables = {
"G1": TableG1,
"G7": TableG7,
}
def get_drag_tables_names():
return list(drag_tables.keys())
def main(page: ft.Page):
page.title = "Drag Curve Comparison"
page.vertical_alignment = ft.MainAxisAlignment.START
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
page.padding = 20
page.bgcolor = ft.Colors.GREY_900
page.theme_mode = ft.ThemeMode.DARK
accent_color = ft.Colors.BLUE_ACCENT_700
bg_color = ft.Colors.GREY_900
# --- FilePicker for saving the graph ---
def _save_graph_result(e: ft.FilePickerResultEvent, current_page: ft.Page):
def show_snackbar(message: str):
snack = ft.SnackBar(ft.Text(message), open=True)
if hasattr(current_page, 'overlay') and isinstance(current_page.overlay, list):
current_page.overlay.append(snack)
else:
print(f"Warning: Cannot show SnackBar, page.overlay is not a list or page is invalid. Message: {message}")
current_page.update()
if e.path:
if hasattr(current_page, 'current_drag_curve_graph_bytes') and current_page.current_drag_curve_graph_bytes:
try:
with open(e.path, "wb") as f:
f.write(current_page.current_drag_curve_graph_bytes)
show_snackbar(f"Graph saved to: {e.path}")
except Exception as ex:
show_snackbar(f"Could not save graph: {ex}")
print(f"Error saving graph: {ex}") # This is where the error from the image likely originates
else:
show_snackbar("No graph data to save.")
else:
show_snackbar("Graph save cancelled.")
# current_page.update()
save_graph_dialog = ft.FilePicker(on_result=lambda e: _save_graph_result(e, page))
if save_graph_dialog not in page.overlay: # Ensure dialog is added only once
page.overlay.append(save_graph_dialog)
page.current_drag_curve_graph_bytes = None # Initialize on the page
def on_save_graph_click(e):
if hasattr(page, 'current_drag_curve_graph_bytes') and page.current_drag_curve_graph_bytes:
save_graph_dialog.save_file(
dialog_title="Save Drag Curve Graph",
file_name="drag_curve_comparison.png",
allowed_extensions=["png"]
)
else:
snack = ft.SnackBar(ft.Text("Please generate a graph first."), open=True)
if hasattr(page, 'overlay') and isinstance(page.overlay, list):
page.overlay.append(snack)
page.update()
# --- End FilePicker setup ---
def drag_curve_page_content():
graph_area = ft.Container(expand=True, alignment=ft.alignment.center)
drag_curve_input_1 = ft.Dropdown(
width=150,
label="Curve 1",
options=[
ft.dropdown.Option(key=table_name, text=table_name)
for table_name in get_drag_tables_names()
],
value="G1",
color=ft.Colors.WHITE70,
bgcolor=ft.Colors.with_opacity(0.1, ft.Colors.WHITE10),
border_color=ft.Colors.BLUE_GREY_700,
focused_border_color=accent_color,
)
drag_curve_input_2 = ft.Dropdown(
width=150,
label="Curve 2",
options=[
ft.dropdown.Option(key=table_name, text=table_name)
for table_name in get_drag_tables_names()
],
value="G7",
color=ft.Colors.WHITE70,
bgcolor=ft.Colors.with_opacity(0.1, ft.Colors.WHITE10),
border_color=ft.Colors.BLUE_GREY_700,
focused_border_color=accent_color,
)
def calculate_and_plot_drag_curve(e):
page.current_drag_curve_graph_bytes = None # Reset before new plot
try:
selected_drag_table_1_data = drag_tables.get(drag_curve_input_1.value)
selected_drag_table_2_data = drag_tables.get(drag_curve_input_2.value)
if not selected_drag_table_1_data or not selected_drag_table_2_data:
raise ValueError("Selected drag table(s) not found.")
df1 = DataFrame(selected_drag_table_1_data)
df2 = DataFrame(selected_drag_table_2_data)
fig, ax = subplots(figsize=(8, 5)) # Create new figure and axes
df1.plot(x='Mach', y='CD', ylabel='Drag Coefficient (CD)', ax=ax, label=drag_curve_input_1.value)
df2.plot(x='Mach', y='CD', ax=ax, label=drag_curve_input_2.value)
legend(loc="upper right")
ax.set_xlabel("Mach Number")
ax.grid(True, linestyle='--', alpha=0.7)
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=100)
plt_close(fig) # Close the figure to free memory
buf.seek(0)
# Store bytes on the page object
page.current_drag_curve_graph_bytes = buf.getvalue()
image_data = base64.b64encode(page.current_drag_curve_graph_bytes).decode()
image_widget = ft.Image(src_base64=image_data, fit=ft.ImageFit.CONTAIN)
graph_area.content = ft.InteractiveViewer(
content=image_widget,
max_scale=4,
min_scale=0.5,
)
page.update()
except Exception as ex:
snack_bar_text = f"Error plotting: {ex}"
print(snack_bar_text)
snack = ft.SnackBar(ft.Text(snack_bar_text), open=True)
if hasattr(page, 'overlay') and isinstance(page.overlay, list):
page.overlay.append(snack)
else: # Fallback if snackbar cannot be shown
graph_area.content = ft.Text(snack_bar_text, color=ft.Colors.RED)
page.update()
return ft.Container(
content=ft.Column(
[
ft.Container(height=10),
ft.Text("Drag Curve Comparison", size=20, weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER),
ft.Row(
controls=[
drag_curve_input_1,
drag_curve_input_2,
],
alignment=ft.MainAxisAlignment.SPACE_EVENLY,
spacing=20
),
ft.Container(
content=ft.Row(
[
ft.ElevatedButton(
"Plot Drag Curves",
on_click=calculate_and_plot_drag_curve,
icon = ft.Icons.AUTO_GRAPH,
style=ft.ButtonStyle(
shape=ft.RoundedRectangleBorder(radius=10),
bgcolor=accent_color,
color=ft.Colors.WHITE,
padding=ft.padding.symmetric(horizontal=20, vertical=10)
)
),
ft.ElevatedButton(
"Save Graph",
icon=ft.Icons.SAVE_ALT,
on_click=on_save_graph_click, # Corrected: on_save_graph_click
style=ft.ButtonStyle(
shape=ft.RoundedRectangleBorder(radius=10),
bgcolor=ft.Colors.GREEN_ACCENT_700,
color=ft.Colors.WHITE,
padding=ft.padding.symmetric(horizontal=20, vertical=10)
)
)
],
alignment=ft.MainAxisAlignment.CENTER,
spacing=15
),
alignment=ft.alignment.center,
padding=ft.padding.only(top=15, bottom=15)
),
graph_area, # The graph will be displayed here
],
expand=True,
scroll=ft.ScrollMode.ADAPTIVE, # Allow scrolling if content overflows
horizontal_alignment=ft.CrossAxisAlignment.CENTER
),
expand=True,
bgcolor=bg_color, # Use defined bg_color
padding=10 # Add some padding
)
page.add(drag_curve_page_content())
if __name__ == "__main__":
ft.app(target=main)
Steps to Reproduce with new code:
Save the code above as a Python file (e.g., app.py).
Ensure you have flet, matplotlib, and pandas installed.
Build the APK: flet build apk
Install and run the APK on an Android device or emulator.
Click the "Plot Drag Curves" button to generate the graph.
Click the "Save Graph" button.
The file save dialog will appear. Choose a location and save the file (e.g., drag_curve_comparison.png).
Observe that the saved file is 0KB and the "Could not save graph: [Errno 2] No such file or directory..." error is shown in a snackbar.
Expected Behavior:
The save_file() method, when used in an APK on Android, should correctly save the file with its actual content (the generated PNG graph bytes in this case) to the user-selected path, without any errors.
Environment:
Flet version: 0.28.3
Python version: 3.13
Build tool: flet build apk
Development OS: Windows 11
Additional Notes:
The pick_files() and get_directory_path() methods of FilePicker still work correctly in the APK.
The issue seems specific to the file writing part of the save_file() flow on Android.
The open(e.path, "wb") in the _save_graph_result callback receives e.path from the FilePicker, but it seems this path is not writable or correctly handled by the Python open() function in the context of an Android APK after the native dialog returns.
Could you please investigate this further? It seems like a path translation or permission issue when writing the file after the native Android save dialog provides the path.
Thanks for your continued efforts on Flet!