[Kivy]Androidアプリ ファイル保存先選択

プログラミング

ファイルの保存先選択

Kivyを使ってAndroidで動くカメラアプリを作っていたところ、撮影した画像をポンポン本体に保存していたらすぐにAndroid本体の容量が足りなくなってしまうので、SDカードに保存されるようにしたくなりました。

保存先をSDカードにする方法を調べてみたところ、アプリで勝手にSDカードに保存先を作ることは出来なさそう(たしかに言われてみれば色々問題ありそう)。本体以外に保存するためにはユーザーで保存先を選べるようにする必要がありそう。

ということで、保存先を選択するやり方を実装サンプルとしてメモしておきます。

from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy import platform
from kivy.clock import Clock
from kivy.graphics.texture import Texture
from kivy.uix.camera import Camera
import time
import cv2
import numpy as np
from kivy.properties import StringProperty
from kivy.uix.popup import Popup


from kivy.core.window import Window

currentActivity = None
CLS_Activity = None
CLS_Intent = None
CLS_Bundle = None

REQUEST_STORAGE_ACCESS = 1

class SaveToSelect(BoxLayout):

    def play(self, button):

        self.cam = Camera(index=0)
        self.cam.play = True
        Clock.schedule_interval(self.update, 1.0 / 30)
        button.disabled = True
        self.ids.capture_button.disabled = False

    def update(self, dt):
        height, width = self.cam.texture.height, self.cam.texture.width
        capture = np.frombuffer(self.cam.texture.pixels, np.uint8)
        capture = capture.reshape(height, width, 4)
        frame = capture[:,:,:3]
        if type(frame).__module__ == 'numpy':
            ret = True
        if platform == "android":
            frame = cv2.flip(frame, 0)
            frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
        self.image_data = frame
        if ret:
            buf = cv2.flip(frame, 0)
            image_texture = Texture.create(size=(frame.shape[1], frame.shape[0]), colorfmt='rgb')
            image_texture.blit_buffer(buf.tostring(), colorfmt='rgb', bufferfmt='ubyte')
            self.ids.camera_view.texture = image_texture

    def capture(self):
        
        if self.image_data is not None:
            timestr = time.strftime("%Y%m%d_%H%M%S")
            filename = "output_{}.png".format(timestr)
            image_data = cv2.cvtColor(self.image_data, cv2.COLOR_RGB2BGR) # openCVの色の並びはBGR
            self.android_write_storage(filename, image_data)

    def file_save_callback(self, filepath):
        if filepath != '':
            message='save'
        else:
            message='cancel'
        self.popup = MessagePopup(title='', message=message, separator_height=0)
        self.popup.open()

    def android_write_storage(self, file_name, image_data):
        if platform == "android":
            self.save_image_data = image_data
            from android import activity
            # 保存先選択
            intent = CLS_Intent(CLS_Intent.ACTION_CREATE_DOCUMENT)
            intent.setType('image/png')
            bundle = CLS_Bundle()
            bundle.putString(CLS_Intent.EXTRA_TITLE, file_name)
            intent.putExtras(bundle)
            intent.addCategory(CLS_Intent.CATEGORY_OPENABLE)
            currentActivity.startActivityForResult(intent, REQUEST_STORAGE_ACCESS)
            activity.unbind(on_activity_result=self.on_activity_result)
            activity.bind(on_activity_result=self.on_activity_result)

    def on_activity_result(self, request_code, result_code, intent):
        try:
            if request_code == REQUEST_STORAGE_ACCESS:
                if result_code == CLS_Activity.RESULT_CANCELED:
                    # キャンセル
                    Clock.schedule_once(lambda dt: self.file_save_callback(''), 0)

                if result_code != CLS_Activity.RESULT_OK:
                    raise NotImplementedError('Unknown result_code "{}"'.format(result_code))

                treeUri = intent.getData()  # Uri
                if treeUri is None:
                    raise NotImplementedError('no data')

                filepath = treeUri.getPath() # String

                outputStream = currentActivity.getContentResolver().openOutputStream(treeUri)
                # ストリームに書き出し
                retval, buf = cv2.imencode('.png', self.save_image_data)
                outputStream.write(buf.tobytes())

                Clock.schedule_once(lambda dt: self.file_save_callback(filepath), 0)

        finally:
            from android import activity
            activity.unbind(on_activity_result=self.on_activity_result)

class MessagePopup(Popup):
    message = StringProperty('')
    ok_text = StringProperty('OK')

    __events__ = ('on_ok',)

    def ok(self):
        self.dispatch('on_ok')
        self.dismiss()
    def on_ok(self):
        pass
    def __init__(self, **kwargs) -> None:
        super(MessagePopup, self).__init__(**kwargs)
        self.size_hint = (0.8, 0.3)
        self.pos_hint={'center_x':0.5, 'center_y':0.5}
        self.auto_dismiss = False

class TestSaveToSelectApp(App):

    def build(self):
        if platform == 'android':
            Window.bind(on_keyboard=self.key_input)
            
            from jnius import autoclass, cast
            
            global currentActivity
            global CLS_Activity
            global CLS_Intent
            global CLS_Bundle

            PythonActivity = autoclass('org.kivy.android.PythonActivity')
            currentActivity = cast('android.app.Activity', PythonActivity.mActivity)
            CLS_Activity = autoclass('android.app.Activity')
            CLS_Intent = autoclass('android.content.Intent')
            CLS_Bundle = autoclass('android.os.Bundle')

            from android.permissions import request_permissions, Permission
            request_permissions([Permission.READ_EXTERNAL_STORAGE,
                                 Permission.MANAGE_DOCUMENTS,
                                 Permission.CAMERA,
                                 Permission.WRITE_EXTERNAL_STORAGE])

        return SaveToSelect()
    
    def key_input(self, window, key, scancode, codepoint, modifier):
        if key == 27:
            return True
        else:
            return False

if __name__ == '__main__':
    TestSaveToSelectApp().run()

確認用のKVファイル

<SaveToSelect>:
    camera_view:camera_view
    BoxLayout:
        orientation: 'vertical'
        Image:
            id: camera_view
        Button:
            text: 'play'
            size_hint_y: 0.1
            on_press: root.play(self)
        Button:
            id:capture_button
            text: 'capture'
            size_hint_y: 0.1
            disabled: True
            on_press: root.capture()

<MessagePopup>:
    FloatLayout:
        Label:
            size_hint: 0.8, 0.6
            pos_hint: {'x': 0.1, 'y':0.4}
            text: root.message
            text_size: self.size
            halign: 'center'
            valign: 'middle'
        Button:
            size_hint: 0.4, 0.35
            pos_hint: {'x':0.5, 'y':0.05}
            text: root.ok_text
            on_release: root.ok()

サンプルソースが長くなってしまいましたが、メインとなる関数は下記2つです。

・android_write_storage:保存先選択ツールを開きます。

・on_activity_result:選択から戻るときの処理。選択された場所にデータを出力します。

REQUEST_STORAGE_ACCESS は定数にしていますが数値は任意です。request_code はどこから戻ってきたのかを判別するもので、startActivityForResult に渡した値と同じ値で返ってきます。

あと必要なのが、Android API にアクセスするため Pyjnius を使っています。サンプルでは build 内で分岐させていますが頭のグローバル変数宣言時に書いてもいいと思います。

from jnius import autoclass, cast

PythonActivity = autoclass('org.kivy.android.PythonActivity')
currentActivity = cast('android.app.Activity', PythonActivity.mActivity)
Activity = autoclass('android.app.Activity')
Intent = autoclass('android.content.Intent')
Bundle = autoclass('android.os.Bundle')

※サンプルで変数名についてる”CLS_”は、ラップしたJavaクラスだと分かりやすくするために適当につけただけで深い意味はないです。

使い方は、サンプルのcapture関数で呼んでいるように android_write_storage にデフォルトのファイル名とデータを渡してあげるだけです。機種によって見え方は違うと思いますが任意のファイル名で保存先を選ぶことができます。

保存先選択イメージ

今回は画像保存に限定しているので、intent.setTypeでPNG指定をしていますが、目的に応じてMIMEタイプを設定してあげれば、他の形式のファイル保存にも応用が利くと思います。(多分)

on_activity_resultの中で出力データをByteデータをストリームに渡して書き込みます。サンプルではデータをOpenCVで扱っていますので imencode を使用しています。画像の扱い方に応じてByteデータで渡せるように調整してください。

file_save_callback の関数に保存後の処理を書いています。呼び出し元と別クラスにしてファイル選択処理を実装する場合は、コールバック関数として渡す形になるかと思います。

ちなみに、サンプルでコールバック関数にファイルパスを返していますが、そのパスを使った画像表示は出来きませんでした。保存先のパスを使って何かしたい場合は、アプリから参照できるパスにするためにもうひと手間必要かもしれません。(必要になったら調べて、うまくできたら投稿するかも)

他のサンプル部分について今回の主旨とは外れますが、動作確認のために、カメラからの画像取得メッセージ通知のためのクラスを使っています。これらについての使い方は別の記事で書いていますので気になる方はご覧ください。(宣伝)

以上、保存先選択の実装サンプルでした。

(参考)

Android10以降のファイル保存の本当に正しいやり方【ACTION_CREATE_DOCUMENT】 | PisukeCode - Web開発まとめ
Android10以降のファイル保存について。Android11からストレージ周りの大幅アプデがあり、「対象範囲別ストレージ」などの影響で今までのように好き勝手保存できなくなります。その対処法とか正しいファイル保存について解説します。

(おまけ)Androidアクセス用クラス例

上の説明の中で、request_codeでon_activity_result がどこから戻ってきたものかを判別できると書きました。

別の投稿(ギャラリー選択)で、同じようにAndroid API にアクセスしてon_activity_resultで受け取る実装方法をあげていますが、Android API にアクセスする共通クラスとしてまとめる例を載せてみます。(クラスとそれに使っている共通変数以外は省略します。)

from jnius import autoclass, cast

PythonActivity = autoclass('org.kivy.android.PythonActivity')
currentActivity = cast('android.app.Activity', PythonActivity.mActivity)
Activity = autoclass('android.app.Activity')
Intent = autoclass('android.content.Intent')
Bundle = autoclass('android.os.Bundle')
ImagesMedia = autoclass('android.provider.MediaStore$Images$Media')

REQUEST_GALLERY = 1
REQUEST_STORAGE_ACCESS = 2
MediaStore_Images_Media_DATA = '_data'

class AndroidAccess():

    def __init__(self, imege_select_callback, file_save_callback):
        self.imege_select_callback = imege_select_callback
        self.file_save_callback = file_save_callback

    def android_select_image(self):
        if platform == "android":
            from android import activity
            # イメージギャラリー
            intent = Intent(Intent.ACTION_PICK,ImagesMedia.EXTERNAL_CONTENT_URI)
            currentActivity.startActivityForResult(intent, REQUEST_GALLERY)
            activity.unbind(on_activity_result=self.on_activity_result)
            activity.bind(on_activity_result=self.on_activity_result)

    def android_write_storage(self, file_name, image_data):
        if platform == 'android':
            self.image_data = image_data
            from android import activity

            intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
            intent.setType('image/png')
            bundle = Bundle()
            bundle.putString(Intent.EXTRA_TITLE, file_name)
            intent.putExtras(bundle)
            intent.addCategory(Intent.CATEGORY_OPENABLE)
            currentActivity.startActivityForResult(intent, REQUEST_STORAGE_ACCESS)
            activity.unbind(on_activity_result=self.on_activity_result)
            activity.bind(on_activity_result=self.on_activity_result)

    def on_activity_result(self, request_code, result_code, intent):
        try:
            # ファイル選択
            if request_code == REQUEST_GALLERY:
                if result_code == Activity.RESULT_CANCELED:
                    # 未選択
                    Clock.schedule_once(lambda dt: self.imege_select_callback(''), 0)
                    return

                if result_code != Activity.RESULT_OK:
                    raise NotImplementedError('Unknown result_code "{}"'.format(result_code))

                selectedImage = intent.getData()  # Uri
                filePathColumn = [MediaStore_Images_Media_DATA] # String[]
                
                cursor = currentActivity.getContentResolver().query(selectedImage, filePathColumn, None, None, None) # Cursor
                cursor.moveToFirst()

                columnIndex = cursor.getColumnIndex(filePathColumn[0]) # int

                selectedPicturePath = cursor.getString(columnIndex) # String
                Clock.schedule_once(lambda dt: self.select_path(selectedPicturePath), 0)
                cursor.close()

            # 保存先選択
            if request_code == REQUEST_STORAGE_ACCESS:
                if result_code == Activity.RESULT_CANCELED:
                    # キャンセル
                    Clock.schedule_once(lambda dt: self.file_save_callback(''), 0)

                if result_code != Activity.RESULT_OK:
                    raise NotImplementedError('Unknown result_code "{}"'.format(result_code))

                treeUri = intent.getData()  # Uri
                if treeUri is None:
                    raise NotImplementedError('no data')

                filepath = treeUri.getPath()

                outputStream = currentActivity.getContentResolver().openOutputStream(treeUri)
                # ストリームに書き出し
                # retval, buf = cv2.imencode('.png', self.image_data)
                outputStream.write(buf.tobytes())

                Clock.schedule_once(lambda dt: self.file_save_callback(filepath), 0)
        finally:
            from android import activity
            activity.unbind(on_activity_result=self.on_activity_result)

ソースの内容はすでに書いているサンプルとほとんど同じですが、例のように startActivityForResult に渡す値を別のものにすることで、request_codeに入ってくる値で判別して処理を両立できます。

※ストリームに書き出しの部分については、データの扱い方に応じてうまくByteデータを渡すよう調整してください。

例ではクラスをインスタンス化する際に、選択後の処理、保存後の処理をそれぞれ渡すようにしています。一応、簡単な使い方イメージも書いてみます。

    # クラスインスタンス化
    self.AA = AndroidAccess(self.imege_select_callback, self.file_save_callback)
    # ファイル選択
    self.AA.android_select_image()
    # ファイル保存
    self.AA.android_write_storage(filename, image_data)

def imege_select_callback(self, filepath):
    # ファイル選択後の処理

def file_save_callback(self, filepath):
    # ファイル保存後の処理

使うときはいろいろ調整が必要と思いますが、参考になれば幸いです。

コメント

タイトルとURLをコピーしました