"""
Native file uploader for Kivy applications across multiple platforms: Windows, macOS, Linux, and Android.
"""
import os
import sys
import threading
from kivy.clock import Clock
from kivy.event import EventDispatcher
from kivy.logger import Logger
from kivy.properties import DictProperty, ListProperty, StringProperty
from kivy.utils import platform
# --- Platform specific imports ---
# Windows
if platform == "win":
import ctypes
from ctypes import wintypes
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception as e:
Logger.error(e)
ctypes.windll.user32.SetProcessDPIAware()
[docs]
OFN_EXPLORER = 0x00000008
OFN_ALLOWMULTISELECT = 0x00000200
OFN_FILEMUSTEXIST = 0x00001000
OFN_PATHMUSTEXIST = 0x00000800
class OPENFILENAMEW(ctypes.Structure):
_fields_ = [
("lStructSize", wintypes.DWORD),
("hwndOwner", wintypes.HWND),
("hInstance", wintypes.HINSTANCE),
("lpstrFilter", wintypes.LPCWSTR),
("lpstrCustomFilter", wintypes.LPWSTR),
("nMaxCustFilter", wintypes.DWORD),
("nFilterIndex", wintypes.DWORD),
("lpstrFile", wintypes.LPWSTR),
("nMaxFile", wintypes.DWORD),
("lpstrFileTitle", wintypes.LPWSTR),
("nMaxFileTitle", wintypes.DWORD),
("lpstrInitialDir", wintypes.LPCWSTR),
("lpstrTitle", wintypes.LPCWSTR),
("Flags", wintypes.DWORD),
("nFileOffset", wintypes.WORD),
("nFileExtension", wintypes.WORD),
("lpstrDefExt", wintypes.LPCWSTR),
("lCustData", wintypes.LPARAM),
("lpfnHook", wintypes.LPVOID),
("lpTemplateName", wintypes.LPCWSTR),
("pvReserved", wintypes.LPVOID),
("dwReserved", wintypes.DWORD),
("FlagsEx", wintypes.DWORD),
]
# Android
elif platform == "android":
from android import activity # type: ignore
from android.runnable import Runnable # type: ignore
from jnius import autoclass, cast # type: ignore
Uri = autoclass("android.net.Uri")
Intent = autoclass("android.content.Intent")
ClipData = autoclass("android.content.ClipData")
PythonActivity = autoclass("org.kivy.android.PythonActivity")
ContentResolver = PythonActivity.mActivity.getContentResolver()
FileOutputStream = autoclass("java.io.FileOutputStream")
# macOS
elif platform == "macosx":
import objc # type: ignore
from Cocoa import NSOpenPanel # type: ignore
# Linux
elif platform == "linux":
import importlib.util
if importlib.util.find_spec("gi") is None:
Logger.info("PyGObject (gi) is not installed. Try: sudo apt install python3-gi")
sys.exit(1)
else:
import gi # type: ignore
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # type: ignore
[docs]
class CFileUploader(EventDispatcher):
[docs]
files = ListProperty(None, allownone=True)
[docs]
file = StringProperty(None, allownone=True)
[docs]
title = StringProperty("Open")
[docs]
filters = DictProperty(None, allownone=True)
def __init__(self, **kwargs):
super(CFileUploader, self).__init__(**kwargs)
# --- Platform-specific implementations ---
def _open_file_windows(self, multiple: bool = False, *args) -> None:
# Minimal WinAPI dialog
buffer = ctypes.create_unicode_buffer(65536)
ofn = OPENFILENAMEW()
ofn.lStructSize = ctypes.sizeof(OPENFILENAMEW)
ofn.lpstrFile = ctypes.cast(buffer, wintypes.LPWSTR)
ofn.nMaxFile = len(buffer)
if self.filters:
parts = []
for label, exts in self.filters.items():
parts.append(label)
parts.append(";".join(exts))
filter_str = "\0".join(parts) + "\0\0"
ofn.lpstrFilter = filter_str
else:
ofn.lpstrFilter = "All Files\0*.*\0"
ofn.nFilterIndex = 1
ofn.Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST
if multiple:
ofn.Flags |= OFN_ALLOWMULTISELECT
if ctypes.windll.comdlg32.GetOpenFileNameW(ctypes.byref(ofn)):
selected_files = []
parts = buffer.value.split("\0")
if multiple:
if len(parts) == 1: # fallback if Explorer-style failed
parts = buffer.value.split(" ")
if len(parts) > 1:
# First part is directory, rest are filenames
directory = parts[0]
selected_files = [
os.path.join(directory, f) for f in parts[1:] if f
]
else:
# Single file selected → already absolute path
selected_files = [str(parts[0])]
def _apply(_dt):
self.files = selected_files
self.file = self.files[0] if self.files else None
Clock.schedule_once(_apply)
return
def _open_file_macos(self, multiple=False, *args) -> None:
# needs testing
selected_files = []
panel = NSOpenPanel.openPanel() # type: ignore
panel.setAllowsMultipleSelection_(multiple)
if self.filters:
all_exts = [
ext.replace("*.", "") for exts in self.filters.values() for ext in exts
]
panel.setAllowedFileTypes_(all_exts)
if panel.runModal():
selected_files = [str(url.path()) for url in panel.URLs()]
def _apply(_dt):
self.files = selected_files
self.file = self.files[0] if self.files else None
Clock.schedule_once(_apply)
return
def _open_file_linux(self, multiple: bool = False, *args) -> None:
action = Gtk.FileChooserAction.OPEN
dialog = Gtk.FileChooserDialog(
title="Select File",
action=action,
)
dialog.add_buttons(
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
Gtk.STOCK_OPEN,
Gtk.ResponseType.OK,
)
dialog.set_select_multiple(multiple)
if self.filters:
for label, exts in self.filters.items():
f = Gtk.FileFilter()
f.set_name(label)
for ext in exts:
f.add_pattern(ext)
dialog.add_filter(f)
selected_files = []
def on_response(dlg, response):
if response == Gtk.ResponseType.OK:
if multiple:
selected_files = dlg.get_filenames()
dlg.destroy()
else:
selected_files = [dlg.get_filename()]
dlg.destroy()
def _apply(_dt):
self.files = selected_files
self.file = self.files[0] if self.files else None
Clock.schedule_once(_apply)
else:
dlg.destroy()
Gtk.main_quit() # Exit the GTK main loop and return control to Kivy
dialog.connect("response", on_response)
# Non-blocking: show the dialog
dialog.show()
Gtk.main()
[docs]
def on_activity_result(self, requestCode: int, resultCode: int, intent) -> None:
if requestCode == 1 and resultCode == PythonActivity.RESULT_OK: # type: ignore
selected_files = []
if intent is not None:
if intent.getData() is not None:
# Single file
uri = intent.getData()
filename = self.copy_content_uri(uri.toString(), 0)
selected_files.append(filename)
elif intent.getClipData() is not None:
# Multiple files
clipData = intent.getClipData()
for i in range(clipData.getItemCount()):
uri = clipData.getItemAt(i).getUri()
try:
filename = self.copy_content_uri(uri.toString(), i)
selected_files.append(filename)
except Exception as e:
Logger.error(e)
def _apply(_dt):
self.files = selected_files
self.file = self.files[0] if self.files else None
Clock.schedule_once(_apply)
return
[docs]
def copy_content_uri(self, uri_string, index=0):
"""Resolve a content:// URI into a local temp file path."""
uri = Uri.parse(uri_string)
inputStream = ContentResolver.openInputStream(uri)
temp_path = os.path.join(
PythonActivity.mActivity.getFilesDir().getAbsolutePath(),
f"picked_{os.path.basename(uri.getPath())}", # unique filename per file
)
fos = FileOutputStream(temp_path)
buf = bytearray(1024)
while True:
read = inputStream.read(buf)
if read == -1:
break
fos.write(buf, 0, read)
fos.close()
inputStream.close()
return temp_path
def _open_file_android(
self, multiple: bool = False, mime_type: str = "*/*", *args
) -> None:
intent = Intent(Intent.ACTION_GET_CONTENT) # type: ignore
intent.setType(mime_type)
intent.addCategory(Intent.CATEGORY_OPENABLE) # type: ignore
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
if multiple:
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, True) # type: ignore
activity.bind(on_activity_result=self.on_activity_result) # type: ignore
PythonActivity.mActivity.startActivityForResult(intent, 1) # type: ignore
return
[docs]
def upload_files(
self, filters: list | None = None, mime_type: str = "*/*", *args
) -> None:
"""Open a file dialog to select multiple files.
filters = {
"JPEG Files": ["*.jpg", "*.jpeg"],
"PNG Files": ["*.png"],
"All Images": ["*.jpg", "*.jpeg", "*.png"]
}
"""
if filters:
self.filters = filters
if platform == "android":
Runnable(self._open_file_android)(multiple=True, mime_type=mime_type)
elif platform == "win":
threading.Thread(
target=self._open_file_windows, daemon=True, kwargs={"multiple": True}
).start()
elif platform == "macosx":
threading.Thread(
target=self._open_file_macos, daemon=True, kwargs={"multiple": True}
).start()
elif platform == "linux":
threading.Thread(
target=self._open_file_linux, daemon=True, kwargs={"multiple": True}
).start()
return
[docs]
def upload_file(
self, filters: list | None = None, mime_type: str = "*/*", *args
) -> None:
"""Open a file dialog to select a single file.
filters = {
"JPEG Files": ["*.jpg", "*.jpeg"],
"PNG Files": ["*.png"],
"All Images": ["*.jpg", "*.jpeg", "*.png"]
}
"""
if filters:
self.filters = filters
if platform == "android":
Runnable(self._open_file_android)(multiple=False, mime_type=mime_type)
elif platform == "win":
threading.Thread(
target=self._open_file_windows, daemon=True, kwargs={"multiple": False}
).start()
elif platform == "macosx":
threading.Thread(
target=self._open_file_macos, daemon=True, kwargs={"multiple": False}
).start()
elif platform == "linux":
threading.Thread(
target=self._open_file_linux, daemon=True, kwargs={"multiple": False}
).start()
return
if __name__ == "__main__":
[docs]
uploader = CFileUploader()
uploader.upload_files()
Logger.debug("Selected files:", uploader.files)
uploader.upload_file()
Logger.debug("Selected file:", uploader.file)