Чтение запись файлов в API 30 (Android 11) в Kivy
Пишу приложение на Python/Kivy (недавно исп.версию 1.11.1, теперь 2.0.0). В позапрошлом году вышел Android 10 (он же Andoid Q, api 29), в 2020 году вышел Android 11 (он же Android R, api 30). Начиная с этих версий гугл установил ограничения на чтения запись файлов, так скажем теперь файлы можно читать или записывать только после получения определенных прав, в специально отведенных местах, и в определенной последовательности (теперь нужно использовать личную папку приложения, где приложение естественно может спокойно "мусорить", а из публичных директорий только в тех, которые относятся к ScopedStorage для устройств с API >= 29). Свободно работать с файлами в /storage/emulated/0 и /sdcard уже не получится.
Только какая последовательность я понять толком не могу. Массу примеров пересмотрел (чаще всего рою именно англ. и прочие иностранные сайты), но теперь кажется, неужели никто не пишет приложений, где обязательно нужно предоставить пользователям публичный доступ к своим файлам проектов (делиться ими или открывать их). Не хочется думать, что kivy создан только для hello world, проверялок рут-прав, игр, и прочих приложений, спокойно обходящиеся внутренней папкой. К тому же api 30 вообще просто обязал разработчиков перейти на ScopedStorage. Мне кажется что я просто что-то упускаю из вида.
В манифесте присутствуют и WRITE_EXTERNAL_STORAGE и READ_EXTERNAL_STORAGE.
Собираю на Ubuntu 18 / buildozer, собирается все нормально. Но как ни крути при попытке получить данные из файла, выдает ошибку 13 Permission denied.
Из наиболее удачной информации я нашел:
https://github.com/niharika2810/ScopedStorageDemo/blob/master/app/src/main/java/com/sample/scopedstorage/activities/MainActivity.kt
https://developer.android.com/training/secure-file-sharing/request-file.html
Такое ощущение что во враппере python-for-android не учтены последние изменения из api 30, которые с такой легкостью пишутся на android studio.
Сам пробую достучаться до файлов средствами pyjnius. Вроде как преобразую java код в python, права программа получает, а в итоге когда пытаюсь уже прочесть файл, все равно пишет Permission denied.
Вопрос такой: Каким образом в связке python-kivy-p4a-jnius можно получить данные из текстового файла для API 30, чтобы приложение не выдавало ошибку #13 Permission denied?
Часть моего кода (в основном использовал код с kivy, где один человек наилучшим образом (как мне кажется) структурировал использование callback для startActivityForResult под python/kivy):
в начале main.py:
if platform == 'android':
from kivy.logger import Logger
from kivy.clock import Clock
from jnius import autoclass
from jnius import cast
from android import activity
from android.permissions import Permission, request_permissions, check_permission
Activity = autoclass('android.app.Activity')
PythonActivity = autoclass("org.kivy.android.PythonActivity")
Intent = autoclass('android.content.Intent')
Uri = autoclass('android.net.Uri')
File = autoclass('java.io.File')
Env = autoclass('android.os.Environment')
MediaStore_Images_Media_DATA = "_data" # Value of MediaStore.Images.Media.DATA
# Custom request codes
RESULT_LOAD_DOC = 1
def permissions_callback(permissions, results):
print('inside permissions_callback')
if all([res for res in results]):
print('Got all permissions')
permissions_granted = True
else:
print('Did not get all permissions')
def get_permissions():
request_permissions([
Permission.WRITE_EXTERNAL_STORAGE,
Permission.READ_EXTERNAL_STORAGE,
Permission.INTERNET],
permissions_callback)
def user_select_doc(callback):
"""Open File chooser and call callback with absolute filepath of document user selected.
None if user canceled.
"""
currentActivity = cast('android.app.Activity', PythonActivity.mActivity)
context = cast('android.content.ContextWrapper', currentActivity.getApplicationContext())
file_p = cast('java.io.File', context.getExternalFilesDir(Env.DIRECTORY_DOCUMENTS))
def on_activity_result(request_code, result_code, intent):
if request_code != RESULT_LOAD_DOC:
Logger.warning('user_select_doc: ignoring activity result that was not RESULT_LOAD_DOC')
return
if result_code == Activity.RESULT_CANCELED:
Clock.schedule_once(lambda dt: callback(None), 0)
return
if result_code != Activity.RESULT_OK:
# This may just go into the void...
raise NotImplementedError('Unknown result_code "{}"'.format(result_code))
selectedFile = intent.getData(); # Uri
filePathColumn = [MediaStore_Images_Media_DATA]; # String[]
# Cursor
cursor = currentActivity.getContentResolver().query(selectedFile, filePathColumn, None, None, None)
cursor.moveToFirst()
# int
columnIndex = cursor.getColumnIndex(filePathColumn[0]);
# String
docPath = cursor.getString(columnIndex);
cursor.close();
Logger.info('android_ui: user_select_doc() selected %s', docPath)
Clock.schedule_once(lambda dt: callback(docPath), 0)
activity.bind(on_activity_result = on_activity_result)
intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.setType("*/*")
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, False)
intent.setAction(Intent.ACTION_GET_CONTENT)
# currentActivity = cast('android.app.Activity', PythonActivity.mActivity)
# chooser = Intent.createChooser(intent, 'Select TXT')
# currentActivity.startActivityForResult(chooser, RESULT_LOAD_DOC)
currentActivity.startActivityForResult(intent, RESULT_LOAD_DOC)
def load(filename):
with io.open(filename, encoding='utf-8') as file:
data = None
try:
data = json.load(file)
except:
print('JSON not loaded')
return False
Далее для какой-нибудь кнопки:
def on_load(self, button):
if platform == 'android':
if check_permission("android.permission.WRITE_EXTERNAL_STORAGE") \
and check_permission("android.permission.READ_EXTERNAL_STORAGE") \
and check_permission("android.permission.INTERNET"):
# if permissions_granted: # variant
user_select_doc(load)
else:
get_permissions()
Ответы (1 шт):
Кажется, проблема решена. Я не был достаточно осведомлен, чем богат ContentResolver. Спасибо blackapps (из англоязычного stackoverflow) за подсказку, в каком направлении искать.
С помощью currentActivity.getContentResolver().openInputStream(intent.getData()) я смог получить бинарные данные выбранного файла и теперь с этими данными я могу делать что захочу.
Таким образом, на текущий момент мое решение для открытия файлов (в дополнение к коду выше):
# От полученного Uri я получаю поток данных
selectedUri = intent.getData()
docStream = currentActivity.getContentResolver().openInputStream(selectedUri)
# Из потока получаю int-массив
ints = []
intVal = docStream.read()
while intVal != -1:
ints.append(intVal)
intVal = docStream.read()
# Массив преобразую в байты и передаю в callback функцию
docBytes = bytes(b % 256 for b in ints)
Clock.schedule_once(lambda dt: callback(docBytes), 0)
А решение для сохранения файлов (обратно в тот же файл либо через "сохранить как") оказалось немного более замороченным для меня. Не обошлось без FileDescriptor'а. Такая функция вызова окна сохранения андроида (в сущности, меняется только intent):
def android_dialog_save_doc(save_callback):
currentActivity = cast('android.app.Activity', PythonActivity.mActivity)
def on_activity_save(request_code, result_code, intent):
if request_code != RESULT_SAVE_DOC:
Logger.warning('android_dialog_save_doc: ignoring activity result that was not RESULT_SAVE_DOC')
return
if result_code == Activity.RESULT_CANCELED:
Clock.schedule_once(lambda dt: save_callback(None), 0)
return
if result_code != Activity.RESULT_OK:
# This may just go into the void...
raise NotImplementedError('Unknown result_code "{}"'.format(result_code))
selectedUri = intent.getData() # Uri
filePathColumn = [MediaStore_Images_Media_DATA] # String
# Cursor
cursor = currentActivity.getContentResolver().query(selectedUri, filePathColumn, None, None, None)
cursor.moveToFirst()
# Если нужно получить путь в документу, хотя я использовал selectedUri.getPath()
# columnIndex = cursor.getColumnIndex(filePathColumn[0]) # int
# docPath = cursor.getString(columnIndex) # String
cursor.close()
Logger.info('android_ui: android_dialog_save_doc() selected %s', selectedUri.getPath())
Clock.schedule_once(lambda dt: save_callback(selectedUri), 0)
activity.bind(on_activity_result = on_activity_save)
# Здесь другой Intent в отличие от получения файла
intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.setType('*/*')
currentActivity.startActivityForResult(intent, RESULT_SAVE_DOC)
Callback функция, передаваемая как android_dialog_save_doc(android_do_saving):
def android_do_saving(uri):
currentActivity = cast('android.app.Activity', PythonActivity.mActivity)
# Просто какая-то функция, которая возвращает бинарные данные откуда-нибудь для записи в uri
bytedata = get_bytes()
if bytedata != None:
try:
# Для записи важно получить ParcelFileDescriptor от ContentResolver'а ...
pfd = currentActivity.getContentResolver().openFileDescriptor(uri, "w")
# ... потому что от ParcelFileDescriptor'а нужно использовать функцию getFileDescriptor(),
# которая позволит нам создать FileOutputStream (а не просто OutputStream), ...
fos = FileOutputStream(pfd.getFileDescriptor())
# ... потому что FileOutputStream может обратиться к каналу OutputStream'а,
fos_ch = fos.getChannel()
# ... чтобы после записи данных в файл ...
fos.write(bytedata)
# ... этот канал смог подрезать лишние байты, если количество вновь записываемых байтов оказалось меньше, чем было в перезаписываемом файле.
fos_ch.truncate(len(bytedata))
fos.close()
# Сохраняю uri на случай быстрой перезаписи, чтобы потом не открывать каждый раз окно выбора файла андроида
openedUri = uri
print('Saving bytedata succeeded.')
except:
print('Saving bytedata failed.')