高低図に現在地を表示する。(アンドロイド地図アプリの開発 その24)

GWになりましたが、ステイホームということであまりやることがありません。
Andoroidアプリの開発もいよいよ大詰めになってきましたので、完成してしまいたいと思います。

前回は高低図の全体を表示するとこまでやりました。
alasixosaka.hatenablog.com

今回は、高低図に現在地を表示するということやってみたいと思います。
その前に、前回はSDカードからGPXファイルを読み込んで解析したデータをグローバル変数に放り込んでいましたが、よくよく考えると、グローバル変数にする意味がないので、ローカル変数に変更しました。
グローバル変数を定義しているSamplesApplication.javaの冒頭のところでArrayListを定義している部分をコメントアウトし、RotateMapViewer.javaの冒頭に同じ記述を書いてこちらでArrayListを定義します。
グローバル変数は地図の回転のOn/Offを表すsetGPSと高低図の表示をOn/OffするsetHeightの2つになります。

public class SamplesApplication extends Application {

    public static final String TAG = "Mapsforge Samples";

   ~省略~

    boolean setGPS;
    boolean setHeight;
    
    //ArrayList<Double> GPXs = new ArrayList<>();

Viewを切り替える

前回の宿題になっていた、高低図ありのViewとなしのViewを切り替える作業です。
アプリの中でViewを動的に切り替えるわけではないので、”Map View”ボタンをクリックしたときに、高低図のありかなしかを判断して、それぞれのViewを割り当てるということをやればできることになります。
Viewはレイアウトファイルで定義されるので、以前の高低図のないレイアウトファイルをもう一つ用意して、高低図を表示しない場合はこちらを使うようにします。レイアウトファイルは"rotatemapviewer2.xml"としました。

SetContentViewがみあたらない

クラスとレイアウトファイルを関連付けるのは、通常はsetContentView(R.layout.xxxx)のように使います。ところが、MapViewをつかさどる”RotateMapViewer.java"にはsetContentViewがありません。その代わりと言っては何ですがgetLayoutIDコマンドに

    @Override
    protected int getLayoutId() {
        return R.layout.rotatemapviewer;
    }

という記述がありました。この部分が唯一レイアウトファイルについて書いてある部分なので、ここを次のように変更しました。setHeightがTrueの場合はrotatemapviewerを使い、Falseの場合はrotatemapviewer2を使います。

    @Override
    protected int getLayoutId() {
        if(globals.setHeight){
            return R.layout.rotatemapviewer;
        }else {
            return R.layout.rotatemapviewer2;
        }
        //return R.layout.rotatemapviewer;
    }

グローバル変数が機能しない。

ところが、この部分でエラーになってしまい、デバッグするとグローバル変数としてglobals.setHeightを実行するとnullとなってしまい、グローバル変数が機能していないことがわかりました。
デバッグで色々調べると、このRotateMapViewer.javaにはOnStartはあってもOnCreateがなく、レイアウトファイルの紐づけはOnStart以前に行っているらしくOnCreateを作って、そこの部分でグローバル変数を設定してやればうまくいくことがわかりました。また、globals = (SamplesApplication) getApplication()がsuper.onCreate(savedInstanceState)の前に書いてありますが、この部分は重要で、この順番が逆だとやっぱりグローバル変数が定義されないままでレイアウトファイルの紐づけに失敗します。理由はよくわかりませんが(笑)。

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        globals = (SamplesApplication) getApplication();
        super.onCreate(savedInstanceState);

    }

これでうまくいくはずだったのですが、しょうもないことでいろいろ苦労したので、備忘録として書いておきます。
結論から言うと、rotatemapviewer2.xmlを昔のプロジェクトからコピペしたために、昔のプロジェクト名が残ったままになっていてここで、そんなのはないよというエラーになっていました。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/mainView"
    
    ~省略~

    <com.example.XXXXX.MapScaleBarView
        android:id="@+id/mapScaleBarView"
        
 ~省略~

com.example.XXXXX.MapScaleBarViewのXXXXXの部分がコピペした元の古いプロジェクト名になっていたのがエラーの原因でした。ここを新しいプロジェクト名にすることでエラーが解消し無事にViewの切り替えができるようになりました。
それがわからずに、別のクラスを用意してそれぞれのクラスでレイアウトファイルの紐づけをやってみたりしましたが、全然エラーが解消せず、かなり悩みました。デバッグモードでエラーのコメントをよく読めばすぐに分かったことなんですが、エラーに焦ってしまって冷静にコメントまでしっかり読まず、あれこれ思いついたことをやったのがかえってよくありませんでした。エラーメッセージのコメントはちゃんと読むべきだと反省しています。

現在地が更新されたら高低図も更新する

さてここからは、いよいよ高低図の本体の処理の部分になります。
まず、現在地が更新された時に高低図を書き換えないといけないので、"OverlayMapViewer.java"にその処理を追記します。具体的にはdrawUserPositionMarkerの最後にif (globals.setHeight){ HeightMap2(); }と高低図の表示がOnならHeightMap2()を呼び出します。

     private void drawUserPositionMarker(Location location){
        if (globals.setGPS==true) {
            Latitude = location.getLatitude();
            Longitude = location.getLongitude();
            
   ~省略~

            this.mapView.setCenter(new LatLong(Latitude, Longitude));
            if (globals.setHeight){
                HeightMap2();
            }        

HeightMap2()は、

    private void HeightMap2() {
        ((PaintView)findViewById(R.id.height)).map(GPXs,elvmax,globals.setGPS,Latitude,Longitude);
    }

PaintView.javaのmap関数を呼び出します。引数はArrayListのGPXs、最高高度のelvmax、ここまでは、前回のHeightMap()と同じ、ここにglobals.setGPSと経度(Latitude)、緯度(Longitude)を加えています。呼び出し関数は同じですが、Javaでは引数の数を認識して適切なルーチンに飛ぶという機能があるので変数名は同じmapで問題ありません。
また、globals.setGPSは地図の回転表示と現在地表示のOn/Offを表すグローバル変数なのでこれで、高低図にも現在地表示の有り無しを伝えます。したがって、前回作製したHeightMap()にも引数としてglobals.setGPSを加えています。したがって、HeigtMapでは引数が3つでmapを呼び出し、HeightMap2では引数が5つでmapを呼び出しています。受け側のmapは次のようになります。単純に引数を変数に入れなおしてinvalidate()を実行して高低図を書き直すだけです。

 public void map(ArrayList arraylist,double emax,boolean GPS){
        arrays = arraylist;
        elvmax = emax;
        setGPS = GPS;
        invalidate();
    }

    public void map(ArrayList arrayList,double emax,boolean GPS, double Lati, double Longi){
        arrays = arrayList;
        elvmax = emax;
        setGPS = GPS;
        lat = Lati;
        lon = Longi;
        invalidate();
    }

現在地点の算出

GPSから現在地の緯度、経度が送られてきますが、これをもとに高低図のどの位置にいるかを算出し、プロットしなくてはいけません。高低図は横軸がスタート地点からの距離、縦軸が高度になっているので緯度、経度の情報を持っていません。しかし、arraysには、緯度、経度の情報のあるので、これをもとに計算します。arraysの中身はGPXファイルの各地点の緯度、経度、通算距離、標高がそれぞれ順番に格納されています。
そこで、面倒ですが、現在地の緯度、経度と各地点の緯度、経度から2点間の距離を計算し、その距離が最も小さい地点が現在地と推定することにします。この場合九十九折れの道などでGPSの精度が低いと現在地を誤認する可能性がありますが、この辺りは実際に使ってみてどの程度問題になるかを見てから対応を考えたいと思います。具体的な処理は次のようになります。ループ変数をiとし、lat1、lon1にGPXファイルの緯度、経度を取ってきて、GPSの現在地点の緯度、経度であるlat、lonとの距離を計算しています。計算ルーチンはGPXファイルから通算距離を計算したときと同じくライブラリを使っています。結果はdisに格納され、mdisと比較し、disがmdisよりも小さい場合はmdisとNposを更新します。結果的にNposが最も近いポイント、mdisがその距離になります。

           for (int i = 0 ; i < arrays.size()/4 ; i++) {
                double lat1 = arrays.get(i * 4);
                double lon1 = arrays.get(i * 4 + 1);
                float[] dis = getDistance(lat1, lon1, lat, lon);
                if (dis[0] < mdis) {
                    mdis = dis[0];
                    Npos = i;
                }
            }

現在地点のスタートから距離をNx、高度をNyとしてarraysからとってきます。

double Nx=arrays.get(Npos*4+2);
double Ny= arrays.get(Npos*4+3);

基本的にはこのNx、Nyを使って高低図上に現在地をプロットすれば良いことになります。

高低図のシフト

だだし、現在地を表示する場合は、高低図の全体をフルスケールにすると距離が長い場合に見づらくなるので、フルスケールを10㎞としています。
そうすると、現在地点に応じて高低図も動かさないとどんどん進んでいって10㎞を越えてしまうと現在地点が表示されなくなるので、高低図をシフトさせる処理を行います。
シフトの考え方は次のようにしました。

  • 現在地点がスタートから3㎞まではシフトしない。
  • ゴール地点が表示されるまでシフトしたらそれ以上はシフトしない。

そうすると、シフトが起こるのは、現在地点がスタート地点から3㎞以上でゴールまでの残り距離が7㎞以上ある場合になります。具体的な計算は下記のようになります。シフト量の単位は㎞として、変数ofsetに代入しています。

            //3㎞以上進んでいて残りが7㎞以上の時は進んだ距離-3㎞シフト
            if ((Nx > 3)&&((Tdis-Nx)>7)){
                ofset = Nx - 3;
                //進んだ距離が3㎞以下ならシフトはゼロ
            }else if (Nx <=3){
                ofset=0;
                //残りの距離が7㎞以下ならシフトをストップ
            }else {
                ofset = Tdis - 10;
            }

現在地点のプロット

これで現在地点のプロット位置を計算することができるようになったので、円をプロットします。だだし、迷ったり、コースを外れたりしたときに、全く見当はずれな位置にプロットするのは間抜けなので、一応コースとの距離が300m以内ならプロットすることにしました。プロット位置の計算は、int Cx = (int)((Nx-ofset)/10*cwidth)、int Cy = (int)(cheight-Ny/ymax*cheight)で求めています。プロットは外周が黒で、シアンで塗りつぶした円にしました。

            //ルート上の最近接地点と現在地の距離が300m以下なら円をプロットする
            if(mdis<300){
                int Cx = (int)((Nx-ofset)/10*cwidth);
                int Cy = (int)(cheight-Ny/ymax*cheight);
                paint.setColor(Color.CYAN);
                paint.setStyle(Paint.Style.FILL);
                canvas.drawCircle(Cx,Cy,20,paint);
                paint.setColor(Color.BLACK);
                paint.setStyle(Paint.Style.STROKE);
                paint.setStrokeWidth(4);
                canvas.drawCircle(Cx,Cy,20,paint);
            }

軸線もシフトする

これで一応高低図に現在地がプロットできるようになったのですが、縦の軸線は5㎞おきに描く事にしたので、フルスケールが10㎞だとセンターに線が表示されただけになってなんだか間抜けな感じがします。そこで、軸線もシフトするようにしました。現在地点に応じて軸線をシフトし、例えば、15㎞地点付近にいるとかをわかるようにしました。具体的な計算は次のようになります。ループ変数をiとし、float X = (float) (Nx - ofset - Nx % 5 + 5*i)で軸線のプロット位置を計算しています。iは-1から3まで計算し、Xが0から10の間、すなわち横軸のフルスケールのどこかに入った場合はプロットします。

            for (int i=-1;i<3;i++) {
                float X = (float) (Nx - ofset - Nx % 5 + 5*i);
                if ((X < 10)&&(X > 0)) {
                    canvas.drawLine(X / 10 * cwidth, 0, X / 10 * cwidth, cheight, paint);
                }
            }

こんな感じに表示されます。

f:id:alasixOsaka:20200429121059j:plain
現在地を高低図にプロット

実はバックグランドで動作してなかった。

高低図に現在地を表示することはできたが、動作確認の段階でバックグランド動作が機能していないことがわかりました。android studio をアップデートしたらエミュレータでもスリープモードが機能するようになって、スリープモードでエミュレータを使ってGPXファイルを読み込んで動かして見ると、軌跡がスリープしている間の分飛んでいる。android 8 からバックグランド動作に対する規制が厳しくなったので、サービスを設定して動かすだけではスリープするとアプリが停止するようだ。

f:id:alasixOsaka:20200430140533j:plain
スリープしている間の軌跡が飛んでしまっている。
startService でなく、startForegroundService コマンドを使い、5秒以内にStartForeground を実行しないとダメらしい。エミュレータandroid 8 になっているのでバックグランド動作ができてなかったようだ。実機の方はandroid 6 なので気にしなくてもいいことはいいが、将来、端末を交換したときのことを考えてandroid 8 にも対応させて見る。また、StartForegroundServiceはNotificationとセットになっているので、Notificationも実装する必要がある。NotificationもAndoroid 8から変更になっていて、チャンネルというのを使わないといけないことになっている。いろいろとめんどくさいが、Andoroidのバージョンを確認して処理を切り分けないといけない。
LocationService.javaのonStartCommandを下記のように変更した。Andoroidのバージョンを確認し、26以上ならチャンネルを使う設定に、25以下ならチャンネルを使わない設定にした。26以上の場合は、Notificationを事項た後に、StartForegroundを実行している。

public int onStartCommand(Intent i, int flags, int startId) {
        super.onStartCommand(i, flags, startId);


        int requestCode = 0;
        String channelId = "default";
        String title = "MyMap実行中";

        PendingIntent pendingIntent =
                PendingIntent.getActivity(context, requestCode,
                        i, PendingIntent.FLAG_UPDATE_CURRENT);


        NotificationManager notificationManager =
                (NotificationManager)context.
                        getSystemService(Context.NOTIFICATION_SERVICE);
        if (Build.VERSION.SDK_INT>=26) {
            NotificationChannel channel = new NotificationChannel(
                    channelId, title, NotificationManager.IMPORTANCE_DEFAULT);

            if (notificationManager != null) {
                notificationManager.createNotificationChannel(channel);
                Notification notification = new Notification.Builder(context, channelId)
                        .setContentTitle(title)
                        // android標準アイコンのコンパスを設定
                        .setSmallIcon(android.R.drawable.ic_menu_compass)
                        .setContentText("GPS")
                        .setAutoCancel(true)
                        .setContentIntent(pendingIntent)
                        .setWhen(System.currentTimeMillis())
                        .build();

                // startForeground
                startForeground(1, notification);
            }
        }else{
            NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this)
                    .setSmallIcon(android.R.drawable.ic_menu_compass)
                    .setContentTitle(title)
                    .setContentText("GPS")
                    .setAutoCancel(true)
                    .setContentIntent(pendingIntent);

            int mNotificationId = 001;
            notificationManager.notify(mNotificationId, notificationBuilder.build());
        }

        return Service.START_STICKY;

    }

アイコンを消去する

ところがこのままだと、アイコンがずっと出っ放しになっている。Android 8以降の場合、バインドを解除してサービスとストップするとアイコンは消えるようだ。そこで、RotateMapViewer.javaのonDestroyに処理を追加した。Intent intent = new Intent(getApplication(), LocationService.class)でLocationServiceのIntentを発行し、this.getApplication().unbindService(serviceConnection)でバインドを解除した。その上で、 stopService(intent)でサービスをストップしている。ところが、Andoroid 6(実機のXperiaZ4)では、この方法ではアイコンが消えないので、更に NotificationManager mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)とmNotificationManager.cancelAll()でアイコンを消去している。
ちなみに、Andoroid 6では、バーを下に引っ張ってアイコンを消去するボタンをタップすると、他のアイコンと一緒に消去されてしまう。消えないようにする方法もあるみたいだが、とりあえすこれで動きそうなのでこのままにしておくことにした。
この処理は、onDestroyに置いておかないと正しく動作しない。始めは、onStopに置いたら、スクリーンオフでスリープに入った時に処理が実行されてバックグラウンド動作がストップしてうまくいかなかった。

   public void onDestroy() {

        try {
            if (locationUpdateReceiver != null) {
                unregisterReceiver(locationUpdateReceiver);
            }

        } catch (IllegalArgumentException ex) {
            ex.printStackTrace();
        }
        Intent intent = new Intent(getApplication(), LocationService.class);
        this.getApplication().unbindService(serviceConnection);
        stopService(intent);

        NotificationManager mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        mNotificationManager.cancelAll();

        super.onDestroy();
   }

マップ表示を終了してメイン画面(メニュー画面)に戻るとバックグラウンド動作が終了し、アイコンも消える。軌跡も消える。という動作になった。

f:id:alasixOsaka:20200430142311j:plain
バックグラウンドでも軌跡が書けている。
また、動作確認中に不具合がわかったので修正を行った。アプリを起動して、例えば、高低図も回転もオフにして地図を表示し、一旦メニュー画面に戻って、今度は高低図と回転をオンにすると、アプリがクラッシュすることが分かった。デバッグで確認するとRotateMapViewer.javaでHeightMap2コマンドの部分で、”on a null object reference”というエラーが出ている。これも結構悩んだ。中身のない変数を使っているよということらしいが、デバッガで見る限り、引数として使っている、GPXs、elvmax、globals.setGPS、Latitude、Longitudeはすべて値が格納されている。結局、エラーの原因はR.id.heightだったが、その前に、HeigtMap関数を呼んでいていこっちはエラーになっていないので、まさかR.id.heightが原因だとは思いもよらなかった。PaintView paintViewを始めに宣言して変数にしておいて、addOverlayLayerの最後の部分でHeigtMapを呼び出しているところで、paintView = findViewById(R.id.height)として一旦、paintViewにR.id.heightを格納してみた。すると、HeightMap2のところでpaintViewがnullの状態で呼び出されていることが分かった。

protected void addOverlayLayers(Layers layers) {

        SharedPreferences preferences = getSharedPreferences("DATA", MODE_PRIVATE);
        GPX_FILE = preferences.getString("gpx", "null.gpx");
        POI_FILE = preferences.getString("poi", "null.poi");
        Path = preferences.getString("path", sdPath);
        
  ~省略~

        layers.add(tappableCircle);
        paintView = findViewById(R.id.height);
        if (globals.setHeight){
            HeightMap();
        }
    }

深く追求するのもめんどくさいので、paintViewがnullなら処理をスキップするようにしたら、クラッシュすることなく動作するようになった。

    private void HeightMap2() {
        //((PaintView)findViewById(R.id.height)).map(GPXs,elvmax,globals.setGPS,Latitude,Longitude);
        if (paintView != null) {
            paintView.map(GPXs, elvmax, globals.setGPS, Latitude, Longitude);

        }
    }

これで、ようやくアプリが完成した(と思う)。あとは実機での検証で不具合があるかどうかを確認してみないといけないが。

参考にしたサイト
[Android] バックグラウンドでGPSログを取り続けるには
[Android] Service の使い方
サービスとNotification - 愚鈍人
Notificationを勉強し直す | Simple is Best
Android Oreoでサービスを使ってみる - Qiita
Android でバックグラウンド(Service)処理(Android8対応) – エンジニアブログ
[Android] Serviceクラス(bindService) - Qiita
[Android]バックグラウンドでセンサーなどのログを取得し続けるには | むあーるの雑記
android: 通知をAPI=26に対応させるには | Ninton
Androidアプリ開発 ステータスバーにNotificationを表示する プログラミングJava
【Android】 Notificationを実装する 【開発メモ】 : 明日のために、今日できることを。

P.S. 2020/5/1追記
一部不具合があったので修正しました。
GPXファイルを表示させない場合、null.gpxという空のファイルを読み込ませてルートを消去していますが、null.gpxを読み込んだ場合に、高低図がOnで現在地表示がOnのとき、無いデータを読み込もうとしてエラーになる不具合がありました。PaintView.javaの該当部分を下記のように変更し、エラーを回避するようにしました。現在表示がOnで現在地点データがある場合の判断に、idx>4を加えています。idxはarrayListの要素数で、GPXデータから解析した、緯度、経度、通算距離、高度が各地点で順番に格納されています。1点でもデータがあれば要素数が4になりますので、要素数が4より大きい場合を判断してデータがある場合のみプロットするようにしています。

//現在地表示がOnで現在地点データがある場合
        if ((setGPS)&&(lat+lon!=0)&&(idx>4)){

            for (int i = 0 ; i < arrays.size()/4 ; i++) {
                ~省略~

また、Andorid8の実端末AQUOS SENSE PLUSで試して気づいたのですが、アプリ側からの設定ではNotificationの通知音をどうやっても消せませんでした。一応、音を消すという設定になっているはずですが、音が鳴るのでいろいろなサイトを調べて試してみましたがどれも効果がなく音が鳴ってしまいました。最終的には端末側の設定で、アプリと通知の部分からアプリを選択し、通知の設定を変更することで音が鳴らないようにできました。今のところこれしか方法が無いようです。