画面をオフにしない、バックグランドでGPS を動かす、軌跡を残す。(アンドロイド地図アプリの開発 その20)

今回は、高低図以外の残りの宿題を一気に片付けてしまいます。

画面をオフにしない。

まず、画面をオフにしない設定ですが、これは簡単にできました。
今回は、地図を表示中に画面を自動でオフにしたくないので、OverlayMapviewer.javaのonCreateの下に下記のように一行加えます。

protected void onCreate(Bundle savedInstanceState) {
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

これで、画面は電源スイッチを操作しないとオフになりません。

バックグランドでGPS を動かす。

東海自然歩道のトレランの時に、時々地図を確認することをしていて、スリープするとGPS が切れてしまっていて、GPS をなかなか拾ってくれなくてイライラしたので、バッテリーの消費は大きくなりますがGPS を切らずにバックグランドで動き続けるようにします。自転車の場合はハンドルにスマホを取り付けて画面をオフにすることなく使うので問題ないのですが、トレランだとずっと持って走るということはなく、時々取り出して地図を確認するという使い方になるのでやっぱりGPS を切らずに使う方が良さそうです。GPS をなかなか拾わない原因の一つはSIMカードをさしてないので通信しないこともあります。スマホが現在地を素早く掴む仕組みとして近くの基地局の電波を拾って三角測量をして位置を決めることも併せてやっているからで、SIMカードの刺さったスマホならイライラ待つ必要はないのですが、何せ廃品利用というか、使わなくなった古いスマホをナビがわりに使おうという事なので仕方がありません。

Service を使う

バックグランドでGPS を動かし続けるにはService と言う仕組みを使うそうです。GPS でログを取るといったことは、比較的取り上げられることなので記事も簡単に見つかります。問題は実装の方法で、下記2つの記事を参考にしました。
medium.com
akira-watson.com
後者の方が実装は簡単そうだったので、始めはこちらの方法を試して見たのですが、GPS の測定結果を受け取るアクティビティが"AppCompactActivity"を継承しないと動かないっぽい。ところが自分の地図アプリの方は継承が複雑でたどっていくとmapsforgeのライブラリーにたどり着いてどうしようもないのでこのやり方は諦めました。
前者の方法だと継承は必要なく、その代わりといってはなんだがブロードキャストという、また新たな仕組みを導入することになり、ちょっとコードが複雑。

ブロードキャストとは

ブロードキャストを使うとイベントが発生したタイミング、この場合GPS の位置情報が更新されたタイミングでアプリ内でイベントの発生をブロードキャストして知らせます。受け取り側のアクティビティはブロードキャストによりイベントの発生を知り処理、この場合は現在地のマークを移動させます。
このイベント処理をonLocationChangedで行っている処理に変えることでバックグランドでGPS を動かし、地図上に反映させることができます。
ソースコードです。まず初めに、Serviceクラスを作成します。これは、通常の左ペインの右クリックでJavaを選ぶところをServiceを選択して作成します。選択肢が2つありますが、単なるServiceの方を選択します。
クラス名はLocationServiceとしました。このとき、Exportedのチェックボックスは外してOKをクリックして作成します。

package com.example.mpf_rotationB;
public class LocationService extends Service implements LocationListener, GpsStatus.Listener {
    public static final String LOG_TAG = LocationService.class.getSimpleName();

    private final LocationServiceBinder binder = new LocationServiceBinder();
    boolean isLocationManagerUpdatingLocation;

    private Context context;

    public LocationService() {
    }

    @Override
    public void onCreate() {
        isLocationManagerUpdatingLocation = false;
        context = getApplicationContext();
    }

    @RequiresApi(api = Build.VERSION_CODES.O)
    @Override
    public int onStartCommand(Intent i, int flags, int startId) {
        super.onStartCommand(i, flags, startId);
   
        return Service.START_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) {
        return binder;
    }

    @Override
    public void onRebind(Intent intent) {
        Log.d(LOG_TAG, "onRebind ");
    }

    @Override
    public boolean onUnbind(Intent intent) {
        Log.d(LOG_TAG, "onUnbind ");

        return true;
    }

    @Override
    public void onDestroy() {
        Log.d(LOG_TAG, "onDestroy ");
    }

    //This is where we detect the app is being killed, thus stop service.
    @Override
    public void onTaskRemoved(Intent rootIntent) {
        Log.d(LOG_TAG, "onTaskRemoved ");

        if(this.isLocationManagerUpdatingLocation == true){
            this.stopUpdatingLocation();
            isLocationManagerUpdatingLocation = false;
        }

        stopSelf();
    }

    /**
     * Binder class
     *
     * @author Takamitsu Mizutori
     *
     */
    public class LocationServiceBinder extends Binder {
        public LocationService getService() {
            return LocationService.this;
        }
    }

    /* LocationListener implemenation */
    @Override
    public void onProviderDisabled(String provider) {
        if (provider.equals(LocationManager.GPS_PROVIDER)) {
            notifyLocationProviderStatusUpdated(false);
        }
    }

    @Override
    public void onProviderEnabled(String provider) {
        if (provider.equals(LocationManager.GPS_PROVIDER)) {
            notifyLocationProviderStatusUpdated(true);
        }
    }

    @Override
    public void onStatusChanged(String provider, int status, Bundle extras) {
        if (provider.equals(LocationManager.GPS_PROVIDER)) {
            if (status == LocationProvider.OUT_OF_SERVICE) {
                notifyLocationProviderStatusUpdated(false);
            } else {
                notifyLocationProviderStatusUpdated(true);
            }
        }
    }

    /* GpsStatus.Listener implementation */
    public void onGpsStatusChanged(int event) {
    }

    private void notifyLocationProviderStatusUpdated(boolean isLocationProviderAvailable) {
        //Broadcast location provider status change here
    }

    public void startUpdatingLocation() {

        LocationManager locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);

        //Exception thrown when GPS or Network provider were not available on the user's device.
        try {
            Criteria criteria = new Criteria();
            criteria.setAccuracy(Criteria.ACCURACY_FINE);
            criteria.setPowerRequirement(Criteria.POWER_HIGH);
            criteria.setAltitudeRequired(false);
            criteria.setSpeedRequired(false);
            criteria.setCostAllowed(true);
            criteria.setBearingRequired(false);

            //API level 9 and up
            criteria.setHorizontalAccuracy(Criteria.ACCURACY_HIGH);
            criteria.setVerticalAccuracy(Criteria.ACCURACY_HIGH);
            //criteria.setBearingAccuracy(Criteria.ACCURACY_HIGH);
            //criteria.setSpeedAccuracy(Criteria.ACCURACY_HIGH);

            Integer gpsFreqInMillis = 1000;
            Integer gpsFreqInDistance = 1;  // in meters

            locationManager.addGpsStatusListener(this);

            locationManager.requestLocationUpdates(gpsFreqInMillis, gpsFreqInDistance, criteria, this, null);

        } catch (IllegalArgumentException e) {
            Log.e(LOG_TAG, e.getLocalizedMessage());
        } catch (SecurityException e) {
            Log.e(LOG_TAG, e.getLocalizedMessage());
        } catch (RuntimeException e) {
            Log.e(LOG_TAG, e.getLocalizedMessage());
        }
    }

    public void stopUpdatingLocation(){
        LocationManager locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
        locationManager.removeUpdates(this);
    }

    @Override
    public void onLocationChanged(final Location newLocation) {
        Log.d(TAG, "(" + newLocation.getLatitude() + "," + newLocation.getLongitude() + ")");
        Intent intent = new Intent("LocationUpdated");
        intent.putExtra("location", newLocation);

        LocalBroadcastManager.getInstance(this.getApplication()).sendBroadcast(intent);
    }
}

ソースの方は参考サイトをほぼそのままコピペしたのであまりよくわかっていませんが、GPS周りの処理はすべてこのServiceクラスで行っています。重要なのはonLocationChangedの部分で、GPSで位置情報が更新されたらここに飛んできます。ここで、ブロードキャストを実行して、位置情報の更新を知らせます。
Serviceクラスを作成すると、Manifestに自動的に下記のようなServiceの記述が追記されます。

<service
            android:name="com.example.mpf_rotationB.LocationService"
            android:enabled="true"
            android:exported="false"></service>

そして、LocationOverlayViewer.javaのOncreateに下記のようにLocationSeviceのインテントを作成しバインドします。またlocationUpdateRecieverでブロードキャストの情報を受け取る記述を追記します。

protected void onCreate(Bundle savedInstanceState) {
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        super.onCreate(savedInstanceState);
        final Intent serviceStart = new Intent(this.getApplication(), LocationService.class);
        this.getApplication().startService(serviceStart);
        this.getApplication().bindService(serviceStart, serviceConnection, BIND_AUTO_CREATE);

        //initLocationManager();
        locationUpdateReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                Location newLocation = intent.getParcelableExtra("location");

                //drawLocationAccuracyCircle(newLocation);
                drawUserPositionMarker(newLocation);

            }
        };

更にServiceConnectionという内部クラスを作成し、StartUpdatingLocationで位置情報の更新を開始します。

private ServiceConnection serviceConnection = new ServiceConnection() {
        public void onServiceConnected(ComponentName className, IBinder service) {
            String name = className.getClassName();

            if (name.endsWith("LocationService")) {
                locationService = ((LocationService.LocationServiceBinder) service).getService();

                locationService.startUpdatingLocation();
            }
        }

        public void onServiceDisconnected(ComponentName className) {
            if (className.getClassName().equals("LocationService")) {
                locationService.stopUpdatingLocation();
                locationService = null;
            }
        }
    };

ブロードキャストを受け取るとdrawUserPositionMarkerを呼び出し、地図上の現在地を更新します。
ここで、メニューのGPSをオンに選んでいると、現在地のマーカーを書き込みます。GPSをバックグランドで動かし続けるのでGPSオフを選んでいてもGPSは動き続け、位置が更新されればブロードキャストを実行しますので、if文で処理を分岐しています。これをやらないと、GPSオフを選んでいてもマーカーが表示され、地図の中心が現在地に固定されてしまいます。
if文の中は、元々のonLocationChangedと同じ処理で、マーカーを現在地に書き込み、地図の中心を現在地に移します。

private void drawUserPositionMarker(Location location){
        if (globals.setGPS==true) {
            Latitude = location.getLatitude();
            Longitude = location.getLongitude();
            
            this.myLocationOverlay.setPosition(Latitude, Longitude, location.getAccuracy());

            // Follow location
            this.mapView.setCenter(new LatLong(Latitude, Longitude));
        }
    }

軌跡を残す

今回、一番苦労したのが軌跡を残す処理でした。
バックグランドでのGPS 動作で参考にしたサイトでgoogle map に軌跡を残す記事があり参考にしましたが、さすがにベースのアクティビティが違い過ぎてうまくいかない。そこで、次に参考にしたのがmapsforge の例を紹介している次のサイト
www.programcreek.com
ここにやろうとしている処理とほぼ同じ処理を見つけました。軌跡を描くためのレイヤーを追加し、現在地がアップデートされたら軌跡を描いているレイヤーを特定し、線を追記しています。
レイヤーの追加は、CreateLayers()に軌跡を描画用にPolyline2というレイヤーを追記しています。線の色は赤色にしています。

@Override
    protected void createLayers() {
        super.createLayers();
      
   ~省略~
       
   polyline2 = new Polyline(Utils.createPaint(
                AndroidGraphicFactory.INSTANCE.createColor(Color.RED),
                (int) (4 * mapView.getModel().displayModel.getScaleFactor()),
                Style.STROKE), AndroidGraphicFactory.INSTANCE);

        ~省略~

    }

こうしておいて、drawUserPositonMarkerの中で、

latLong15 = new LatLong(Latitude,Longitude);
Layers x = mapView.getLayerManager().getLayers();
int i = layerX.indexOf(this.polyline2);
((Polyline) mapView.getLayerManager().getLayers().get(i)).getLatLongs().add(latLong15);

として、位置情報が更新されるたびに軌跡を追記するようにしました。

ところが、最初はうまくいくのですが、一旦メニュー画面に戻るとエラーが発生してアプリが停止してしまう。デバッグで原因を探るとどういう訳かメニューに戻った直後にレイヤーの情報がクリアされている。
Layers x = mapView.getLayerManager().getLayers();
int i = layerX.indexOf(this.polyline2); 
で取得した結果が-1になっている。-1ということは該当のレイヤが無いということを意味している。
デバッグモードでたどっていってもしっかりCreateLayersのところでレイヤーの追加をやっているにもかかわらずGPS がアップデートされたときの処理drawUserPositionMarkerにやって来るとレイヤー情報が消えてしまっている。まったく訳がわからないが、参考にしたサイトで
Layers layers = mapView.getLayerManager().getLayers();
if (startMarker != null) {
removeLayer(layers, startMarker);
}
としていたのを思い出して、レイヤー情報がない場合処理をスキップするようにして見たところ何故かうまくいった。どうもすっきりしないがとりあえず動いたのでよしとすることにする。

Layers x = mapView.getLayerManager().getLayers();
            //int i = mapView.getLayerManager().getLayers().indexOf(this.polyline2);
            int i = layerX.indexOf(this.polyline2);
            if (i>=0) {
                ((Polyline) mapView.getLayerManager().getLayers().get(i)).getLatLongs().add(latLong15);
            }

エミュレータで動かしてみた。青がGPXファイルの想定ルート、赤が軌跡。GPSの位置情報の入れ方が少しアバウトなので青のGPXと赤の軌跡が完全に重なっていないが、実際にもこんなくらいの誤差はありうるのでかえってリアルに見える。

f:id:alasixOsaka:20200327155222j:plain
青がGPXのルート、赤が軌跡
この軌跡は、メニュー画面に戻るとmapviewを再描画するので消えてしまう。これを防ぐにはログを記録しておいて再描画の際に書き直す処理をする必要があるが、とりあずこれで使い勝手を見てみたいと思っている。

Notificationについて

アンドロイドではバックグラウンドでアプリが動作していることなどを知らせるためのNotificationという仕組みがある。ところが、こいつの仕組みがOreo(Android8)から大きく変更になっていて、実装の仕方が全く異なっている。Oreo以降ではチャンネルという仕組みを使うらしいが、この仕組みを古いOreo以前のAndroidが搭載されているスマホに入れてもうまく動かない。自分の場合はエミュレータはAPI26にしているのでチャンネルを使ったNotificationがうまく動作したが、実機(XperiaZ4)はAndroid6なので、エラーになって動かない。一方で古い仕組みを使ってみるとエミュレータでは動かない。Androidデベロッパーサイトには、古い仕組みは推奨されないが、互換性があるので動作はするというようなことが書いてあるが実際にやってみると動かない。Notificationはあると便利だが、古いスマホを地図アプリ専用に使おうとしているのでなくても大きな不便はないと思われるので、今後の課題ということにして今回は実装しないことにした。また、スマホを買い換えたら考えてみようと思う。

さて残りは最難関の高低図の描画だ。一応、GPXファイルから高さ情報を拾ってきて、高低図のプロットまでは何とかできそうな目途がついてきている。ただ、その図を地図アプリの方に書き込むのができるのかどうか。このへんが大きなポイントになりそうだ。


参考にしたサイト
Androidで明示的に画面をOFFにさせない - Qiita
位置情報を正確にトラッキングする技術 – Medium
[Android] バックグラウンドでGPSログを取り続けるには
MapHandler Java Source Code