Site cover image

ふつうのITエンジニアの独り言

本業はAndroidとiPhoneのアプリ開発のエンジニアです。将来はフリーランスで海の近くで妻とのんびり暮らすことを夢見て、幅広くIT技術に触れていきたいと思います。このブログはその備忘録と私のポートフォリオとして活動記録を記すものです。

Macで動作するAndroidのDevice ExplorerのようなアプリをPythonで自作する

目次


はじめに


MacとAndroidのファイル交換の事情

 MacでAndroidアプリを開発していると、Androidとファイル交換が必要になることがあります。Androidアプリを開発している人であれば、Android StudioのDevice Explorerを使うのが一般的かもしれません。また、フリーのアプリを探すと、Android File TransferやMacDroid、OpenMTPなどが見つかるので、それらを使っている人も多いと思います。私もフリーのアプリをいくつか試してみましたが、公式のサポートが終わっていたり、無料枠での制限が多かったり、接続しているAndroidを認識しないなどの使いにくさを感じていました。

 私にとっては、Androidとファイル交換するために高機能は不要で、Android Studioの提供するDevice Explorerのようなシンプルなアプリが欲しいと思っていました。かといって、ファイル交換のたびにAndroid Studioを立ち上げるのも時間がかかってしまいます。

 そこで、Android StudioのDevice ExplorerのようなシンプルなUIのアプリを、Pythonを使って自作することに挑戦しました。

Device Explorerって?

 Android StudioのDevice Explorerは、標準で使える機能の一つです。Android開発のプロジェクトを作成して、実機またはエミュレータを接続している状態であれば、Androidデバイスのフォルダやファイルを確認することができ、ファイル交換や削除などを行うことができます。

 残念ながら、このDevice Explorerは非常にシンプルで使いやすく重宝しているのですが、プロジェクトを立ち上げておかないと利用できないという欠点があります。そのため、Device Explorerを利用する為だけにプロジェクトを立ち上げておくということもありました。

 ちなみに、以前はGoogleからDDMSというスタンドアローンで利用できるアプリが提供されていました。DDMSはプロジェクトを立ち上げる必要がなく、ファイル操作やlogcatの出力を確認できるなど、とても使い勝手の良いアプリでした・・・。

Device Explorerの表示例

ADB File Controller紹介


ADB File Controllerって?

 Android StudioのDevice ExplorerのようなシンプルなUIで、手軽に起動して利用できるアプリを目指して自作したアプリです。デバッグモード状態のAndroidデバイスを認識して、ADBコマンドを利用してファイル交換やファイル操作を行うことができます。

ADB File Controllerの全体像

ADB File Controllerの特徴

とにかくシンプルに

 ファイル交換を目的として余計な機能をつけず、誰でも簡単に使い始められることを目指しました。画面に表示されるのは、ディレクトリツリー構造と、ファイルサイズ、パーミッション、作成日時だけです。これらの表示は、Device Explorerに合わせています。

 ディレクトリツリーから任意のファイル/フォルダを選択して、右クリックすると実行できる操作が表示されます。ファイル操作はすべてこのメニューから実行することになります。これも、Device Explorerと同じです。

複数デバイスのリアルタイム認識

 ADB File Controllerは、ADBコマンドでデバイスを認識しています。そのためファイル交換を行うためにはAndroidデバイスをデバッグモードに切り替えておく必要があります。ファイル交換を行うためにはADBコマンドが必須になるため、この制限は外すことができませんでした。しかし、ADBコマンドで認識されさえすればアプリに即時表示されて、ファイル交換をすることができます。

認識したデバイスのIDが表示され、2台のデバイスを認識しているところ。
AndroidデバイスからのMacへの転送

 右クリックのメニューから、”Android → PC”を選択すると、保存先のフォルダを選択するFinderが表示されます。Finderからフォルダを選択すると転送が開始されます。

 複数のファイルを転送したい場合は、キーボードの[command]+選択で複数選択することができます。

[command]を押しながら選択することで、複数のファイル/フォルダを選択できる。
MacからAndroidデバイスへの転送

 右クリックのメニューから、”Android ← PC(files)”または”Android ← PC(floder)”を選択すると、MacからAndroidへファイルを転送することができます。こちらは、ディレクトリツリーからフォルダを一つだけ選択している場合にのみ利用することができます。

ファイル/フォルダ名の変更

 右クリックのメニューから、”rename”を選択すると名前を変更するダイアログが表示され、OKを押すことで名前を変更します。こちらは、ディレクトリツリーからフォルダまたはファイルを一つだけ選択している場合にのみ利用することができます。名前変更はDevice Explorerにはないオリジナルの機能となっています。

名前変更のためのダイアログが表示されている状態。OKを押すことで名前変更が実行される。
ファイル/フォルダの削除

 右クリックのメニューから、”delete”を選択すると選択したフォルダ/ファイルが削除されます。こちらは、複数選択での実行が可能となっています。

シェルの起動

 右クリックのメニューから、”adb shell”を選択すると、選択したフォルダ位置まで移動した状態でMacのターミナルが立ち上がります。直接、Androidのシェルを実行したい場合に利用することができ、これも、Device Explorerにはないオリジナルの機能です。

ADB File Controller実現の基本情報


Tkinterによる画面構築方法

 UIを構成するためにTkinterを利用しました。TkinterはPython標準ライブラリの一つで、クロスプラットフォームのアプリ開発にも利用することができます。

 私の場合は、GUIクラスを作成しTkinterを継承して利用しました。コンストラクタで画面の初期化を行い、mainloopで画面に対する操作イベントを待ちます。

from tkinter import Tk, ttk, messagebox, filedialog, Menu

class GUI(Tk):
    def __init__(self):
        super().__init__()
        self.geometry(f'{480}x{640}') # 表示サイズを設定
        self.title(f'ADB File Controller') # アプリ名設定
        
if __name__ == '__main__':
    gui = GUI()
    gui.mainloop()
画面サイズとアプリ名を指定して、画面を初期化するサンプル。

 また、Tkinterにはツリー構造を扱う機能(TreeView)も備わっています。今回は画面いっぱいにディレクトリツリーを表示する仕様なので、ttk.Treeviewを全体表示されるように、fillを’both’に設定しています。

from tkinter import Tk, ttk, messagebox, filedialog, Menu

def __init__(self):
	    # Frame
	    frame = ttk.Frame(self)
	    frame.pack(fill='both', expand=True)
	
	    # スタイルでインデント幅を変更
	    style = ttk.Style()
	    style.configure('Treeview', indent=10)
	
	    # TreeView
	    self.tree = ttk.Treeview(frame, selectmode='extended')
	    self.tree.pack(fill='both', expand=True)
	    
	    # カラム設定
	    self.tree['columns'] = ('size', 'permission', 'date')
	    self.tree.heading('#0', text='Name')
	    self.tree.heading('size', text='Size')
	    self.tree.heading('permission', text='Permission')
	    self.tree.heading('date', text='Date')

ADBコマンドの実行方法

PythonからADBコマンドを実行する

 PythonからADBコマンドを実行するために、subprocessを利用しました。今回のファイル操作では、非同期処理を行いたいケースと、同期処理を行いたいケースの2パターンが有り、これらを区別して実行することに苦労しました。

 前者の非同期処理を行うケースとしては、時間がかかる処理(ファイル転送など)を実行した時に、処理状況をダイアログに表示する状況を想定しました。ダイアログ更新を行うためには、非同期処理にする必要がありました。

 以下は、同期処理と非同期処理のサンプルになります。

import subprocess

command = ["adb", 
            "-s", f"{deviceId}",
            "shell", 
            "ls", "-l"
            ]
result = subprocess.run(command, capture_output=True, text=True)
ADBコマンドを同期で実行するサンプル。
command = ["adb", "-s",
           f"{deviceId}", 
           "pull", 
           src_path, 
           dst_path]
proc = subprocess.Popen(
    command,
    stdin=slave_fd,
    stdout=slave_fd,
    stderr=slave_fd,
    universal_newlines=True
)

# 出力をリアルタイムで読み取り表示
with os.fdopen(master_fd) as stdout:
    for line in stdout:
        print(line)
proc.wait()
ADBコマンドを非同期で実行して、出力をリアルタイムで出力するサンプル。
ADBコマンドでAndroidからファイルを取り出す

 ADBコマンドで、Androidからファイルを取り出す場合は、pullコマンドを使います。ユーザが選択したAndroidのファイルパスと、転送先のMacのフォルダパスを引数に設定して実行します。複数のファイル/フォルダを転送する場合は、forループでadbコマンドを繰り返し実行するようにしています。

adb -s <デバイスID> shell pull <src> <dst>
pullコマンドのフォーマット
graph LR
	subgraph Android
		target("指定フォルダ/ファイル")
	end
		
  target --> |pullコマンド|PC(Mac)
pullコマンドでAndroidからMacにフォルダ/ファイルを転送する。

 転送には時間がかかる場合も有るため、subprocess.Popenを利用し、転送中はダイアログにstdoutの内容を表示します。

 進捗表示のダイアログは、tk.Toplevelを継承したADBProgressDialogクラスを用意し、ダイアログは画面中央に表示されるようにしています。また、ダイアログを[x]ボタンで閉じた場合には、キャンセルされたことを通知するコールバックができるようにしました。これにより、ADB処理を中断する処理を実行することも可能になります。

import tkinter as tk
from tkinter import simpledialog

class ADBProgressDialog(tk.Toplevel):
    message = None
    parent = None
    on_cancel_callback = None

    def __init__(self, parent):
        super().__init__(parent)
        self.title("Processing")
        self.geometry("340x120")
        self.result = None

        # ラベル
        self.message = tk.Label(self, text="", wraplength=300)
        self.message.pack(pady=5)

        # ボタンフレーム
        button_frame = tk.Frame(self)
        button_frame.pack(pady=10)

        # 画面中央
        self.parent = parent
        parent_x = self.parent.winfo_rootx()
        parent_y = self.parent.winfo_rooty()
        parent_width = self.parent.winfo_width()
        parent_height = self.parent.winfo_height()
        dialog_width = 340
        dialog_height = 120
        x = parent_x + (parent_width // 2) - (dialog_width // 2)
        y = parent_y + (parent_height // 2) - (dialog_height // 2)

        self.geometry(f"{dialog_width}x{dialog_height}+{x}+{y}")

        # ウィンドウの「×」ボタンが押されたときの処理を設定
        self.protocol("WM_DELETE_WINDOW", self.on_cancel)

        # モーダルにする
        self.transient(parent)
        self.grab_set()
    #    parent.wait_window(self)

    def on_cancel(self):
        if self.on_cancel_callback != None:
            self.on_cancel_callback()

        self.result = None
        self.destroy()
    
    def set_on_cancel_callback(self, callback):
        self.on_cancel_callback = callback

    def update_message(self, is_running : bool, message : str):
        try:
            if is_running:
                self.message.config(text=message)
                self.update_idletasks()  # レイアウトを更新
                new_height = self.message.winfo_reqheight() + 60  # ラベルの高さ + ボタンなどの余白
                self.geometry(f"{self.winfo_width()}x{new_height}")
            else:
                self.destroy()
        except Exception as e:
            print(e)
adbコマンドの実行状態を表示するためのダイアログを表示
ADBコマンドでMacからファイルを転送する

 ADBコマンドで、MacからAndroidにファイルを転送する場合は、pushコマンドを使います。

adb -s <デバイスID> shell push <src> <dst>
pushコマンドのフォーマット
graph LR
	subgraph Android
		target("指定フォルダ")
	end
		
  PC(Mac) --> |pushコマンド|target
pushコマンドで直接ファイルを転送する。

 ただし、pushコマンドでは、直接フォルダにファイルを移動できない場合があり、Android OSによって制限された場所にファイルを転送しようとした時にエラーが発生します。そこで、MacからAndroidにファイルを転送する場合には、”/sdcard/Download/”に転送した後に、mvコマンドで指定のフォルダに移動させるという、2段階の操作を行うようにしました。

adb -s <デバイスID> shell mv <src> <dst>
mvコマンドのフォーマット
graph LR
	subgraph Android
		download("/sdcard/Download/")
		target("指定フォルダ")
	end
		
  PC(Mac) --> |pushコマンド|download --> |mvコマンド|target
pushのエラーを回避するために、ダウンロードフォルダを仲介する。
ADBコマンドでファイル名を変更する

 ファイル名の変更はmvコマンドで簡単に実現できます。srcとdstに同じディレクトリパスを指定して、異なるファイル名/フォルダ名を指定するだけで、名前が変更されます。

adb -s <デバイスID> shell mv <src> <dst>
mvコマンドのフォーマット

 名前変更を実行する前には、変更する名前を設定するダイアログが表示されます。名前を変更してOKを押すことで、mvコマンドが実行されますが、キャンセルや無変更でOKを押した場合は何も実行されないようになっています。

名前変更のダイアログ
import tkinter as tk

class RenameDialog(tk.Toplevel):
    def __init__(self, parent, originalText : str):
        super().__init__(parent)
        self.title("Rename")
        self.geometry("300x100")
        self.result = None

        # ラベル
        # tk.Label(self, text="テキストを入力:").pack(pady=5)

        # テキストボックス
        self.entry = tk.Entry(self, width=30)
        self.entry.pack(pady=5)
        self.entry.insert(0, originalText)

        # ボタンフレーム
        button_frame = tk.Frame(self)
        button_frame.pack(pady=10)

        # OKボタン
        ok_button = tk.Button(button_frame, text="OK", command=self.on_ok)
        ok_button.pack(side=tk.LEFT, padx=5)

        # Cancelボタン
        cancel_button = tk.Button(button_frame, text="Cancel", command=self.on_cancel)
        cancel_button.pack(side=tk.LEFT, padx=5)

        # カーソル位置に表示
        x = parent.winfo_pointerx()
        y = parent.winfo_pointery()
        self.geometry(f"+{x}+{y}")

        # モーダルにする
        self.transient(parent)
        self.grab_set()
        parent.wait_window(self)

    def on_ok(self):
        self.result = self.entry.get()
        self.destroy()

    def on_cancel(self):
        self.result = None
        self.destroy()
Renameダイアログを表示する
ADBコマンドでファイルを削除する

 ファイルの削除には、rmコマンドを使います。-rfを指定することでフォルダ以下のファイルも含めてすべて削除されます。

adb -s <デバイスID> shell rm -rf <src>
rmコマンドのフォーマット

Androidデバイス検出のポーリング方法

 ADB File Controllerでは、アプリ起動後は常にAndroidデバイスをポーリングで監視しています。ポーリングには、Tkのafter関数とスレッドを利用して1秒間隔で接続確認を行うようにしています。

 また、Androidデバイスの存在確認には、adb devicesのコマンドで確認します。

import threading

class GUI(Tk):
	def _devices_update(self):
	    # ここでADB接続確認処理を行う
	
	 # Androidデバイスの接続監視
	def _devices_update_timer(self):
	    thread = threading.Thread(target=self._devices_update)
	    thread.start()
	    self.after(1000, self._devices_update_timer)

if __name__ == '__main__':
    gui = GUI()
    gui._devices_update_timer()
    gui.mainloop()

Macの/tmpの活用

 ADB File Controllerでは、Androidフォルダの表示情報(フォルダの展開、折りたたみ)を一時ファイルとしてMacの/tmpに保存しています。表示情報を保存することで、端末を抜き差ししても前回と同じ状態でフォルダ構成が表示されるようになるため、深い階層にあるファイルを扱う場合に毎回フォルダを辿っていく必要がなくなります。

 また、/tmpに置いたファイルはMacが適切なタイミングで削除してくれます。これによって、長期間利用がないAndroidデバイスの表示情報は削除されるため、大量のAndroidデバイスを扱う場合でも表示情報のファイルがMacのディスク容量を圧迫することが無いようにしています。

 表示情報の他にログファイルも/tmpに保存するようにしました。アプリの問題発生直後にのみログファイルが確認できればよく、永続的に保存する必要がないため、/tmpを利用しています。

Androidの任意フォルダでシェルを起動する方法

 ADB File Controllerでは、adb shellコマンドを実行した状態のターミナルを開く機能を追加しました。本アプリで実現しているadbコマンド以外に、他のコマンドを実行したいという場合があると考えたからです。これを手動で実行する場合は、ターミナルからadb shellを実行し、cdコマンドで目的のフォルダまで移動する必要があります。この一連のコマンド実行を行うPythonコードが以下になります。

 直接ターミナルを起動するのではなく、osascript経由で起動しています。こうすることで、ターミナルを起動した後に、任意のコマンドを実行時てもらうことが可能となります。

 注意する点は、文字列として扱う際のダブルクォーテーションとシングルクォーテーションの取扱です。cdコマンドを実行する場合、pathにスペースが含まれる場合があるため、pathを”で囲む必要があります。しかし、そのままosascript_commandに組み込むと、””の中に””が含まれてしまい、期待通りの文字列として認識されません。そこで、commandの”は\\\”として記載することで、適切にエスケープされるようになります。

def open_shell(self, path : str):
    command = f"cd \\\"{path}\\\";exec sh"

    adb_command = f"adb -s {self.deviceId} shell '{command}'"
    osascript_command = f'tell application "Terminal" to do script "{adb_command}"'
    subprocess.run(['osascript',
                    '-e', 
                    osascript_command])
ターミナルを起動して、adb shellコマンドとcdコマンドを実行するサンプル。

まとめ


 ADB File Controllerの仕様と実装のポイントについて紹介させていただきました。構想から完成まで30時間ほど掛かったと思います。今回の実装で、画面の作り方とsubprocessについて理解を深めることができました。Pythonの勉強を始めたばかりで、変数の命名規則がおかしなところや、実装な実装も散見されるかもしれませんが、参考になれば幸いです。

最後にアプリの改善と追加したい機能を列挙して終わりにします。

  • フォルダ/ファイル名の前にアイコンを表示させたい。
  • ショートカットファイルは、リンク先のディレクトリツリーが表示できるようにしたい。
  • フォルダの新規作成ができるようにしたい。
  • 右クリックのメニューにアイコン表示させたい。