Skip to content
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

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 131 additions & 51 deletions examples/dialogs/dialogs/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ def action_open_file_dialog(self, widget):
except ValueError:
self.label.text = "Open file dialog was canceled"

async def action_open_file_dialog_android(self, widget):
try:
selected_uri = ''
selected_uri = await self.app.main_window.open_file_dialog(
title="Choose a file",
multiselect=False)
self.label.text = "You selected: " + str(selected_uri)
except ValueError as e:
selected_uri = str(e)
self.label.text = selected_uri
print(str(selected_uri))

def action_open_file_filtered_dialog(self, widget):
try:
fname = self.main_window.open_file_dialog(
Expand All @@ -57,6 +69,9 @@ def action_open_file_filtered_dialog(self, widget):
except ValueError:
self.label.text = "Open file dialog was canceled"

def action_open_file_filtered_dialog_android(self, widget):
self.label.text = "file_types currently not supported by rubicon java"

def action_open_file_dialog_multi(self, widget):
try:
filenames = self.main_window.open_file_dialog(
Expand All @@ -72,6 +87,18 @@ def action_open_file_dialog_multi(self, widget):
except ValueError:
self.label.text = "Open file dialog was canceled"

async def action_open_file_dialog_multi_android(self, widget):
try:
selected_uri = ''
selected_uri = await self.app.main_window.open_file_dialog(
title="Choose a file",
multiselect=True)
self.label.text = "You selected: " + str(selected_uri)
except ValueError as e:
selected_uri = str(e)
self.label.text = selected_uri
print(str(selected_uri))

def action_select_folder_dialog(self, widget):
try:
path_names = self.main_window.select_folder_dialog(
Expand All @@ -81,6 +108,17 @@ def action_select_folder_dialog(self, widget):
except ValueError:
self.label.text = "Folder select dialog was canceled"

async def action_select_folder_dialog_android(self, widget):
try:
selected_uri = ''
selected_uri = await self.app.main_window.select_folder_dialog("Choose a folder",
multiselect=False)
self.label.text = "You selected: " + str(selected_uri)
except ValueError as e:
selected_uri = str(e)
self.label.text = selected_uri
print(str(selected_uri))

def action_select_folder_dialog_multi(self, widget):
try:
path_names = self.main_window.select_folder_dialog(
Expand All @@ -91,6 +129,9 @@ def action_select_folder_dialog_multi(self, widget):
except ValueError:
self.label.text = "Folders select dialog was canceled"

async def action_select_folder_dialog_multi_android(self, widget):
self.label.text = "Multiple folder selection is not supported"

def action_save_file_dialog(self, widget):
fname = 'Toga_file.txt'
try:
Expand Down Expand Up @@ -180,62 +221,101 @@ def startup(self):
btn_question = toga.Button('Question', on_press=self.action_question_dialog, style=btn_style)
btn_confirm = toga.Button('Confirm', on_press=self.action_confirm_dialog, style=btn_style)
btn_error = toga.Button('Error', on_press=self.action_error_dialog, style=btn_style)
btn_open = toga.Button('Open File', on_press=self.action_open_file_dialog, style=btn_style)
btn_open_filtered = toga.Button(
'Open File (Filtered)',
on_press=self.action_open_file_filtered_dialog,
style=btn_style
)
btn_open_multi = toga.Button(
'Open File (Multiple)',
on_press=self.action_open_file_dialog_multi,
style=btn_style
)
btn_save = toga.Button('Save File', on_press=self.action_save_file_dialog, style=btn_style)
btn_select = toga.Button('Select Folder', on_press=self.action_select_folder_dialog, style=btn_style)
btn_select_multi = toga.Button(
'Select Folders',
on_press=self.action_select_folder_dialog_multi,
style=btn_style
)
btn_open_secondary_window = toga.Button(
'Open Secondary Window',
on_press=self.action_open_secondary_window,
style=btn_style
)
btn_close_secondary_window = toga.Button(
'Close All Secondary Windows',
on_press=self.action_close_secondary_windows,
style=btn_style
)
if toga.platform.current_platform == 'android':
btn_open = toga.Button('Open File', on_press=self.action_open_file_dialog_android, style=btn_style)
btn_open_filtered = toga.Button(
'Open File (Filtered)',
on_press=self.action_open_file_filtered_dialog_android,
style=btn_style
)
btn_open_multi = toga.Button(
'Open File (Multiple)',
on_press=self.action_open_file_dialog_multi_android,
style=btn_style
)
btn_select = toga.Button('Select Folder',
on_press=self.action_select_folder_dialog_android, style=btn_style)
btn_select_multi = toga.Button(
'Select Folders',
on_press=self.action_select_folder_dialog_multi_android, style=btn_style
)
else:
btn_open = toga.Button('Open File', on_press=self.action_open_file_dialog, style=btn_style)
btn_open_filtered = toga.Button(
'Open File (Filtered)',
on_press=self.action_open_file_filtered_dialog,
style=btn_style
)
btn_open_multi = toga.Button(
'Open File (Multiple)',
on_press=self.action_open_file_dialog_multi,
style=btn_style
)
btn_save = toga.Button('Save File', on_press=self.action_save_file_dialog, style=btn_style)
btn_select = toga.Button('Select Folder', on_press=self.action_select_folder_dialog, style=btn_style)
btn_select_multi = toga.Button(
'Select Folders',
on_press=self.action_select_folder_dialog_multi,
style=btn_style
)
btn_open_secondary_window = toga.Button(
'Open Secondary Window',
on_press=self.action_open_secondary_window,
style=btn_style
)
btn_close_secondary_window = toga.Button(
'Close All Secondary Windows',
on_press=self.action_close_secondary_windows,
style=btn_style
)

btn_clear = toga.Button('Clear', on_press=self.do_clear, style=btn_style)

# Outermost box
box = toga.Box(
children=[
btn_info,
btn_question,
btn_confirm,
btn_error,
btn_open,
btn_open_filtered,
btn_save,
btn_select,
btn_select_multi,
btn_open_multi,
btn_open_secondary_window,
btn_close_secondary_window,
btn_clear,
self.label,
self.window_label
],
style=Pack(
flex=1,
direction=COLUMN,
padding=10
if toga.platform.current_platform == 'android':
box = toga.Box(
children=[
btn_info,
btn_open,
btn_open_filtered,
btn_select,
btn_select_multi,
btn_open_multi,
btn_clear,
self.label,
self.window_label
],
style=Pack(
flex=1,
direction=COLUMN,
padding=10
)
)
else:
box = toga.Box(
children=[
btn_info,
btn_question,
btn_confirm,
btn_error,
btn_open,
btn_open_filtered,
btn_save,
btn_select,
btn_select_multi,
btn_open_multi,
btn_open_secondary_window,
btn_close_secondary_window,
btn_clear,
self.label,
self.window_label
],
style=Pack(
flex=1,
direction=COLUMN,
padding=10
)
)
)

# Add the content on the main window
self.main_window.content = box
Expand Down
7 changes: 5 additions & 2 deletions examples/dialogs/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ author_email = "[email protected]"
formal_name = "Dialog Demo"
description = "A testing app"
sources = ['dialogs']
requires = []
requires = [
'c:/Projects/Python/Toga/src/core'
]


[tool.briefcase.app.dialogs.macOS]
Expand All @@ -40,5 +42,6 @@ requires = [

[tool.briefcase.app.dialogs.android]
requires = [
'toga-android',
#'toga-android',
'c:/Projects/Python/Toga/src/android'
]
2 changes: 2 additions & 0 deletions src/android/toga_android/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .window import Window



# `MainWindow` is defined here in `app.py`, not `window.py`, to mollify the test suite.
class MainWindow(Window):
pass
Expand Down Expand Up @@ -137,3 +138,4 @@ async def intent_result(self, intent):
self.native.startActivityForResult(intent, code)
await result_future
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like to do return await result_future as one line, rather than two lines like you do here on 129-130. Your way is fine, too, so not a blocker, but just something that occurred to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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()
And return await result_future.result() did not work.

return result_future.result()

2 changes: 2 additions & 0 deletions src/android/toga_android/libs/activity.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from rubicon.java import JavaClass, JavaInterface

Activity = JavaClass("android/app/Activity")

# The Android cookiecutter template creates an app whose main Activity is
# called `MainActivity`. The activity assumes that we will store a reference
# to an implementation/subclass of `IPythonApp` in it.
Expand Down
3 changes: 3 additions & 0 deletions src/android/toga_android/libs/android/net.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from rubicon.java import JavaClass

Uri = JavaClass("android/net/Uri")
89 changes: 89 additions & 0 deletions src/android/toga_android/window.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from . import dialogs
from .libs.activity import Activity
from .libs.android import R__attr
from .libs.android.content import Intent
from .libs.android.net import Uri
from .libs.android.util import TypedValue


Expand Down Expand Up @@ -104,3 +107,89 @@ 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I filed a rubicon enhancement request:
beeware/rubicon-java#55

# see https://github.com/beeware/rubicon-java/pull/53
# intent.putExtra(Intent.EXTRA_MIME_TYPES, file_mime_types)
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, will do

self.interface.factory.not_implemented(
'Window.open_file_dialog() on Android currently does not support the file_type parameter')
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiselect)
selected_uri = None
result = await self.app.intent_result(intent)
if result["resultCode"] == Activity.RESULT_OK:
if result["resultData"] is not None:
selected_uri = result["resultData"].getData()
if multiselect:
if selected_uri is None:
# when the user selects more than 1 file, getData() will be None. Instead, getClipData() will
# contain the list of chosen files
selected_uri = []
clip_data = result["resultData"].getClipData()
if clip_data is not None: # just to be sure there will never be a null reference exception...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - but why will there be a null reference exception? Docstrings should be about the why, not the what. Dif size == 3: # check if size is 3 doesn't tell me anything I couldn't work out by reading the code; # size 3 is prohibited by the standard tells me why the code exists.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I'm not sure if there ever will be a null pointer exception.
On the other hand, I'm also not sure that clip_data will always be not-null when resultData is null...

for i in range(0, clip_data.getItemCount()):
selected_uri.append(str(clip_data.getItemAt(i).getUri()))
else:
selected_uri = [str(selected_uri)]
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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:
Copy link
Contributor

Choose a reason for hiding this comment

The 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 tree 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.intent_result(intent)
if result["resultCode"] == Activity.RESULT_OK:
if result["resultData"] is not None:
selected_uri = result["resultData"].getData()
if multiselect is True:
Copy link
Member

Choose a reason for hiding this comment

The 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
Loading