となりのJohnの気まぐれ

気の向くままに

【Android】ウインドウ選択補助アプリケーションをつくったはなし(前編)

はじめに

日々,PCを操作していると避けられないのがウインドウ切り替え. ショートカットを使えば比較的手軽にできる操作であるものの,ウインドウが多いと行き過ぎたりする. とはいえ,マウスでウインドウ選択はちょっと面倒

そこで,タブレット現在のウインドウ情報を反映,操作するアプリを制作した.

タブレットで表示されるウインドウを選択すると,そのウインドウがPCで選択される.

マウスの操作機能も付与した. キーボードを何らかの形で操作できるようになると,手元のタブレットだけでPC操作ができる. 今回はできる限りソースコードを取り上げていく.

コントローラ(タブレット): github.com

ホスト(PC): github.com

リポジトリ名がガバガバである 使用言語はそれぞれAndroid JavaC#

全体の構成

実装に入る前に構築したシステムの全体の構成についてとり上げておく.

f:id:newbie29979:20181025020918p:plain

  • タブレットでは左側にPCのウインドウ一覧が,右側にマウスカーソル操作用のパッドがある.
  • PCから各ウインドウのタイトルとアイコンのリストがjsonタブレットに送られる.
  • タブレットでウインドウを選んだ場合,ウインドウのタイトルがPCに送られる.
  • タブレットでマウス操作(L/Rクリックやスクロール操作,カーソル移動)が入力されるとその操作がjsonでPCに送られる.

実装

本記事はコントローラ(タブレット)側.

WebSocket

今回のようなPC状のウインドウ状況の取得とマウスカーソルの操作では通信にリアルタイム性が求められるため,WebSocketを用いた通信を試みる. Android JavaでWebSocketを使う場合,以下のTooTallNate氏のライブラリが参考になる.

github.com

このライブラリを使用するためには,build.gradleに追記が必要.

build.gradle:

dependencies {
    implementation 'org.java-websocket:Java-WebSocket:1.3.8'
}

このライブラリを使用すると,AsyncTaskの記述ナシに通信ができる. 実装箇所はMainActivityだけ.神か.

必要最小限のメソッドのオーバーライドで使用できるようになるのも,簡単に使えて◎.

MainActivity:

    private class SendDataWebSocket extends WebSocketClient {
        public SendDataWebSocket(URI _uri){
            super(_uri);
        }
        @Override
        public void onOpen(ServerHandshake _handShake){ System.out.println("Connected"); }
        @Override
        public void onMessage(final String _message){
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    Type listType_ = new TypeToken<ArrayList<WindowRowData>>(){}.getType();
                    ArrayList<WindowRowData> windowRowDataList_ = new Gson().fromJson(_message, listType_);
                    setLsvWindowList(windowRowDataList_);
                }
            });
        }
        @Override
        public void onClose(int _code, String _reason, boolean _remote){ System.out.println("Disconnected"); }
        @Override
        public void onError(Exception e){ System.out.println(e); }
    }

jsonのパーサはGsonを使う(これにもbuild.gradleに記述が必要).

メッセージが到達した段階(onMessage())で,ビューにウインドウ一覧を表示するため,やむなくスレッドを立ち上げる.受け取ったjsonからウインドウのクラスをもつArrayListマッピングする.

ウインドウ操作

f:id:newbie29979:20181025020918p:plain ウインドウタイトルは左側にリストアップされる.

RecycleView

ウインドウはRecycleViewを使い描画される. Adapterのインスタンス生成時には,ウインドウタイトルにOnClickListenerをセットしておく.

MainActivity:

        // ウインドウタイトルを選択した場合
        windowRecycleViewAdapter = new WindowRecycleViewAdapter(this, windowList){
            @Override
            public WindowViewHolder onCreateViewHolder(ViewGroup _parent, int _viewType) {
                final WindowViewHolder holder_ = super.onCreateViewHolder(_parent, _viewType);
                holder_.itemView.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View view) {
                        final int pos_ = holder_.getAdapterPosition();
                        Send(new SendingData("SelectTitle", windowList.get(pos_).getTitle()));
                    }
                });
                return holder_;
            }
        };

何故かキャメルケースになっていないが,Send()メソッドでWebSocketを通じてPCに選択したウインドウのタイトルを送る.

PCで更新があった場合のウインドウリスト表示は以下のようにArrayListの更新後,adapterに更新をnotifyすることで行う.

MainActivity:

    public void setLsvWindowList(ArrayList<WindowRowData> _newWindowList){
        ArrayList<WindowRowData> windowList_ = new ArrayList<>();
        windowList_.addAll(windowList);

        // 閉じられたウインドウをリストから削除
        for (WindowRowData w : windowList_) {
            if (!_newWindowList.contains(w)) {
                removeWindow(w);
            }
        }

        // 過去のウインドウリストにない新規ウインドウを追加
        for(WindowRowData w : _newWindowList){
            if(!windowList_.contains(w)){
                addWindow(w);
            }
        }
    }
    public void addWindow(WindowRowData _windowData){
        windowList.add(_windowData);
        windowRecycleViewAdapter.notifyItemChanged(windowList.size()-1);
    }
    public void removeWindow(WindowRowData _windowData){
        int index_ = windowList.indexOf(_windowData);
        Iterator<WindowRowData> it = windowList.iterator();

        while(it.hasNext()){
            WindowRowData rowData_ = it.next();
            if(rowData_.getWindowData().equals(_windowData)){
                it.remove();
                windowRecycleViewAdapter.notifyItemRemoved(index_);
                windowRecycleViewAdapter.notifyItemRangeChanged(index_, windowList.size());
                break;
            }
        }
    }

ArrayListのremoveはiteratorを使用しないとjavaのremoveの処理の関係上エラーが生じることがあるので注意する. 泥臭いけれどエラーが出てしまう以上仕方がない

画像の受信

画像はPCでBase64エンコードし,タブレットでデコードする. AdapterのonBindViewHolderでholderにBindするViewの諸設定を行うが,ここでデコードを行う.

WindowRecycleViewHolder:

    @Override
    public void onBindViewHolder(WindowViewHolder _holder, int _pos){
        WindowRowData data_ = windowList.get(_pos);
        _holder.titleView.setText(data_.getTitle());

        if(!data_.getIcon().equals("null")){
            byte[] decodedByteArray_ = Base64.decode(data_.getIcon(), 0);
            Bitmap receivedBmp_ = BitmapFactory.decodeByteArray(decodedByteArray_, 0, decodedByteArray_.length);
            Bitmap resourceBmp_ = BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher);

            receivedBmp_ = Bitmap.createScaledBitmap(receivedBmp_, resourceBmp_.getWidth(), resourceBmp_.getHeight(), true);
            _holder.iconView.setImageBitmap(receivedBmp_);
        }
    }

タブレット上でデコードのコストがかかってしまうが,画像を直接送信する通信コストよりかはマシ.

マウス操作

f:id:newbie29979:20181025020918p:plain 右側の灰色の領域でマウス操作が可能になっている.

L/Rクリック

マウスのL/Rクリック動作はタブレットの音量ボタンを押下することで可能にした.

これでマウスパッド部分はカーソル移動・スクロール操作のみを担当することになり,操作性がある程度上がる?

MainActivity:

 @Override
    public boolean dispatchKeyEvent(KeyEvent _keyEvent) {
        switch (_keyEvent.getKeyCode()) {
            case KeyEvent.KEYCODE_VOLUME_UP:
                if (_keyEvent.getAction() == KeyEvent.ACTION_DOWN) {
                    if(!isRClickDOWN) {
                        Send(new SendingData("MouseEvent", "LClickDOWN"));
                        isRClickDOWN = true;
                    }
                }
                else{
                    isRClickDOWN = false;
                    Send(new SendingData("MouseEvent", "LClickUP"));
                }
                break;
            case KeyEvent.KEYCODE_VOLUME_DOWN:
                if (_keyEvent.getAction() == KeyEvent.ACTION_DOWN) {
                    if(!isLClickDOWN) {
                        Send(new SendingData("MouseEvent", "RClickDOWN"));
                        isLClickDOWN = true;
                    }
                }
                else{
                    isLClickDOWN = false;
                    Send(new SendingData("MouseEvent", "RClickUP"));
                }
                break;
        }
        return true;
    }

dispatchKeyEvent()はAndroid端末の電源キーや音量ボタンの役割をオーバーライド可能なメソッド.

このアプリケーションでは音量ボタンの挙動を上書きするので,KEYCODE_VOLUME_UP,KEYCODE_VOLUME_DOWNについて記述を行う.

カーソル移動・スクロール操作

カーソル移動・スクロール操作はWebSocketを使う主な要因.これらの操作はマウスパッドであるImageViewで実装する.

CursorImageView:

@Override
    public boolean onTouchEvent(MotionEvent _event){
        pos currentPos = new pos(_event.getX(), _event.getY());

        if(_event.getAction() == MotionEvent.ACTION_DOWN ||
                _event.getAction() == MotionEvent.ACTION_POINTER_DOWN){
            maxPointerCount = 0;
            oldPos.setPos(_event.getX(), _event.getY());
        }
        maxPointerCount = Math.max(_event.getPointerCount(), maxPointerCount);

        pos variation = new pos(currentPos.x - oldPos.x, currentPos.y - oldPos.y);

        switch (maxPointerCount){
            case 1:
                mainActivity.Send(new SendingData("MouseEvent", "MoveCursor",
                        (double) variation.x, (double) variation.y, maxPointerCount));
                break;
            case 2:
                mainActivity.Send(new SendingData("MouseEvent", "ScrollWindow",
                        (double) variation.x, (double) variation.y, maxPointerCount));
                break;
            case 3:
                mainActivity.Send(new SendingData("MouseEvent", "HScrollWindow",
                        (double) variation.x, (double) variation.y, maxPointerCount));
                break;
        }

        oldPos.setPos(currentPos.x, currentPos.y);

        return true;
    }

ユーザのタップによって発火するonTouchEvent()に処理を書いていく.

カーソル操作やスクロール操作は指の本数で区別している.これは現代のトラックパッドでよくみられるやつ.

まとめ

案外,自分が描いたコードを抜粋,抜粋して説明することの難しさを感じている. 最終的に書き上げたコードは最初に上げたGitHubに上がっているので,UI部品の細かな実装やクラス/メソッドの詳細はそちらを参考にしてほしい.