-
-
Notifications
You must be signed in to change notification settings - Fork 671
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Android file browser #1158
base: main
Are you sure you want to change the base?
Android file browser #1158
Changes from 12 commits
558d9f1
4343df7
df001f5
7ecb67d
5d8658f
f9a1ffd
2f33e22
b66cdcc
62eeca6
26c46dc
85d4f4c
ed40eaa
bc61b05
842e2a1
37b9ca4
2ffc67f
2fc5940
7e177f3
868d06f
b4bc066
2009c6e
a7e016e
32cb78e
f2ae7c1
aad9eab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
Android Filebrowser Demo | ||
======================== | ||
|
||
Test app for the Android native file / folder chooser. | ||
|
||
Quickstart | ||
~~~~~~~~~~ | ||
|
||
For this example to work under Android, you need a briefcase android template | ||
which supports onActivityResult in MainActivity.java | ||
see https://github.com/t-arn/briefcase-android-gradle-template.git branch onActivityResult | ||
|
||
To run this example: | ||
|
||
$ pip install toga | ||
$ python -m filebrowser |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
# Examples of valid version strings | ||
# __version__ = '1.2.3.dev1' # Development release 1 | ||
# __version__ = '1.2.3a1' # Alpha Release 1 | ||
# __version__ = '1.2.3b1' # Beta Release 1 | ||
# __version__ = '1.2.3rc1' # RC Release 1 | ||
# __version__ = '1.2.3' # Final Release | ||
# __version__ = '1.2.3.post1' # Post Release 1 | ||
|
||
__version__ = '0.0.1' |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from filebrowser.app import main | ||
|
||
if __name__ == '__main__': | ||
main().main_loop() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
# For this example to work under Android, you need a briefcase android template | ||
# which supports onActivityResult in MainActivity.java | ||
# see https://github.com/t-arn/briefcase-android-gradle-template.git branch onActivityResult | ||
|
||
|
||
import toga | ||
from toga.style import Pack | ||
from toga.constants import COLUMN, ROW | ||
from rubicon.java import JavaClass | ||
|
||
Intent = JavaClass("android/content/Intent") | ||
Activity = JavaClass("android/app/Activity") | ||
|
||
|
||
class ExampleFilebrowserApp(toga.App): | ||
# Button callback functions | ||
async def do_open_file(self, widget, **kwargs): | ||
print("Clicked on 'Open file'") | ||
multiselect = False | ||
mimetypes = str(self.file_types.value).split(' ') | ||
if self.multiselect.value == 'True': | ||
multiselect = True | ||
try: | ||
selected_uri = '' | ||
if self.use_oifm.value != 'True': | ||
selected_uri = await self.app.main_window.open_file_dialog("Choose a file", self.initial_dir.value, | ||
mimetypes, multiselect) | ||
else: | ||
intent = Intent("org.openintents.action.PICK_FILE") | ||
intent.putExtra("org.openintents.extra.TITLE", "Choose a file") | ||
result = await self.app._impl.invoke_intent_for_result(intent) | ||
print(str(result)) | ||
if result["resultData"] is not None: | ||
selected_uri = result["resultData"].getData() | ||
else: | ||
selected_uri = 'No file selected, ResultCode was ' + str(result["resultCode"]) + ")" | ||
except ValueError as e: | ||
selected_uri = str(e) | ||
print(str(selected_uri)) | ||
self.multiline.value = "You selected: \n" + str(selected_uri) | ||
|
||
async def do_open_folder(self, widget, **kwargs): | ||
print("Clicked on 'Open folder'") | ||
multiselect = False | ||
if self.multiselect.value == 'True': | ||
multiselect = True | ||
try: | ||
selected_uri = '' | ||
if self.use_oifm.value != 'True': | ||
selected_uri = await self.app.main_window.select_folder_dialog("Choose a folder", | ||
self.initial_dir.value, multiselect) | ||
else: | ||
intent = Intent("org.openintents.action.PICK_DIRECTORY") | ||
intent.putExtra("org.openintents.extra.TITLE", "Choose a folder") | ||
result = await self.app._impl.invoke_intent_for_result(intent) | ||
print(str(result)) | ||
if result["resultData"] is not None: | ||
selected_uri = result["resultData"].getData() | ||
else: | ||
selected_uri = 'No folder selected, ResultCode was ' + str(result["resultCode"]) + ")" | ||
except ValueError as e: | ||
selected_uri = str(e) | ||
self.multiline.value = "You selected: \n" + str(selected_uri) | ||
|
||
def do_clear(self, widget, **kwargs): | ||
print('Clearing result') | ||
self.multiline.value = "Ready." | ||
|
||
def startup(self): | ||
# Set up main window | ||
self.main_window = toga.MainWindow(title=self.name, size=(400, 700)) | ||
flex_style = Pack(flex=1) | ||
|
||
# set options | ||
self.initial_dir = toga.TextInput(placeholder='initial directory', style=flex_style) | ||
self.file_types = toga.TextInput(placeholder='MIME types (blank separated)', style=flex_style) | ||
self.multiselect = toga.TextInput(placeholder='is multiselect? (True / False)', style=flex_style) | ||
self.use_oifm = toga.TextInput(placeholder='Use OI Filemanager? (True / False)', style=flex_style) | ||
# Toga.Switch does not seem to work on Android ... | ||
# self.multiselect = toga.Switch('multiselect', is_on=False) | ||
# self.use_oifm = toga.Switch('Use OI Filemanager') | ||
|
||
# Text field to show responses. | ||
self.multiline = toga.MultilineTextInput('Ready.', style=(Pack(height=200))) | ||
|
||
# Buttons | ||
btn_open_file = toga.Button('Open file', on_press=self.do_open_file, style=flex_style) | ||
btn_open_folder = toga.Button('Open folder', on_press=self.do_open_folder, style=flex_style) | ||
btn_clear = toga.Button('Clear', on_press=self.do_clear, style=flex_style) | ||
btn_box = toga.Box( | ||
children=[ | ||
btn_open_file, | ||
btn_open_folder, | ||
btn_clear | ||
], | ||
style=Pack(direction=ROW) | ||
) | ||
|
||
# Outermost box | ||
outer_box = toga.Box( | ||
children=[self.initial_dir, self.file_types, self.multiselect, self.use_oifm, btn_box, self.multiline], | ||
style=Pack( | ||
flex=1, | ||
direction=COLUMN, | ||
padding=10, | ||
width=500, | ||
height=300 | ||
) | ||
) | ||
|
||
# Add the content on the main window | ||
self.main_window.content = outer_box | ||
|
||
# Show the main window | ||
self.main_window.show() | ||
|
||
|
||
def main(): | ||
return ExampleFilebrowserApp('Android Filebrowser Demo', 'org.beeware.widgets.filebrowser') | ||
|
||
|
||
if __name__ == '__main__': | ||
app = main() | ||
app.main_loop() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Put any icons or images in this directory. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
[build-system] | ||
requires = ["briefcase"] | ||
|
||
[tool.briefcase] | ||
project_name = "Android Filebrowser Demo" | ||
bundle = "org.beeware" | ||
version = "0.3.0.dev25" | ||
url = "https://beeware.org" | ||
license = "BSD license" | ||
author = 'Tiberius Yak' | ||
author_email = "[email protected]" | ||
|
||
[tool.briefcase.app.filebrowser] | ||
formal_name = "Android Filebrowser Demo" | ||
description = "A testing app" | ||
sources = ['filebrowser'] | ||
requires = [ | ||
'c:/Projects/Python/Toga/src/core' | ||
] | ||
|
||
|
||
[tool.briefcase.app.filebrowser.macOS] | ||
requires = [ | ||
'toga-cocoa', | ||
] | ||
|
||
[tool.briefcase.app.filebrowser.linux] | ||
requires = [ | ||
'toga-gtk', | ||
] | ||
|
||
[tool.briefcase.app.filebrowser.windows] | ||
requires = [ | ||
# 'toga-winforms', | ||
'c:/Projects/Python/Toga/src/winforms' | ||
] | ||
|
||
# Mobile deployments | ||
[tool.briefcase.app.filebrowser.iOS] | ||
requires = [ | ||
'toga-iOS', | ||
] | ||
|
||
[tool.briefcase.app.filebrowser.android] | ||
requires = [ | ||
#'toga-android', | ||
'c:/Projects/Python/Toga/src/android' | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,13 +5,18 @@ | |
from .libs.activity import IPythonApp, MainActivity | ||
from .window import Window | ||
|
||
import asyncio | ||
|
||
|
||
# `MainWindow` is defined here in `app.py`, not `window.py`, to mollify the test suite. | ||
class MainWindow(Window): | ||
pass | ||
|
||
|
||
class TogaApp(IPythonApp): | ||
last_intent_requestcode = -1 # always increment before using it for invoking new Intents | ||
running_intents = {} # dictionary for currently running Intents | ||
|
||
def __init__(self, app): | ||
super().__init__() | ||
self._interface = app | ||
|
@@ -39,6 +44,21 @@ def onDestroy(self): | |
def onRestart(self): | ||
print("Toga app: onRestart") | ||
|
||
def onActivityResult(self, requestCode, resultCode, resultData): | ||
""" | ||
Callback method, called from MainActivity when an Intent ends | ||
|
||
:param int requestCode: The integer request code originally supplied to startActivityForResult(), | ||
allowing you to identify who this result came from. | ||
:param int resultCode: The integer result code returned by the child activity through its setResult(). | ||
:param Intent resultData: An Intent, which can return result data to the caller (various data can be attached | ||
to Intent "extras"). | ||
""" | ||
print("Toga app: onActivityResult") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For logging purposes, it might be helpful to include requestCode and resultCode here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Well, I have thought about this as well and it could be done pretty easily. In addition there is the problem that in Android 11 the file I/O will not be supported anymore on external storage: That's why I think we should stick to the content URI params and result of open_file_dialog. Just my 2 cents... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hrm. That's... annoying... but equally annoying is that the reasons that make a lot of sense. :-) I agree that a high level "just get me the file content" API would be extremely useful. The general problem of "download this file in the background" is a universal one; it's just a little more obvious on mobile (and Android is clearly forcing the issue by locking down the security on those APIs). However, this does give us an opportunity to provide an API that is Pathlib/File duck-compatible, but abstracts network (or file) access. We don't need to solve that problem right now - but we do need to work out how to not box ourselves into a corner for a future where this API exists. I'm not wild about the idea of Android returning a different type of result than other platforms; however, I'm also not completely sure I have a better idea as an alternative. I guess it's easy enough to distinguish There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, I added some method documentation in toga\window.py open_file_dialog and select_folder_dialog to make it clear to the user that on Android, he should work with content URIs |
||
result_future = self.running_intents[str(requestCode)] | ||
self.running_intents.pop(str(requestCode)) # remove Intent from the list of running Intents | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pop returns the object that was popped, so these two lines can be combined. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool, I learned something here :-) |
||
result_future.set_result({"resultCode": resultCode, "resultData": resultData}) | ||
|
||
@property | ||
def native(self): | ||
# We access `MainActivity.singletonThis` freshly each time, rather than | ||
|
@@ -92,3 +112,19 @@ def set_on_exit(self, value): | |
|
||
def add_background_task(self, handler): | ||
self.loop.call_soon(wrapped_handler(self, handler), self) | ||
|
||
async def invoke_intent_for_result(self, intent): | ||
""" | ||
Calls an Intent and waits for its result | ||
|
||
:param Intent intent: The Intent to call | ||
:returns: A Dictionary containing "resultCode" (int) and "resultData" (Intent or None) | ||
:rtype: dict | ||
""" | ||
self._listener.last_intent_requestcode += 1 | ||
code = self._listener.last_intent_requestcode | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a great use of counters to avoid object leaks, and excellent bookkeeping of the |
||
result_future = asyncio.Future() | ||
self._listener.running_intents[str(code)] = result_future | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why the conversion to str for the dictionary key? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are keys not required to be strings in Python dictionaries? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, I just found that keys can be of any type...I'm still a Python newbie, I'm afraid.. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor clarification - they dict keys can be any hashable type. You can't use a dictionary as a hash key, for example, because the dict itself can change, so you can't generate a consistent hash. But integers, strings, floats, most simple objects, and tuples of those primitives are all fair game as dict keys. |
||
self.native.startActivityForResult(intent, code) | ||
await result_future | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like to do There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, I do not return result_future, I need to return result_future.result() |
||
return result_future.result() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,10 @@ | ||
from . import dialogs | ||
|
||
from rubicon.java import JavaClass | ||
Intent = JavaClass("android/content/Intent") | ||
Activity = JavaClass("android/app/Activity") | ||
Uri = JavaClass("android/net/Uri") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These declarations should be moved to toga_android.libs so they aren't accidentally declared multiple times. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I saw that there are lots of such declarations in libs/android_widgets.py There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a good point. I'd go even simpler - just call it There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should I create a new file libs/android.py with just my classes or should I rename the existing libs/android_widget.py? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I now created a new file with only my declaration. |
||
|
||
|
||
class AndroidViewport: | ||
def __init__(self, native): | ||
|
@@ -78,3 +83,85 @@ def stack_trace_dialog(self, title, message, content, retry=False): | |
|
||
def save_file_dialog(self, title, suggested_filename, file_types): | ||
self.interface.factory.not_implemented('Window.save_file_dialog()') | ||
|
||
async def open_file_dialog(self, title, initial_uri, file_mime_types, multiselect): | ||
""" | ||
Opens a file chooser dialog and returns the chosen file as content URI. | ||
Raises a ValueError when nothing has been selected | ||
|
||
:param str title: The title is ignored on Android | ||
:param initial_uri: The initial location shown in the file chooser. Must be a content URI, e.g. | ||
'content://com.android.externalstorage.documents/document/primary%3ADownload%2FTest-dir' | ||
:type initial_uri: str or None | ||
:param file_mime_types: The file types allowed to select. Must be MIME types, e.g. | ||
['application/pdf','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']. | ||
Currently ignored to avoid error in rubicon | ||
:type file_mime_types: list[str] or None | ||
:param bool multiselect: If True, then several files can be selected | ||
:returns: The content URI of the chosen file or a list of content URIs when multiselect=True. | ||
:rtype: str or list[str] | ||
""" | ||
print('Invoking Intent ACTION_OPEN_DOCUMENT') | ||
intent = Intent(Intent.ACTION_OPEN_DOCUMENT) | ||
intent.addCategory(Intent.CATEGORY_OPENABLE) | ||
intent.setType("*/*") | ||
if initial_uri is not None and initial_uri != '': | ||
intent.putExtra("android.provider.extra.INITIAL_URI", Uri.parse(initial_uri)) | ||
if file_mime_types is not None and file_mime_types != ['']: | ||
# Commented out because rubicon currently does not support arrays and nothing else works with this Intent | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @t-arn! This is a great change overall! With regard to this line, it might be nice to file a rubicon-java bug requesting string arrays be passable-in somehow. Based on beeware/rubicon-java#53 it should be possible to do that. It doesn't have to block this PR! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, I filed a rubicon enhancement request: |
||
# intent.putExtra(Intent.EXTRA_MIME_TYPES, file_mime_types) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would be worth putting in a NotImplemented call here so that the end-user sees a manifestation of this limitation in the logs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok, will do |
||
pass | ||
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiselect) | ||
selected_uri = None | ||
result = await self.app.invoke_intent_for_result(intent) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is awesome :D There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I also like that method. You can use it for calling any Intent you like. |
||
if result["resultCode"] == Activity.RESULT_OK: | ||
if result["resultData"] is not None: | ||
selected_uri = result["resultData"].getData() | ||
if multiselect is True: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When multiselect is False, then there is no clip_data and the result is already in selected_uri There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah - ok; I think I see how it works now. There's definitely a need for some inline documentation to explain the logic here - in particular, what the return types are, and how they interact with multiselect. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, added some inline documentation |
||
if selected_uri is None: | ||
selected_uri = [] | ||
clip_data = result["resultData"].getClipData() | ||
if clip_data is not None: | ||
for i in range(0, clip_data.getItemCount()): | ||
selected_uri.append(str(clip_data.getItemAt(i).getUri())) | ||
else: | ||
selected_uri = [str(selected_uri)] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems like it's not at the right indent level... is this the else for the multiselect? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, the problem is that even when multiselect is True, the user might only select 1 file which will be returned with getData() (and clip_data will be empty). Only when multiselect is True AND the user selected several files, clip_data will contain the list with the files (and getData will be None) |
||
if selected_uri is None: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code smoothly handles the cancellation possibility! |
||
raise ValueError("No filename provided in the open file dialog") | ||
return selected_uri | ||
|
||
async def select_folder_dialog(self, title, initial_uri=None, multiselect=False): | ||
""" | ||
Opens a folder chooser dialog and returns the chosen folder as content URI. | ||
Raises a ValueError when nothing has been selected | ||
|
||
:param str title: The title is ignored on Android | ||
:param initial_uri: The initial location shown in the file chooser. Must be a content URI, e.g. | ||
'content://com.android.externalstorage.documents/document/primary%3ADownload%2FTest-dir' | ||
:type initial_uri: str or None | ||
:param bool multiselect: If True, then several files can be selected | ||
:returns: The content URI of the chosen folder or a list of content URIs when multiselect=True. | ||
:rtype: str or list[str] | ||
""" | ||
print('Invoking Intent ACTION_OPEN_DOCUMENT_TREE') | ||
intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) | ||
if initial_uri is not None and initial_uri != '': | ||
intent.putExtra("android.provider.extra.INITIAL_URI", Uri.parse(initial_uri)) | ||
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiselect) | ||
selected_uri = None | ||
result = await self.app.invoke_intent_for_result(intent) | ||
if result["resultCode"] == Activity.RESULT_OK: | ||
if result["resultData"] is not None: | ||
selected_uri = result["resultData"].getData() | ||
if multiselect is True: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's an analogous need for docstrings in this implementation; we can't assume someone will read both methods. |
||
if selected_uri is None: | ||
selected_uri = [] | ||
clip_data = result["resultData"].getClipData() | ||
if clip_data is not None: | ||
for i in range(0, clip_data.getItemCount()): | ||
selected_uri.append(str(clip_data.getItemAt(i).getUri())) | ||
else: | ||
selected_uri = [str(selected_uri)] | ||
if selected_uri is None: | ||
raise ValueError("No folder provided in the open folder dialog") | ||
return selected_uri |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These c:/ paths will have to be replaced with names that work on others' machines, e.g.
toga-core
here, I believe.I definitely appreciate that it's finnicky to swap these in when you're developing toga, then swap them out to match the style of other examples'
pyproject.toml
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, you're right there.
I just did not know, whether we will keep the filebrowser example or if we just use it until the PR is accepted.
There actually is already an example for dialogs, but I didn't want to change that completly (due to the async nature).
Besides, the existing dialog examle might have a too large height for Android where scroll view is not yet working.
Some controls might acutally be outside the visible area.