ファイルマネージャーを使ってマップファイルを指定する(アンドロイド地図アプリの開発 その3)

喘息の方は徐々に快方に向かっているようだ。昨日は気管拡張剤をマックスの4回使ったが、今日はここまでまだ1回で済んでいる。しかし、昨夜はあまり眠れなかった。お陰で、昼間は眠気と戦うことになってしまった。
こんな状態だとおっかなくて運転なんてできない。仕事でもミスしそうで怖い。もう少し回復には時間がかかりそうだ。

ところで、アンドロイドアプリ開発の続きです。前回は、ファイルマネージャを使って、ファイルを選択するアプリを作ってみました。
alasixosaka.hatenablog.com

今回はこれを、前々回のマップ表示アプリに組み込んでみたいと思います。
まずは準備から。
前々回はベルリンの地図を使いましたが、今回はそれに加えてハンブルグの地図を使います。mapsforgeのダウンロードサイトからハンブルグの地図を落としてきます。
今回は、ファイルマネージャーを使って、ベルリンとハンブルグの地図を交互に表示できるようにしてみます。
別にハンブルグでなくてもよいのですが、アプリの初期設定がベルリンの位置になっているので、ハンブルグの地図を表示してもベルリンの位置を表示してしまいます。ベルリンからあまり遠いと地図の表示エリアを探すのに苦労するのでなるべく近くの都市を選んでみました。

今回のお勉強ですが、別アクティビティを使った画面遷移の方法、プリファレンスを保存する方法を新たに使います。また、ライフタイムを考慮したプログラミングを行う必要があるので、その部分に手を入れています。
ちょっと内容てんこ盛りで、整理して書けるか自信がないですが、やったことを書いていきます。

まずは、別アクティビティを使った画面遷移

地図を表示している画面から、ファイルを選ぶ操作をしないといけないので、画面遷移という方法を使って、メニュー画面を表示させることにします。
100%は理解してないので、でたらめを書いているかもしれませんが、アンドロイドで新たな画面を呼び出す方法として、新規にアクティビティを作成し、IntentクラスとしてNewして、メインアクティビティから呼び出すという操作をします。
新規にアクティビティを追加する操作はAndroid Studioでは、Javaフォルダを選択し、ファイルメニュー(あるいは右クリック)からNew → Activity → Empty Activityとすればアクティビティの追加画面が表示されます。
ここで、Activity Nameを適当に入力すればOKです。今回は、”Setting"という名前にしました。
当然レイアウトファイルも新しい画面用にできます。ファイル名は”activity_setting.xml"となっているはずです。ここでは、ボタンを3つ表示するレイアウトにしました。
こんな感じです。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:layout_width="122dp"
        android:layout_height="wrap_content"
        android:onClick="onBackButtonClick"
        android:text="戻る" />

    <Button
        android:id="@+id/btMap"
        android:layout_width="122dp"
        android:layout_height="wrap_content"
        android:text="地図ファイル" />

    <Button
        android:id="@+id/btRoute"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ルートファイル"/>

</LinearLayout>

f:id:alasixOsaka:20190628200413j:plain
メニュー画面を開いたところ
今回は、3つ目の”ルートファイル”は使いません。一応作ってみただけです。したがって、クリックしても何も起こりません。
Javaファイルの方は前回とほとんど同じなので、あえて書くほどのことはないですが、念のためコードを載せておくと、こんな感じです。

public class setting extends AppCompatActivity {

    private final static int CHOSE_FILE_CODE = 1002;
    private static String Mapfile ="";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_setting);

        Intent intent = getIntent();

        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
                != PackageManager.PERMISSION_GRANTED){
            String[] permissions = {Manifest.permission.WRITE_EXTERNAL_STORAGE};
            ActivityCompat.requestPermissions(this, permissions, 1000);
            return;
        }
        Button clickButton =  findViewById(R.id.btMap);

        SetListener listener = new SetListener();

        clickButton.setOnClickListener(listener);

        Button btRoute = findViewById(R.id.btRoute);
        btRoute.setOnClickListener(listener);
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        try {
            if (requestCode == CHOSE_FILE_CODE && resultCode == RESULT_OK) {
                String filePath = data.getDataString();
                filePath=filePath.substring(filePath.indexOf("storage"));
                String decodedfilePath = URLDecoder.decode(filePath, "utf-8");
                File file = new File(decodedfilePath);
                String mapfile = (String) file.getName();
                String fpath = decodedfilePath.substring(0,decodedfilePath.indexOf(mapfile));

                String ext = mapfile.substring(mapfile.indexOf(".")+1);

                if (ext.equals("map") ) {
                    SharedPreferences preferences = getSharedPreferences("DATA", Context.MODE_PRIVATE);
                    SharedPreferences.Editor editor = preferences.edit();
                    editor.putString("Mapfile", mapfile);
                    editor.putString("path", fpath);
                    editor.apply();
                }else {
                    Toast.makeText(this, "not map file", Toast.LENGTH_SHORT).show();
                }

            }
        } catch (UnsupportedEncodingException e) {
            // いい感じに例外処理
            e.printStackTrace();
        }
    }

    public void onBackButtonClick(View view){

        finish();
    }

    private class SetListener implements View.OnClickListener {
        @Override
        public void onClick(View view){
            int id = view.getId();

            switch (id){

                case R.id.btMap:
                    Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
                    intent.setType("file/*");
                    startActivityForResult(intent, CHOSE_FILE_CODE);
                    break;

                case R.id.btRoute:
            }
        }
    }
}

戻るボタンを押したときの処理は、onBackButtonClick()になります。アクティビティを終了させるだけなので単にfinish()としています。端末の戻るボタンを押しても動作は同じです。
その他のボタンを押したときの処理は最後の部分です。
idを取得して、ボタンによって処理を分岐していますが、”ルートファイル”のボタン”btRoute"のところは何も処理が記述されていないので何も起こりません。
”地図ファイル”のボタンをクリックすると”btMap"の処理に飛び、ファイルマネージャーが呼び出されます。

 private class SetListener implements View.OnClickListener {
        @Override
        public void onClick(View view){
            int id = view.getId();

            switch (id){

                case R.id.btMap:
                    Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
                    intent.setType("file/*");
                    startActivityForResult(intent, CHOSE_FILE_CODE);
                    break;

                case R.id.btRoute:
            }
        }
    }

結果はonActivityResultで受けています。前回とほぼ同じですが、メインアクティビティの方でマップファイルを指定するときに、ファイルパスとファイル名に分けて指定しているので、ここで、パスとファイル名を分けています。ファイル名は
String mapfile = (String) file.getName();
として、mapfileに入れています。
パスは、得られたファイル名を使ってサブストリングスとしてfpathに入れています。
String fpath = decodedfilePath.substring(0,decodedfilePath.indexOf(mapfile));
この二つをプリファレンスに格納するのですが、プリファレンスについてはこの先で詳しく書きます。

プリファレンスの使い方

アプリなんかで初期設定値を保存しておきたいということはよくあると思いますが、そのために使われるのがプリファレンスになります。
このプリファレンスを使ってメインアクティビティで地図ファイルのファイル名とファイルパスを呼び出しているのが、getFilePath()の部分になります。

private void getFilePath(){
        SharedPreferences preferences = getSharedPreferences("DATA", Context.MODE_PRIVATE);
        MAP_FILE = preferences.getString("Mapfile", "berlin.map");
        Path = preferences.getString("path", "storage/emulated/0/");
    }

ここではファイル名を”DATA”として、getSharedPreferencesの第一引数として記述しています。第二引数はContext.MODE_PRIVATEとして、このアプリだけで使うことを宣言しています。
引き続いて、MAP_FILEというストリング変数に、”DATA"から値を呼び出しています。値は、ストリングと値の1対で格納されていて、データ型は、int、long、float、String、boolean、Setが使えます。ここではStringを使っていますので、preferences.getString()としてストリング変数を呼び出しています。
第一引数がデータの名前に相当するストリングで第二引数はデータがヌルのときに代入される初期値です。プリファレンスはアプリが削除されない限り保存されるので、アプリを一番最初に動かしたときに使われる値になります。以降は、プリファレンスに保存されたデータが呼び出されます。
地図ファイルのファイル名は”Mapfile"という名前で保存されていて、初期値は”berlin.map"にしています。ファイルパスも同様に"path”という名前で保存されていて初期値は”storage/emulated/0/"となっています。
実は、後で書きますが、このベルリンの地図が正しく読めないと、このアプリがエラーで止まってしまうので、メニュー画面に行くこともできず立ち往生してしまうことになっています。特に、ファイルパスの部分は端末ごとに異なるようなので、このままでは著しく汎用性に欠くアプリになっています。この点はおいおい修正することにして、とりあえずエミュレータを使って機能を追加していくことにします。
プリファレンスを保存する部分ですが、これは、メニュー画面を動かすアクティビティ”setting.java"の次の部分になります。

String ext = mapfile.substring(mapfile.indexOf(".")+1);

                if (ext.equals("map") ) {
                    SharedPreferences preferences = getSharedPreferences("DATA", Context.MODE_PRIVATE);
                    SharedPreferences.Editor editor = preferences.edit();
                    editor.putString("Mapfile", mapfile);
                    editor.putString("path", fpath);
                    editor.apply();
                }else {
                    Toast.makeText(this, "not map file", Toast.LENGTH_SHORT).show();
                }

データを読みだしたときと同じく、
SharedPreferences preferences = getSharedPreferences("DATA", Context.MODE_PRIVATE);
でファイルを指定して
SharedPreferences.Editor editor = preferences.edit();
editor.putString("Mapfile", mapfile);
editor.putString("path", fpath);
editor.apply();
で書き込んでいます。最後にeditor.apply();としないと変更結果は反映されないそうです。
また、ここで、間違ったファイル選んでしまうと、エラーになって、さっき書いたようにメニュー画面が起動できない状態に落ちいってしまいますので、最低限の処理としてファイル名の拡張子をチェックしています。
チェックして、拡張子が"map”ならそのまま新しいファイルのファイル名とパスをプリファレンスに書き込みます。"map”でない場合は、エラーをトースト表示して、プリファレンスの書き換えは行われません。
ただし、拡張子のチェックだけしかしていませんので、拡張子が"map”でmapsforgeで読めないファイルを指定した場合は、エラーになるので、先ほど書いたようにメニュー画面すら起動できない状態になるので注意が必要です。欠陥があるのは承知の上ですが、まずは必要な機能を追加することを優先したいので、今はこのまま進むことにします。

メニュー画面の表示とライフサイクル

今まであまりライフサイクルを意識いてなかったのですが、画面遷移をすると意識せざるをえません。前回までのアプリはライフサイクルはほとんど関係なしで動けばいいというものでしたが別画面でメニューを表示するとどこに戻るかが重要でライフサイクルが大事になってきます。アンドロイドアプリ開発の初心者(私のような(笑))はonCreate からプログラムを書き始めると思いますが、アンドロイドでは、アプリが起動するとまずonCreate が走り、onStart 、onResumeと進み、実行中になります。ここで画面をタップするなどの変化を待ちます。画面タップにリスナーが登録されていれば、その処理を行います。今回はメニューの画面でこの仕組みを使ってます。ところでタップなどで別のアクティビティが呼ばれるとonPause が走り引き続きonStop に移りプログラムが停止し、別アクティビティの終了を待ちます。別アクティビティでの処理が終了するとonRestart が走り、onStart から再開します。これらの様子を図示したのが次の図です。

f:id:alasixOsaka:20190629124957p:plain
ライフサイクルの遷移

onCreate は絶対に必要ですが、他はなくても構いません。onCreate しかない時に別のアクティビティが呼ばれるとどうなるのかと言うとonStart もonResume もないのでそのまま待機状態になります。
この仕組みを理解してなかったので始めはメニュー画面で地図ファイルを変更しても反映されずに、悩むことになりました。onCreate は一度だけ走る初期設定のみにし、メインの部分はメニューから帰って来た時も走るonResume に移すことで地図ファイルの変更が反映されるようになりました。
ところで、メニュー画面の表示のさせかたですが、前々回も参考にさせてもらった伊勢在住のプログラマーさんの記事を参考にさせていただきました。参考サイトでは、地図画面をロングタップすることでポップアップバルーンを表示させていますが、今回のプログラムはロングタップでメニュー画面が表示されるようになっています。
だいぶ長いですがメインアクティビティのリストを書いておきます。

public class MainActivity extends AppCompatActivity {
    // Name of the map file in device storage
    private static String MAP_FILE = "";
    private static String Path ="";
  
    private final static int PERMISSION_REQUEST_CODE = 1;
    private MapView mapView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        Log.i("LifeCycle", "OnCreate");
        super.onCreate(savedInstanceState);

        AndroidGraphicFactory.createInstance(getApplication());
        mapView = new MapView(this);
        setContentView(mapView);
        /*
         * We then make some simple adjustments, such as showing a scale bar and zoom controls.
         */
        mapView.setClickable(true);
        mapView.getMapScaleBar().setVisible(true);
        mapView.setBuiltInZoomControls(true);

        /*
         * To avoid redrawing all the tiles all the time, we need to set up a tile cache with an
         * utility method.
         */

        final int permission = ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE);
        if (permission != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQUEST_CODE);
        } else {
            getFilePath();
        }
    }
    @Override
    protected void onDestroy() {
        /*
         * Whenever your activity exits, some cleanup operations have to be performed lest your app
         * runs out of memory.
         */
        Log.i("LifeCycle", "onDestroy");
        mapView.destroyAll();
        AndroidGraphicFactory.clearResourceMemoryCache();
        super.onDestroy();
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);

        switch(requestCode) {
            case PERMISSION_REQUEST_CODE:
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    //displayMap();
                    getFilePath();
                    return;
                }
            default:
        }

        // アプリを終了
        this.finish();
    }

    private void displayMap() {

        try {
            TileCache tileCache = AndroidUtil.createTileCache(this, "mapcache", mapView.getModel().displayModel.getTileSize(), 1f, mapView.getModel().frameBufferModel.getOverdrawFactor());
            File mapFile = new File(Path, MAP_FILE);
            MapDataStore mds = new MapFile(mapFile);
            TileRendererLayer trl = new TileRendererLayer(tileCache, mds, mapView.getModel().mapViewPosition, AndroidGraphicFactory.INSTANCE) {
                @Override
                public boolean onLongPress(LatLong tapLatLong, Point layerXY, Point tapXY) {
                    return setSetting(tapLatLong);
                }
            };

            trl.setXmlRenderTheme(InternalRenderTheme.DEFAULT);

            mapView.getLayerManager().getLayers().add(trl);

            //mapView.setCenter(new LatLong(34.491297, 136.709685)); // 伊勢市駅
            mapView.setCenter(new LatLong(52.517037, 13.38886));
            mapView.setZoomLevel((byte) 12);
        }catch (Exception e) {
            /*
             * In case of map file errors avoid crash, but developers should handle these cases!
             */
            e.printStackTrace();
        }
    }
    private boolean setSetting(LatLong latlong) {

        Intent intent = new Intent(MainActivity.this, setting.class);
        startActivity(intent);
        return true;
    }

    @Override
    public void onRestart(){
        super.onRestart();
        getFilePath();
    }

    @Override
    public void onResume(){
        super.onResume();
        displayMap();
    }

    private void getFilePath(){
        SharedPreferences preferences = getSharedPreferences("DATA", Context.MODE_PRIVATE);
        MAP_FILE = preferences.getString("Mapfile", "berlin.map");
        Path = preferences.getString("path", "storage/emulated/0/");
    }
}

ロングタップを検出する部分は、displaymap()の中の
@Override
public boolean onLongPress(LatLong tapLatLong, Point layerXY, Point tapXY) {
return setSetting(tapLatLong);
}
の部分です。ロングタップを検出したら、setSetting()に飛ぶようになっています。seSettingでは
Intent intent = new Intent(MainActivity.this, setting.class);
として、settingというアクティビティをインテントとしてnewして、
startActivity(intent);
で呼び出しています。
戻ってきたときの処理は、onRestart()になり、ここでgetFilePath()を実行して、ファイル名とファイルパスに変更があれば反映されるようになっています。
また、地図を描くdisplayMap()をonResumu()に移動して、メニュー画面から戻った時に地図を再描画できるようにしています。
実は、地図を再描画させる方法としては裏技というか別の方法もあって、それは、画面を回転させることです。画面を回転するとアプリは再起動し、onCreate()からスタートします。
でも、地図ファイルを切り替えてもいちいち画面を回転させないと表示が変わらないのも間抜けなので、ライフサイクルの仕組みを使って、メニューから戻ったら再描画をするようにしました。
エミュレータで動かしてみます。
初期画面でロングタップをするとメニュー画面が現れますので、ここで地図ファイルをタップするとファイルマネージャーが起動します。ファイルマネージャーでハンブルグの地図を選択。

f:id:alasixOsaka:20190629130846j:plain
ファイルマネージャーでハンブルグの地図を選択
戻るをタップすると、ベルリンの地図が消えて真っ白な画面になります。位置はベルリンのままで、地図がハンブルグに切り替わったためです。
f:id:alasixOsaka:20190629131615j:plain
地図ファイルを切り替えると画面が真っ白に
ズームアウトしていくと小さくハンブルグの地図が現れます。
f:id:alasixOsaka:20190629131020j:plain
ハンブルグの地図に切り替えてズームアウトしたところ
位置を変えてズームインするとハンブルグの市街地マップが現れます。
f:id:alasixOsaka:20190629131941j:plain
ハンブルグの市街地が表示された
これで、好きな場所の地図ファイルを指定して表示させることができるようになりました。
次はGPSを使った位置表示にチャレンジしてみます。

参考にさせていただいたサイト
mapsforge でポップアップするマーカーを試す - プログラマーのメモ書き
アクティビティのライフサイクル - Androidプログラミング入門 | JavaDrive
SharedPreferencesをサクッと使う - Qiita
[Android] アプリの画面遷移とActivity間のデータ転送