• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kivy / plyer / 5553208366

pending completion
5553208366

push

github-actions

web-flow
Merge pull request #755 from DexerBR/patch-3

Fixes error when selecting a file from the `Documents` option in newer Android APIs

2 of 2 new or added lines in 1 file covered. (100.0%)

1459 of 5785 relevant lines covered (25.22%)

0.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/plyer/platforms/android/filechooser.py
1
'''
2
Android file chooser
3
--------------------
4

5
Android runs ``Activity`` asynchronously via pausing our ``PythonActivity``
6
and starting a new one in the foreground. This means
7
``AndroidFileChooser._open_file()`` will always return the default value of
8
``AndroidFileChooser.selection`` i.e. ``None``.
9

10
After the ``Activity`` (for us it's the file chooser ``Intent``) is completed,
11
Android moves it to the background (or destroys or whatever is implemented)
12
and pushes ``PythonActivity`` to the foreground.
13

14
We have a custom listener for ``android.app.Activity.onActivityResult()``
15
via `android` package from `python-for-android` recipe,
16
``AndroidFileChooser._on_activity_result()`` which is called independently of
17
any our action (we may call anything from our application in Python and this
18
handler will be called nevertheless on each ``android.app.Activity`` result
19
in the system).
20

21
In the handler we check if the ``request_code`` matches the code passed to the
22
``Context.startActivityForResult()`` i.e. if the result from
23
``android.app.Activity`` is indeed meant for our ``PythonActivity`` and then we
24
proceed.
25

26
Since the ``android.app.Activity.onActivityResult()`` is the only way for us
27
to intercept the result and we have a handler bound via ``android`` package,
28
we need to get the path/file/... selection to the user the same way.
29

30
Threading + ``Thread.join()`` or ``time.sleep()`` or any other kind of waiting
31
for the result is not an option because:
32

33
1) ``android.app.Activity.onActivityResult()`` might remain unexecuted if
34
the launched file chooser activity does not return the result (``Activity``
35
dies/freezes/etc).
36

37
2) Thread will be still waiting for the result e.g. an update of a value or
38
to actually finish, however the result from the call of
39
``AndroidFileChooser._open_file()`` will be returned nevertheless and anything
40
using that result will use an incorrect one i.e. the default value of
41
``AndroidFilechooser.selection`` (``None``).
42

43
.. versionadded:: 1.4.0
44
'''
45

46
from os.path import join, basename
×
47
from random import randint
×
48

49
from android import activity, mActivity
×
50
from jnius import autoclass, cast, JavaException
×
51
from plyer.facades import FileChooser
×
52
from plyer import storagepath
×
53

54
Environment = autoclass("android.os.Environment")
×
55
String = autoclass('java.lang.String')
×
56
Intent = autoclass('android.content.Intent')
×
57
Activity = autoclass('android.app.Activity')
×
58
DocumentsContract = autoclass('android.provider.DocumentsContract')
×
59
ContentUris = autoclass('android.content.ContentUris')
×
60
Uri = autoclass('android.net.Uri')
×
61
Long = autoclass('java.lang.Long')
×
62
IMedia = autoclass('android.provider.MediaStore$Images$Media')
×
63
VMedia = autoclass('android.provider.MediaStore$Video$Media')
×
64
AMedia = autoclass('android.provider.MediaStore$Audio$Media')
×
65
Files = autoclass('android.provider.MediaStore$Files')
×
66
FileOutputStream = autoclass('java.io.FileOutputStream')
×
67

68

69
class AndroidFileChooser(FileChooser):
×
70
    '''
71
    FileChooser implementation for Android using
72
    the built-in file browser via Intent.
73

74
    .. versionadded:: 1.4.0
75
    '''
76

77
    # filechooser activity <-> result pair identification
78
    select_code = None
×
79
    save_code = None
×
80

81
    # default selection value
82
    selection = None
×
83

84
    # select multiple files
85
    multiple = False
×
86

87
    # mime types
88
    mime_type = {
×
89
        "doc": "application/msword",
90
        "docx": "application/vnd.openxmlformats-officedocument." +
91
                "wordprocessingml.document",
92
        "ppt": "application/vnd.ms-powerpoint",
93
        "pptx": "application/vnd.openxmlformats-officedocument." +
94
                "presentationml.presentation",
95
        "xls": "application/vnd.ms-excel",
96
        "xlsx": "application/vnd.openxmlformats-officedocument." +
97
                "spreadsheetml.sheet",
98
        "text": "text/*",
99
        "pdf": "application/pdf",
100
        "zip": "application/zip",
101
        "image": "image/*",
102
        "video": "video/*",
103
        "audio": "audio/*",
104
        "application": "application/*"}
105

106
    selected_mime_type = None
×
107

108
    def __init__(self, *args, **kwargs):
×
109
        super().__init__(*args, **kwargs)
×
110
        self.select_code = randint(123456, 654321)
×
111
        self.save_code = randint(123456, 654321)
×
112
        self.selection = None
×
113

114
        # bind a function for a response from filechooser activity
115
        activity.bind(on_activity_result=self._on_activity_result)
×
116

117
    @staticmethod
×
118
    def _handle_selection(selection):
×
119
        '''
120
        Dummy placeholder for returning selection from
121
        ``android.app.Activity.onActivityResult()``.
122

123
        .. versionadded:: 1.4.0
124
        '''
125
        return selection
×
126

127
    def _open_file(self, **kwargs):
×
128
        '''
129
        Running Android Activity is non-blocking and the only call
130
        that blocks is onActivityResult running in GUI thread
131

132
        .. versionadded:: 1.4.0
133
        '''
134

135
        # set up selection handler
136
        # startActivityForResult is async
137
        # onActivityResult is sync
138
        self._handle_selection = kwargs.pop(
×
139
            'on_selection', self._handle_selection
140
        )
141
        self.selected_mime_type = \
×
142
            kwargs.pop("filters")[0] if "filters" in kwargs else ""
143

144
        # create Intent for opening
145
        file_intent = Intent(Intent.ACTION_GET_CONTENT)
×
146
        if not self.selected_mime_type or \
×
147
            type(self.selected_mime_type) != str or \
148
                self.selected_mime_type not in self.mime_type:
149
            file_intent.setType("*/*")
×
150
        else:
151
            file_intent.setType(self.mime_type[self.selected_mime_type])
×
152
        file_intent.addCategory(
×
153
            Intent.CATEGORY_OPENABLE
154
        )
155

156
        # use putExtra to allow multiple file selection
157
        if kwargs.get('multiple', self.multiple):
×
158
            file_intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, True)
×
159

160
        # start a new activity from PythonActivity
161
        # which creates a filechooser via intent
162
        mActivity.startActivityForResult(
×
163
            Intent.createChooser(file_intent, cast(
164
                'java.lang.CharSequence',
165
                String("FileChooser")
166
            )),
167
            self.select_code
168
        )
169

170
    def _save_file(self, **kwargs):
×
171
        self._save_callback = kwargs.pop("callback")
×
172

173
        title = kwargs.pop("title", None)
×
174

175
        self.selected_mime_type = \
×
176
            kwargs.pop("filters")[0] if "filters" in kwargs else ""
177

178
        file_intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
×
179
        if not self.selected_mime_type or \
×
180
            type(self.selected_mime_type) != str or \
181
                self.selected_mime_type not in self.mime_type:
182
            file_intent.setType("*/*")
×
183
        else:
184
            file_intent.setType(self.mime_type[self.selected_mime_type])
×
185
        file_intent.addCategory(
×
186
            Intent.CATEGORY_OPENABLE
187
        )
188

189
        if title:
×
190
            file_intent.putExtra(Intent.EXTRA_TITLE, title)
×
191

192
        mActivity.startActivityForResult(
×
193
            Intent.createChooser(file_intent, cast(
194
                'java.lang.CharSequence',
195
                String("FileChooser")
196
            )),
197
            self.save_code
198
        )
199

200
    def _on_activity_result(self, request_code, result_code, data):
×
201
        '''
202
        Listener for ``android.app.Activity.onActivityResult()`` assigned
203
        via ``android.activity.bind()``.
204

205
        .. versionadded:: 1.4.0
206
        '''
207

208
        # bad data
209
        if data is None:
×
210
            return
×
211

212
        if result_code != Activity.RESULT_OK:
×
213
            # The action had been cancelled.
214
            return
×
215

216
        if request_code == self.select_code:
×
217
            selection = []
×
218
            # Process multiple URI if multiple files selected
219
            try:
×
220
                for count in range(data.getClipData().getItemCount()):
×
221
                    ele = self._resolve_uri(
×
222
                        data.getClipData().getItemAt(count).getUri()) or []
223
                    selection.append(ele)
×
224
            except Exception:
×
225
                selection = [self._resolve_uri(data.getData()), ]
×
226

227
            # return value to object
228
            self.selection = selection
×
229
            # return value via callback
230
            self._handle_selection(selection)
×
231

232
        elif request_code == self.save_code:
×
233
            uri = data.getData()
×
234

235
            with mActivity.getContentResolver().openFileDescriptor(
×
236
                uri, "w"
237
            ) as pfd:
238
                with FileOutputStream(
×
239
                    pfd.getFileDescriptor()
240
                ) as fileOutputStream:
241
                    # return value via callback
242
                    self._save_callback(fileOutputStream)
×
243

244
    @staticmethod
×
245
    def _handle_external_documents(uri):
×
246
        '''
247
        Selection from the system filechooser when using ``Phone``
248
        or ``Internal storage`` or ``SD card`` option from menu.
249

250
        .. versionadded:: 1.4.0
251
        '''
252

253
        file_id = DocumentsContract.getDocumentId(uri)
×
254
        file_type, file_name = file_id.split(':')
×
255

256
        # internal SD card mostly mounted as a files storage in phone
257
        internal = storagepath.get_external_storage_dir()
×
258

259
        # external (removable) SD card i.e. microSD
260
        external = storagepath.get_sdcard_dir()
×
261
        try:
×
262
            external_base = basename(external)
×
263
        except TypeError:
×
264
            external_base = basename(internal)
×
265

266
        # resolve sdcard path
267
        sd_card = internal
×
268

269
        # because external might have /storage/.../1 or other suffix
270
        # and file_type might be only a part of the real folder in /storage
271
        if file_type in external_base or external_base in file_type:
×
272
            sd_card = external
×
273
        elif file_type == "home":
×
274
            sd_card = join(Environment.getExternalStorageDirectory(
×
275
            ).getAbsolutePath(), Environment.DIRECTORY_DOCUMENTS)
276

277
        return join(sd_card, file_name)
×
278

279
    @staticmethod
×
280
    def _handle_media_documents(uri):
×
281
        '''
282
        Selection from the system filechooser when using ``Images``
283
        or ``Videos`` or ``Audio`` option from menu.
284

285
        .. versionadded:: 1.4.0
286
        '''
287

288
        file_id = DocumentsContract.getDocumentId(uri)
×
289
        file_type, file_name = file_id.split(':')
×
290
        selection = '_id=?'
×
291

292
        if file_type == 'image':
×
293
            uri = IMedia.EXTERNAL_CONTENT_URI
×
294
        elif file_type == 'video':
×
295
            uri = VMedia.EXTERNAL_CONTENT_URI
×
296
        elif file_type == 'audio':
×
297
            uri = AMedia.EXTERNAL_CONTENT_URI
×
298

299
        # Other file type was selected (probably in the Documents folder)
300
        else:
301
            uri = Files.getContentUri("external")
×
302

303
        return file_name, selection, uri
×
304

305
    @staticmethod
×
306
    def _handle_downloads_documents(uri):
×
307
        '''
308
        Selection from the system filechooser when using ``Downloads``
309
        option from menu. Might not work all the time due to:
310

311
        1) invalid URI:
312

313
        jnius.jnius.JavaException:
314
            JVM exception occurred: Unknown URI:
315
            content://downloads/public_downloads/1034
316

317
        2) missing URI / android permissions
318

319
        jnius.jnius.JavaException:
320
            JVM exception occurred:
321
            Permission Denial: reading
322
            com.android.providers.downloads.DownloadProvider uri
323
            content://downloads/all_downloads/1034 from pid=2532, uid=10455
324
            requires android.permission.ACCESS_ALL_DOWNLOADS,
325
            or grantUriPermission()
326

327
        Workaround:
328
            Selecting path from ``Phone`` -> ``Download`` -> ``<file>``
329
            (or ``Internal storage``) manually.
330

331
        .. versionadded:: 1.4.0
332
        '''
333

334
        # known locations, differ between machines
335
        downloads = [
×
336
            'content://downloads/public_downloads',
337
            'content://downloads/my_downloads',
338

339
            # all_downloads requires separate permission
340
            # android.permission.ACCESS_ALL_DOWNLOADS
341
            'content://downloads/all_downloads'
342
        ]
343

344
        file_id = DocumentsContract.getDocumentId(uri)
×
345
        try_uris = [
×
346
            ContentUris.withAppendedId(
347
                Uri.parse(down), Long.valueOf(file_id)
348
            )
349
            for down in downloads
350
        ]
351

352
        # try all known Download folder uris
353
        # and handle JavaExceptions due to different locations
354
        # for content:// downloads or missing permission
355
        path = None
×
356
        for down in try_uris:
×
357
            try:
×
358
                path = AndroidFileChooser._parse_content(
×
359
                    uri=down, projection=['_data'],
360
                    selection=None,
361
                    selection_args=None,
362
                    sort_order=None
363
                )
364

365
            except JavaException:
×
366
                import traceback
×
367
                traceback.print_exc()
×
368

369
            # we got a path, ignore the rest
370
            if path:
×
371
                break
×
372

373
        # alternative approach to Downloads by joining
374
        # all data items from Activity result
375
        if not path:
×
376
            for down in try_uris:
×
377
                try:
×
378
                    path = AndroidFileChooser._parse_content(
×
379
                        uri=down, projection=None,
380
                        selection=None,
381
                        selection_args=None,
382
                        sort_order=None,
383
                        index_all=True
384
                    )
385

386
                except JavaException:
×
387
                    import traceback
×
388
                    traceback.print_exc()
×
389

390
                # we got a path, ignore the rest
391
                if path:
×
392
                    break
×
393
        return path
×
394

395
    def _resolve_uri(self, uri):
×
396
        '''
397
        Resolve URI input from ``android.app.Activity.onActivityResult()``.
398

399
        .. versionadded:: 1.4.0
400
        '''
401

402
        uri_authority = uri.getAuthority()
×
403
        uri_scheme = uri.getScheme().lower()
×
404

405
        path = None
×
406
        file_name = None
×
407
        selection = None
×
408
        downloads = None
×
409

410
        # This does not allow file selected from google photos or gallery
411
        # or even any other file explorer to work
412
        # not a document URI, nothing to convert from
413
        # if not DocumentsContract.isDocumentUri(mActivity, uri):
414
        #     return path
415

416
        if uri_authority == 'com.android.externalstorage.documents':
×
417
            return self._handle_external_documents(uri)
×
418

419
        # in case a user selects a file from 'Downloads' section
420
        # note: this won't be triggered if a user selects a path directly
421
        #       e.g.: Phone -> Download -> <some file>
422
        elif uri_authority == 'com.android.providers.downloads.documents':
×
423
            path = downloads = self._handle_downloads_documents(uri)
×
424

425
        elif uri_authority == 'com.android.providers.media.documents':
×
426
            file_name, selection, uri = self._handle_media_documents(uri)
×
427

428
        # parse content:// scheme to path
429
        if uri_scheme == 'content' and not downloads:
×
430
            try:
×
431
                path = self._parse_content(
×
432
                    uri=uri, projection=['_data'], selection=selection,
433
                    selection_args=file_name, sort_order=None
434
                )
435
            except JavaException:  # handles array error for selection_args
×
436
                path = self._parse_content(
×
437
                    uri=uri, projection=['_data'], selection=selection,
438
                    selection_args=[file_name], sort_order=None
439
                )
440

441
        # nothing to parse, file:// will return a proper path
442
        elif uri_scheme == 'file':
×
443
            path = uri.getPath()
×
444

445
        return path
×
446

447
    @staticmethod
×
448
    def _parse_content(
×
449
            uri, projection, selection, selection_args, sort_order,
450
            index_all=False
451
    ):
452
        '''
453
        Parser for ``content://`` URI returned by some Android resources.
454

455
        .. versionadded:: 1.4.0
456
        '''
457

458
        result = None
×
459
        resolver = mActivity.getContentResolver()
×
460
        read = Intent.FLAG_GRANT_READ_URI_PERMISSION
×
461
        write = Intent.FLAG_GRANT_READ_URI_PERMISSION
×
462
        persist = Intent.FLAG_GRANT_READ_URI_PERMISSION
×
463

464
        # grant permission for our activity
465
        mActivity.grantUriPermission(
×
466
            mActivity.getPackageName(),
467
            uri,
468
            read | write | persist
469
        )
470

471
        if not index_all:
×
472
            cursor = resolver.query(
×
473
                uri, projection, selection,
474
                selection_args, sort_order
475
            )
476

477
            idx = cursor.getColumnIndex(projection[0])
×
478
            if idx != -1 and cursor.moveToFirst():
×
479
                result = cursor.getString(idx)
×
480
        else:
481
            result = []
×
482
            cursor = resolver.query(
×
483
                uri, projection, selection,
484
                selection_args, sort_order
485
            )
486
            while cursor.moveToNext():
×
487
                for idx in range(cursor.getColumnCount()):
×
488
                    result.append(cursor.getString(idx))
×
489
            result = '/'.join(result)
×
490
        return result
×
491

492
    def _file_selection_dialog(self, **kwargs):
×
493
        mode = kwargs.pop('mode', None)
×
494
        if mode == 'open':
×
495
            self._open_file(**kwargs)
×
496
        elif mode == 'save':
×
497
            self._save_file(**kwargs)
×
498

499

500
def instance():
×
501
    return AndroidFileChooser()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc