【osmdroid】Android で GPS ロガーを自作する(Java)

はじめに

 登山やサイクリングの移動ログを残して、サイト上の地図に記載したいと思う事がありますが、そういった場合にはGPSログをとることが必要になってきます。そこで、今回は Android で GPS ログを取得して、その経緯度データを CSV 出力するようなアプリを自作したいと思います。

 なお、この記事は「【osmdroid】GPSで現在位置を表示する」の続きです。

デモアプリ

 当サイトでは、osmdroid を用いた GPS ロガーのデモアプリを公開しています。下記のボタン、あるいはQRコードよりダウンロードできます。このアプリは Android4.1 から Android13 までに対応しています。
 

 


備考:デモアプリの Google Play での公開はめんどくさすぎて心が折れました。
 なお、デモアプリ上で使用しているマップデータは当サイトのサーバーに保存しているため、サーバースペックにより低解像度の地図となっています。高解像度な地図をご所望の方は、以下にソースコードを公開しているので、Android Studio でアプリを作成して、OpenStreetMap にアクセスしてください。
 後述していますが、機種によってはバッテリー消費を抑える為、アプリが強制停止されることがあります。その場合は、バッテリー設定より、バックグラウンドでの動作を常に許可してください。
 → プライバシーポリシー

デモンストレーション

 今回紹介するGPSロガーアプリのデモンストレーションです。

 まず、アプリをダウンロードして、正確な位置情報へのアクセスの許可と、常に位置情報へアクセスする許可を行うと、以下のようなローディング画面が開きます。

図1. ローディング画面

 数秒時間が経つと、現在位置が中央に表示されます。今回は博多駅から新大阪駅まで新幹線で移動するときのログを取得してみます。サンプリング間隔を10秒に設定し、「GPSロガーを開始する」ボタンをタップするGPSロガーが開始されます。

図2. 現在位置の表示

 GPSロガー動作の途中経過の様子です(博多駅ー広島駅間)。移動距離が表示され、「GPSロガーを開始する」ボタンの表示が、「GPSロガーを停止する」の表示に書き換わっていることが分かります。適切にログがとれているように見えますが、新幹線はトンネルが多く、トンネルではGPSログの取得がスキップされています(位置情報の精度が50m以下の時にはログに記録しないようにフィルタをかけています。)。

図3. GPSロガーの動作状況

 ゴールの新大阪駅についたので、「GPSロガーを停止する」ボタンをタップして、GPSロガーを停止しました。「GPSロガーを停止する」ボタンの表示が「GPSログを削除する」に書き換わっていることが分かります。

図3. GPSロガーの停止

 取得したGPSログの全体像です。博多駅と新大阪駅を結ぶデータがとれていることが分かります。

図4. GPSロガーの軌跡

 ここで、GPSログを削除せず、「CSVを出力する」ボタンをタップします。すると入力ダイアログが立ち上がるので、ファイル名(拡張子抜き)を入力すると、Download フォルダに GPS ログが CSV で保存されます。

図5. CSV ファイル名の入力

 得られた CSV ファイルを、DrawTrail の「CSV読み込み」→「ラインを描く」から読み込み、「エクスポート」の「埋め込みコードを生成</>」から JavaScript を生成して、カスタムHTMLでGPSログを埋め込むと、以下のようになります。

DrawTrail

図6. 博多駅-新大阪駅間のGPSログ

 なお注意点なのですが、Android の機種によっては、「バックグラウンドアクティビティを許可」しておかないと、途中でバッテリー消費を抑えるために、GPS ロガーが停止してしまうことがあります。そのときは、「設定」→「バッテリー」→「バッテリー使用量」から TrekkingTrail を選択し、「バックグラウンドアクティビティを許可」を選択しておいてください。

GPSロガーアプリの動作の流れ

 今回紹介するGPSロガーアプリの基本的な動作の流れについて説明します。

起動時

 初回起動時は、権限のリクエストを行います。特に、GPSログの取得に必要な「ACCESS_FINE_LOCATION」と、フォアグラウンドサービス中に位置情報へアクセスすることを許可する「ACCESS_BACKGROUND_LOCATION」のアクセス許可が必要になります。これらが許可されたら、現在位置の表示に移ります。
 現在位置の表示では、GPS位置情報の取得と同時にデータベースへアクセスし、GPSログが残っていたら軌跡を Polyline として表示します。ここまでの処理(権限のリクエストから現在位置の表示まで)は少し時間がかかるので、スプラッシュ画面でアプリの操作を制限しています。
 なお、アプリを開いているときは、現在位置を1秒間隔で更新するようにしています。その間にデータベースが変化したら、その変更内容も(測距結果も含めて)更新されるようになっています。

 MainActivity.java onCreate()(初期設定) → onRequestPermissionResult()(ACCESS_FINE_LOCATION のリクエスト結果) → checkForegroundGPS() (ACCESS_BACKGROUND_LOCATIONの確認)→ startLocationActivity() (現在位置の取得とスプラッシュ画面終了)まで

GPSロガーボタン押下時

 GPSロガーボタンが押されたとき、既にGPSロガー動作中ではなく、かつログが残っていない場合に、新たにGPSロガーを開始します。GPSロガーはフォアグラウンドサービス(ActivityからUIを無くしたようなコンポーネント)で動作しており、スピナー(プルダウンメニュー)で設定されたサンプリング間隔を元に、GPSログを収集していき、データベース(SQLite)に書き込みます。
 再度GPSロガーボタンが押されたとき(既にGPSロガー動作中の場合)は、stopService() でフォアグラウンドサービスを停止します。停止後にログが残っていたら、三回目GPSロガーボタン押下時にはログを削除します。

MainActivity.java GPSLoggerBtnClickListener()(GPSロガーボタンのイベントリスナー) ↔ GPSLoggerService.java onStartCommand()(フォアグラウンドサービスの初期設定) → GPSLog() (GPSログの取得とデータベースへの記録)まで

CSV 出力ボタン押下時

 CSV出力ボタンが押されたとき、データベースにGPSログが残っていたら、初回はフォルダへのアクセス権限(WRITE_EXTERNAL_STRAGE)を確認します。許可された場合、入力ダイアログにファイル名を入力してもらい、Download フォルダに保存します。

MainActivity.java csvBtnClickListener() (CSVボタンのイベントリスナー)→ exportCSVData() (CSVデータの保存)

全体のソースコード

 Android Studio で「File」→「New」→「New Project」で、新しい Empty Activity を作成します。ここでは、プロジェクト名を「TrekkingTrail」としました。

build.gradle

plugins {
    id 'com.android.application'
}

android {
    namespace 'com.example.trekkingtrail'
    compileSdk 33

    defaultConfig {
        applicationId "com.example.trekkingtrail"
        minSdk 16
        targetSdk 33
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.android.material:material:1.9.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    implementation 'org.osmdroid:osmdroid-android:6.1.13'
    implementation 'com.google.android.gms:play-services-location:21.0.1'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}

values

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="spinner_items">
        <item>1 秒</item>
        <item>3 秒</item>
        <item>5 秒</item>
        <item>10 秒</item>
        <item>30 秒</item>
        <item>1 分</item>
        <item>3 分</item>
        <item>5 分</item>
    </string-array>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#FFBB86FC</color>
    <color name="purple_500">#FF6200EE</color>
    <color name="purple_700">#FF3700B3</color>
    <color name="teal_200">#FF03DAC5</color>
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>
    <color name="appTheme">#337ab7</color>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">TrekkingTrail</string>
    <string name="distance">移動距離: 0.0 km</string>
    <string name="startGPSLogger">GPSロガーを開始する</string>
    <string name="stopGPSLogger">GPSロガーを停止する</string>
    <string name="clearGPSLog">GPSログを削除する</string>
    <string name="exportCSVFile">CSVを出力する</string>
    <string name="samplingTime">サンプリング間隔:</string>
    <string name="waitingText">GPSデータ取得中です</string>
    <string name="dialogHint">CSVファイル名を入力してください。</string>
</resources>
<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.TrekkingTrail" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/black</item>
        <item name="colorPrimaryVariant">@color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor" tools:targetApi="21">@color/appTheme</item>
        <!-- Customize your theme here. -->
    </style>

    <style name="SplashTheme" parent="Theme.TrekkingTrail">
        <item name="android:windowBackground">@drawable/splash</item>
    </style>
</resources>

drawable

<?xml version="1.0" encoding="utf-8"?>
<layer-list
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:opacity="opaque"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <item
        android:drawable="@color/appTheme"/>

</layer-list>

AndroidManifest

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <!-- Required only when requesting background location access on
     Android 10 (API level 29) and higher. -->
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        tools:ignore="ScopedStorage" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/Theme.TrekkingTrail"
        tools:targetApi="33">

        <service
            android:name=".GPSLoggerService"
            android:foregroundServiceType="location">
        </service>

        <activity
            android:name=".MainActivity"
            android:screenOrientation="portrait"
            android:exported="true"
            android:theme="@style/SplashTheme"
            android:excludeFromRecents="false">

            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

layout

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <FrameLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_weight="1">

                <org.osmdroid.views.MapView
                    android:id="@+id/mapView"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent">
                </org.osmdroid.views.MapView>

                <TextView
                    android:id="@+id/mapCaption"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="end|top"
                    android:background="#B3FFFFFF"
                    android:paddingLeft="5dp"
                    android:paddingTop="2dp"
                    android:paddingRight="5dp"
                    android:paddingBottom="2dp"
                    android:text="@string/app_name"
                    android:textColor="#000000"
                    android:textSize="11sp" />

            </FrameLayout>

            <View
                android:id="@+id/divider"
                android:layout_width="match_parent"
                android:layout_height="1dp"
                android:background="#000000" />

            <TextView
                android:id="@+id/distanceDisplay"
                android:layout_width="match_parent"
                android:layout_height="40dp"
                android:gravity="center"
                android:text="@string/distance"
                android:textColor="#000000" />

            <Button
                android:id="@+id/GPSLoggerBtn"
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:layout_marginLeft="30dp"
                android:layout_marginRight="30dp"
                android:text="@string/startGPSLogger"
                app:backgroundTint="@color/appTheme" />

            <Button
                android:id="@+id/csvBtn"
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:layout_marginLeft="30dp"
                android:layout_marginRight="30dp"
                android:text="@string/exportCSVFile"
                app:backgroundTint="@color/appTheme"/>

            <LinearLayout
                android:id="@+id/spinnerContainer"
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:orientation="horizontal">

                <TextView
                    android:id="@+id/samplingTime"
                    android:layout_width="match_parent"
                    android:layout_height="50dp"
                    android:layout_weight="1"
                    android:gravity="center"
                    android:text="@string/samplingTime"
                    android:textColor="#000000" />

                <Spinner
                    android:id="@+id/spinner"
                    android:layout_width="match_parent"
                    android:layout_height="50dp"
                    android:layout_weight="1"
                    android:entries="@array/spinner_items"
                    android:gravity="center" />
            </LinearLayout>
        </LinearLayout>

        <LinearLayout
            android:id="@+id/waitingScreen"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center"
            android:orientation="vertical">

            <TextView
                android:id="@+id/AppTitle"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:gravity="center"
                android:text="@string/app_name"
                android:textColor="#FFFFFF"
                android:textSize="30sp" />

            <ProgressBar
                android:id="@+id/loadingBar"
                style="?android:attr/progressBarStyle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="50dp"
                android:maxWidth="60dp"
                android:maxHeight="60dp"
                android:minWidth="60dp"
                android:minHeight="60dp"
                android:indeterminate="true"/>

            <TextView
                android:id="@+id/waitingText"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:gravity="center"
                android:layout_marginTop="30dp"
                android:text="@string/waitingText"
                android:textColor="#FFFFFF"
                android:textSize="15sp" />
        </LinearLayout>
    </FrameLayout>

</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@android:id/text1"
    style="?attr/spinnerDropDownItemStyle"
    android:singleLine="true"
    android:layout_width="match_parent"
    android:layout_height="?attr/dropdownListPreferredItemHeight"
    android:ellipsize="marquee"
    android:textColor="@android:color/black"
    android:background="@android:color/white"
    android:textAlignment="center"
    android:gravity="center_horizontal" />
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@android:id/text1"
    style="?attr/spinnerDropDownItemStyle"
    android:singleLine="true"
    android:layout_width="match_parent"
    android:layout_height="?attr/dropdownListPreferredItemHeight"
    android:ellipsize="marquee"
    android:textColor="@android:color/black"
    android:background="@android:color/white"
    android:textAlignment="center"
    android:gravity="center" />
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <androidx.appcompat.widget.AppCompatEditText
        android:id="@+id/editTextDialog"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginEnd="16dp"
        android:hint="@string/dialogHint"
        android:inputType="text"
        android:maxLines="1"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

java

package com.example.trekkingtrail;

import android.Manifest;
import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.app.AlertDialog;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;

import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.graphics.Color;
import android.location.Location;
import android.media.MediaScannerConnection;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Looper;
import android.preference.PreferenceManager;
import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.Spinner;
import android.widget.TextView;
import android.widget.Toast;

import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationCallback;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationResult;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.location.Priority;

import org.osmdroid.api.IMapController;
import org.osmdroid.config.Configuration;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.MapView;
import org.osmdroid.views.overlay.Marker;
import org.osmdroid.views.overlay.Polyline;
import org.osmdroid.views.overlay.ScaleBarOverlay;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
import java.util.regex.Pattern;

public class MainActivity extends AppCompatActivity {

    private final int REQUEST_PERMISSION = 1000;
    private boolean requestingLocationUpdates = false;
    private boolean firstAccess = true;
    private LocationRequest.Builder locationRequest;
    private LocationCallback locationCallback;
    private FusedLocationProviderClient fusedLocationClient;
    private SQLiteActivity helper = null;
    private SQLiteDatabase db = null;
    private int spinnerCount = 1;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Configuration.getInstance().load(getApplicationContext(), PreferenceManager.getDefaultSharedPreferences(getApplicationContext()));
        setTheme(R.style.Theme_TrekkingTrail);
        setContentView(R.layout.activity_main);

        //読み込み時のタッチ無効化
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);

        // ローディング用画面の表示。スプラッシュと同じ背景に差し替える。
        LinearLayout splash = (LinearLayout) findViewById(R.id.waitingScreen);
        splash.setBackgroundColor(getResources().getColor(R.color.appTheme));

        //ローディング画面のプログレスバーの色を変更する。
        ProgressBar progressBar = (ProgressBar) findViewById(R.id.loadingBar);
        progressBar.getIndeterminateDrawable().setColorFilter(getResources().getColor(R.color.white), android.graphics.PorterDuff.Mode.SRC_IN);


        // 「GPSLoggerBtn」ボタンのイベントリスナーを設定
        Button GPSLoggerBtn = (Button) findViewById(R.id.GPSLoggerBtn);
        GPSLoggerBtn.setOnClickListener(new GPSLoggerBtnClickListener());

        // 「csvBtn」ボタンのイベントリスナーを設定
        Button csvBtn = (Button) findViewById(R.id.csvBtn);
        csvBtn.setOnClickListener(new csvBtnClickListener());

        // spinner の初期値を設定
        Spinner spinner = findViewById(R.id.spinner);
        ArrayAdapter<String> adapter = new ArrayAdapter<>(
                this,
                R.layout.custom_spinner,
                getResources().getStringArray(R.array.spinner_items)
        );
        adapter.setDropDownViewResource(R.layout.custom_spinner_dropdown);
        spinner.setAdapter(adapter);
        spinner.setOnItemSelectedListener(new spinnerSelectListener());
        SharedPreferences prefs = getSharedPreferences("spinnerState", Activity.MODE_PRIVATE);
        int stateInt = prefs.getInt("INTERVAL", 3);
        spinner.setSelection(stateInt);

        TextView mapCap = (TextView)findViewById(R.id.mapCaption);
        mapCap.setText("Ⓒ OpenStreetMap\ncontributors");

        Pattern pattern = Pattern.compile("OpenStreetMap");
        final String strUrl = "https://www.openstreetmap.org/copyright";
        Linkify.TransformFilter filter = (match, url) -> strUrl;
        Linkify.addLinks(mapCap, pattern, strUrl, null, filter);

        /*---------- GPSのアクセス許可設定 ----------*/
        // API23以上でパーミッションを確認する。
        if (Build.VERSION.SDK_INT >= 23) {
            // パーミッションが既に許可されている場合
            if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
                // 実行
                checkForegroundGPS();
            // 権限の根拠を示す必要がある場合
            } else if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.ACCESS_FINE_LOCATION)) {
                AlertDialog.Builder builder = new AlertDialog.Builder(this);
                // 権限が必要な理由・メリットを説明
                builder.setMessage("アプリを継続するためには正確な位置情報の取得が必要です。")
                        .setPositiveButton("OK", (dialog, id) ->
                                ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, REQUEST_PERMISSION))
                        .setNegativeButton("Cancel", (dialog, id) ->
                                Toast.makeText(this, "正確な位置情報が許可されないとアプリを実行できません。", Toast.LENGTH_LONG).show());
                builder.create();
                builder.show();
            // 権限の根拠を示す必要が無い場合
            } else {
                // システム権限を要求する
                ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, REQUEST_PERMISSION);
            }
        } else {
            // APIが22以下なら実行
            checkForegroundGPS();
        }
    }

    // パーミッションダイアログの結果受け取り
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == REQUEST_PERMISSION) {
            Log.d("debug", permissions[0]);
            // ダイアログで承認された場合
            if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                checkForegroundGPS();
            // ダイアログで拒否された場合
            } else {
                Toast.makeText(this, "権限が許可されないとアプリを実行できません。", Toast.LENGTH_LONG).show();
            }
        }
    }

    private void checkForegroundGPS() {
        // API28以上でパーミッションを確認する。
        if (Build.VERSION.SDK_INT >= 29) {
            // パーミッションが既に許可されている場合
            if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED) {
                // 実行
                startLocationActivity();
                // 権限の根拠を示す必要がある場合
            } else if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION)) {
                AlertDialog.Builder builder = new AlertDialog.Builder(this);
                // 権限が必要な理由・メリットを説明
                builder.setMessage("GPSロガーを使用するためにはバックグラウンドでの位置情報の取得が必要です。位置情報の権限を常に許可する必要があります。")
                        .setPositiveButton("OK", (dialog, id) ->
                                ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}, REQUEST_PERMISSION))
                        .setNegativeButton("Cancel", (dialog, id) ->
                                Toast.makeText(this, "位置情報の権限が常に許可されないと、アプリを実行できません。", Toast.LENGTH_LONG).show());
                builder.create();
                builder.show();
                // 権限の根拠を示す必要が無い場合
            } else {
                // システム権限を要求する
                ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}, REQUEST_PERMISSION);
            }
        } else {
            // APIが28以下なら実行
            startLocationActivity();
        }
    }
    private void startLocationActivity() {

        if(helper == null){
            helper = new SQLiteActivity(getApplicationContext());
        }

        if(db == null){
            db = helper.getReadableDatabase();
        }

        Cursor cursor = db.query("GPSLogDB",
                new String[] { "GPSLogLati", "GPSLogLong", "GPSLogAlti"},
                null, null, null, null, null);
        cursor.moveToFirst();
        Resources res = getResources();
        if(cursor.getCount() != 0) {
            Button GPSLoggerBtn = (Button) findViewById(R.id.GPSLoggerBtn);
            String buttonStr = res.getString(R.string.clearGPSLog);
            GPSLoggerBtn.setText(buttonStr);
        }

        cursor.close();

        if(GPSLoggerService.isAppInForeground) {
            Button GPSLoggerBtn = (Button) findViewById(R.id.GPSLoggerBtn);
            String buttonStr = res.getString(R.string.stopGPSLogger);
            GPSLoggerBtn.setText(buttonStr);
        }

        MapView mapView = (MapView) findViewById(R.id.mapView);
        IMapController mapController = mapView.getController();

        // マップの初期設定
        mapView.setHorizontalMapRepetitionEnabled(true);
        mapView.setVerticalMapRepetitionEnabled(false);
        mapView.setScrollableAreaLimitLatitude(MapView.getTileSystem().getMaxLatitude(), MapView.getTileSystem().getMinLatitude(), 0);
        mapView.setMinZoomLevel(3.0);
        if (firstAccess) {
            mapController.setZoom(17.0);
        }

        mapView.setMultiTouchControls(true);

        requestingLocationUpdates = true;
        locationRequest = new LocationRequest.Builder(1000);
        locationRequest.setPriority(Priority.PRIORITY_HIGH_ACCURACY);

        locationCallback = new LocationCallback() {
            @Override
            public void onLocationResult(@NonNull LocationResult locationResult) {
                for (Location location : locationResult.getLocations()) {
                    // マップの表示を一旦すべて消す
                    mapView.getOverlays().clear();
                    mapView.invalidate();

                    loadMarkers();

                    // 現在位置を示すマーカーを追加
                    GeoPoint currentPoint = new GeoPoint(location.getLatitude(), location.getLongitude());
                    Marker currentMarker = new Marker(mapView);
                    currentMarker.setPosition(currentPoint);
                    currentMarker.setTitle ( "現在地" );
                    mapView.getOverlays().add(currentMarker);

                    ScaleBarOverlay scaleBar = new ScaleBarOverlay(mapView);
                    mapView.getOverlays().add(scaleBar);

                    if (firstAccess) {
                        // アプリを開いた瞬間なら現在位置を中心に表示する
                        mapController.setCenter(currentPoint);

                        // スプラッシュ画面をフェードアウトさせる
                        int colorFrom = getResources().getColor(R.color.appTheme);
                        int colorTo = Color.parseColor("#00000000");
                        ValueAnimator colorAnimation = ValueAnimator.ofObject(new ArgbEvaluator(), colorFrom, colorTo);
                        colorAnimation.setDuration(500);
                        colorAnimation.addUpdateListener(animator -> {
                            LinearLayout splashIMG = (LinearLayout) findViewById(R.id.waitingScreen);
                            splashIMG.setBackgroundColor((int) animator.getAnimatedValue());
                        });
                        colorAnimation.start();

                        //ローディング用オブジェクトの非表示化
                        findViewById(R.id.AppTitle).setVisibility(View.INVISIBLE);
                        findViewById(R.id.loadingBar).setVisibility(View.INVISIBLE);
                        findViewById(R.id.waitingText).setVisibility(View.INVISIBLE);

                        //タッチ無効化の解除
                        getWindow().clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);

                        firstAccess = false;
                    }
                }
            }
        };
        fusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
    }

    private void startLocationUpdates() {
        if (ActivityCompat.checkSelfPermission(
                this, Manifest.permission.ACCESS_FINE_LOCATION)
                != PackageManager.PERMISSION_GRANTED) {
            return;
        }
        fusedLocationClient.requestLocationUpdates(locationRequest.build(), locationCallback, Looper.getMainLooper());
    }

    private void loadMarkers(){

        if(helper == null){
            helper = new SQLiteActivity(getApplicationContext());
        }

        if(db == null){
            db = helper.getReadableDatabase();
        }

        Cursor cursor = db.query("GPSLogDB",
                new String[] { "GPSLogLati", "GPSLogLong", "GPSLogAlti"},
                null, null, null, null, null);
        cursor.moveToFirst();

        MapView mapView = (MapView) findViewById(R.id.mapView);
        Polyline line = new Polyline(mapView);

        for (int i = 0; i < cursor.getCount(); i++) {
            line.addPoint( new GeoPoint(cursor.getDouble(0),cursor.getDouble(1)) );
            cursor.moveToNext();
        }
        cursor.close();

        line.setTitle ( "GPSログ" );
        mapView.getOverlays().add(line);
        double distance = (double)Math.round(line.getDistance())/1000;
        String distanceStr = String.valueOf(distance);
        TextView distanceDisplay = findViewById(R.id.distanceDisplay);
        String displayStr = "移動距離: " + distanceStr + " km";
        distanceDisplay.setText(displayStr);
    }

    public class GPSLoggerBtnClickListener implements View.OnClickListener {
        @Override
        public void onClick(View v) {
            Button GPSLoggerBtn = (Button) findViewById(R.id.GPSLoggerBtn);

            if(helper == null){
                helper = new SQLiteActivity(getApplicationContext());
            }

            if(db == null){
                db = helper.getReadableDatabase();
            }

            Cursor cursor = db.query("GPSLogDB",
                    new String[] { "GPSLogLati", "GPSLogLong", "GPSLogAlti"},
                    null, null, null, null, null);
            cursor.moveToFirst();
            int csvDataSize = cursor.getCount();
            cursor.close();

            if(GPSLoggerService.isAppInForeground) {
                AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
                builder.setMessage("GPSロガーを停止します")
                        .setPositiveButton("OK", (dialog, which) -> {
                            Resources res = getResources();
                            String buttonStr = res.getString(R.string.clearGPSLog);
                            if(csvDataSize == 0) {
                                buttonStr = res.getString(R.string.startGPSLogger);
                            }
                            GPSLoggerBtn.setText(buttonStr);

                            Intent intent = new Intent(getApplication(), GPSLoggerService.class);
                            stopService(intent);
                        })
                        .setNegativeButton("Cancel", null);
                builder.create();
                builder.show();
            }else{
                if(csvDataSize == 0) {
                    AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
                    builder.setMessage("GPSロガーを開始します。\n\n「バックグラウンドアクティビティを許可」していないと、途中で停止する恐れがあります。")
                            .setPositiveButton("OK", (dialog, which) -> {
                                Resources res = getResources();
                                String buttonStr = res.getString(R.string.stopGPSLogger);
                                GPSLoggerBtn.setText(buttonStr);

                                Intent intent = new Intent(getApplication(), GPSLoggerService.class);
                                if (Build.VERSION.SDK_INT >= 26) {
                                    startForegroundService(intent);
                                } else {
                                    startService(intent);
                                }
                            })
                            .setNegativeButton("Cancel", null);
                    builder.create();
                    builder.show();
                }else{
                    AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
                    builder.setMessage("GPSログを削除します。")
                            .setPositiveButton("OK", (dialog, which) -> {
                                SQLiteDatabase.deleteDatabase(getDatabasePath("GPSLogDB.db"));
                                helper = null;
                                db = null;

                                MapView mapView = (MapView) findViewById(R.id.mapView);
                                mapView.getOverlays().clear();
                                mapView.invalidate();

                                Resources res = getResources();
                                String buttonStr = res.getString(R.string.startGPSLogger);
                                GPSLoggerBtn.setText(buttonStr);
                            })
                            .setNegativeButton("Cancel", null);
                    builder.create();
                    builder.show();
                }
            }
        }
    }

    public class csvBtnClickListener implements View.OnClickListener {
        @Override
        public void onClick(View v) {
            if(helper == null){
                helper = new SQLiteActivity(getApplicationContext());
            }

            if(db == null){
                db = helper.getReadableDatabase();
            }

            Cursor cursor = db.query("GPSLogDB",
                    new String[] { "GPSLogLati", "GPSLogLong", "GPSLogAlti"},
                    null, null, null, null, null);
            cursor.moveToFirst();

            if(cursor.getCount() != 0) {
                // API 23以上でパーミッシンを確認する。
                if(Build.VERSION.SDK_INT >= 23){
                    // パーミッシンが既に許可されている場合
                    if (ActivityCompat.checkSelfPermission(MainActivity.this,
                        Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
                        PackageManager.PERMISSION_GRANTED){
                        //実行
                        exportCSVData();
                    // 初回アクセス、もしくはパーミッシンを拒否に変更された場合
                    }else{
                        //ダイアログを表示
                        //結果はonRequestPermissionsResult()
                        ActivityCompat.requestPermissions(MainActivity.this,
                            new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_PERMISSION);
                    }

                    //API23未満だとそのまま実行
                }else{
                    exportCSVData();
                }
            }else{
                Toast.makeText(MainActivity.this, "GPSログがありません。", Toast.LENGTH_LONG).show();
            }
            cursor.close();
        }
    }

    private void exportCSVData(){
        EditText editView = new EditText(MainActivity.this);
        new AlertDialog.Builder(MainActivity.this)
                .setTitle("CSVファイル名を入力してください。")
                .setView(editView)
                .setPositiveButton("OK", (dialog, whichButton) -> {
                    if(editView.getText().toString().equals("")) {
                        Toast.makeText(MainActivity.this, "ファイル名を入力してください。", Toast.LENGTH_LONG).show();
                    }else{
                        String filename = editView.getText().toString()+".csv";
                        File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
                        File file = new File(path, filename);

                        boolean isExists = file.exists();

                        if(isExists) {
                            Toast.makeText(this, "既に存在するファイル名です。", Toast.LENGTH_LONG).show();
                        }else{
                            StringBuilder csvStr = new StringBuilder();

                            //保存していたマーカーをCSVに記録する。
                            if(helper == null){
                                helper = new SQLiteActivity(getApplicationContext());
                            }
                            if(db == null){
                                db = helper.getReadableDatabase();
                            }

                            Cursor cursor = db.query("GPSLogDB",
                                    new String[] { "GPSLogLati", "GPSLogLong", "GPSLogAlti"},
                                    null, null, null, null, null);
                            cursor.moveToFirst();

                            for (int i = 0; i < cursor.getCount(); i++) {
                                csvStr.append(cursor.getDouble(0)).append(",").append(cursor.getDouble(1)).append(",").append(cursor.getDouble(2)).append("\n");
                                cursor.moveToNext();
                            }
                            cursor.close();

                            try(FileOutputStream fileOutputStream = new FileOutputStream(file, true);
                                OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fileOutputStream, Charset.forName("Shift_JIS"));
                                BufferedWriter bw = new BufferedWriter(outputStreamWriter)) {bw.write(csvStr.toString());
                                bw.flush();

                                String[] paths = {path.toString() + "/" + filename};
                                String[] mimeTypes = {"text/csv"};
                                MediaScannerConnection.scanFile(getApplicationContext(),
                                        paths,
                                        mimeTypes,
                                        mScanCompletedListener);

                                Toast.makeText(this, filename + "を" + path + "に保存しました", Toast.LENGTH_LONG).show();
                            } catch (Exception e) {
                                e.printStackTrace();
                                Toast.makeText(this, "CSVファイルの保存に失敗しました。", Toast.LENGTH_LONG).show();
                            }
                        }
                    }
                })
                .setNegativeButton("Cancel", (dialog, whichButton) -> {
                })
                .show();
    }

    MediaScannerConnection.OnScanCompletedListener mScanCompletedListener = (path, uri) -> {
        Log.d("MediaScannerConnection", "Scanned " + path + ":");
        Log.d("MediaScannerConnection", "-> uri=" + uri);
    };


    public class spinnerSelectListener implements AdapterView.OnItemSelectedListener {
        @Override
        public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
            SharedPreferences prefs = getSharedPreferences("spinnerState", Activity.MODE_PRIVATE);

            if(GPSLoggerService.isAppInForeground) {
                if(spinnerCount == 0) {
                    Toast.makeText(MainActivity.this, "GPSロガー動作中はサンプリング間隔を変更できません。", Toast.LENGTH_LONG).show();
                    spinnerCount += 2;
                }
                int stateInt = prefs.getInt("INTERVAL", 2);
                Spinner spinner = findViewById(R.id.spinner);
                spinner.setSelection(stateInt);
                spinnerCount -= 1;
            }else {
                SharedPreferences.Editor editor = prefs.edit();
                editor.putInt("INTERVAL", i);
                editor.apply();
            }
        }

        @Override
        public void onNothingSelected(AdapterView<?> adapterView) {

        }
    }
    @Override
    protected void onResume() {
        super.onResume();
        if (requestingLocationUpdates) {
            startLocationUpdates();
        }
    }

    @Override
    protected void onPause() {
        super.onPause();
        if(!firstAccess) {
            fusedLocationClient.removeLocationUpdates(locationCallback);
        }
    }
}
package com.example.trekkingtrail;

import android.Manifest;
import android.app.Activity;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.database.sqlite.SQLiteDatabase;
import android.location.Location;
import android.os.Build;
import android.os.IBinder;
import android.os.Looper;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat;

import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationCallback;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationResult;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.location.Priority;

public class GPSLoggerService extends Service {

    private SQLiteActivity helper = null;
    private SQLiteDatabase db = null;
    private LocationCallback locationCallback;
    private FusedLocationProviderClient fusedLocationClient;
    int interval = 10000;
    private double lastLatitude = 0;
    private double currentLatitude, currentLongitude;
    float[] results = new float[3];
    private double lastLongitude = 0;
    public static boolean isAppInForeground = false;

    @Override
    public void onCreate() {
        super.onCreate();
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Context context = getApplicationContext();
        String channelId = "default";
        Resources res = getResources();
        String title = res.getString(R.string.app_name);

        Intent notifyIntent = new Intent(this, MainActivity.class);
        notifyIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
        NotificationManager notificationManager =
                (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);

        if (notificationManager != null) {
            if (Build.VERSION.SDK_INT >= 26) {
                PendingIntent pendingIntent = PendingIntent.getActivity(
                        this, 0, notifyIntent,
                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);

                // Notification Channel 設定
                NotificationChannel channel = new NotificationChannel(channelId, title, NotificationManager.IMPORTANCE_DEFAULT);
                channel.setDescription("Silent Notification");
                channel.setSound(null,null);
                channel.enableLights(false);
                channel.enableVibration(false);

                notificationManager.createNotificationChannel(channel);
                Notification notification = new Notification.Builder(context, channelId)
                        .setContentTitle("GPSロガー動作中")
                        .setSmallIcon(android.R.drawable.ic_menu_mylocation)
                        .setAutoCancel(true)
                        .setContentIntent(pendingIntent)
                        .setWhen(System.currentTimeMillis())
                        .build();

                // startForeground
                startForeground(1, notification);
                GPSLog();
            }
        }else{
            GPSLog();
        }

        isAppInForeground = true;
        return START_REDELIVER_INTENT;
    }

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

    public void GPSLog() {
        SharedPreferences prefs = getSharedPreferences("spinnerState", Activity.MODE_PRIVATE);
        int stateInt = prefs.getInt("INTERVAL", 3);

        if (stateInt == 0) {
            interval = 1000;
        } else if (stateInt == 1) {
            interval = 3000;
        } else if (stateInt == 2) {
            interval = 5000;
        } else if (stateInt == 3) {
            interval = 10000;
        } else if (stateInt == 4) {
            interval = 30000;
        } else if (stateInt == 5) {
            interval = 60000;
        } else if (stateInt == 6) {
            interval = 180000;
        }  else if (stateInt == 7) {
            interval = 300000;
        }

        LocationRequest.Builder locationRequest = new LocationRequest.Builder(interval);
        locationRequest.setPriority(Priority.PRIORITY_HIGH_ACCURACY);

        locationCallback = new LocationCallback() {
            @Override
            public void onLocationResult(@NonNull LocationResult locationResult) {
                for (Location location : locationResult.getLocations()) {
                    // GPSロガーの測定精度が 50 m 以内の場合のみ記録する
                    if ((location.getAccuracy() < 50) && (location.getAccuracy() > 0)) {
                        currentLatitude = location.getLatitude();
                        currentLongitude = location.getLongitude();
                        Location.distanceBetween(lastLatitude, lastLongitude, currentLatitude, currentLongitude, results);
                        Log.d("debug", currentLatitude+","+currentLongitude+","+location.getAccuracy()+","+results[0]);

                        // 移動速度が 0.1m/秒 以上 ( 0.36km/h 以上) の場合のみ記録する
                        if(results[0] > (0.1*interval/1000)) {
                            // DBに経緯度データを蓄積させていく
                            if (helper == null) {
                                helper = new SQLiteActivity(getApplicationContext());
                            }

                            if (db == null) {
                                db = helper.getWritableDatabase();
                            }

                            ContentValues values = new ContentValues();
                            values.put("GPSLogLati", currentLatitude);
                            values.put("GPSLogLong", currentLongitude);
                            values.put("GPSLogAlti", location.getAltitude());
                            db.insert("GPSLogDB", null, values);

                            lastLatitude = currentLatitude;
                            lastLongitude = currentLongitude;
                        }
                    }
                }
            }
        };
        fusedLocationClient = LocationServices.getFusedLocationProviderClient(this);

        if (ActivityCompat.checkSelfPermission(
                this, Manifest.permission.ACCESS_FINE_LOCATION)
                != PackageManager.PERMISSION_GRANTED) {
            return;
        }
        fusedLocationClient.requestLocationUpdates(locationRequest.build(), locationCallback, Looper.getMainLooper());
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        isAppInForeground = false;
        fusedLocationClient.removeLocationUpdates(locationCallback);
        stopSelf();
    }
}
package com.example.trekkingtrail;

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;


public class SQLiteActivity extends SQLiteOpenHelper {

    private static final int DATABASE_VERSION = 1;
    private static final String DATABASE_NAME = "GPSLogDB.db";
    private static final String TABLE_NAME = "GPSLogDB";
    private static final String _ID = "_id";
    private static final String GPSLog_LATITUDE = "GPSLogLati";
    private static final String GPSLog_LONGITUDE = "GPSLogLong";
    private static final String GPSLog_ALTITUDE = "GPSLogAlti";
    private static final String SQL_CREATE_ENTRIES =
            "CREATE TABLE " + TABLE_NAME + " (" +
                    _ID + " INTEGER PRIMARY KEY," +
                    GPSLog_LATITUDE  + " REAL," +
                    GPSLog_LONGITUDE + " REAL," +
                    GPSLog_ALTITUDE  + " REAL)";
    private static final String SQL_DELETE_ENTRIES =
            "DROP TABLE IF EXISTS " + TABLE_NAME;


    SQLiteActivity(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(SQL_CREATE_ENTRIES);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // アップデートの判別、古いバージョンは削除して新規作成
        if(db != null) {
            db.execSQL(SQL_DELETE_ENTRIES);
            onCreate(db);
        }
    }

    public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        onUpgrade(db, oldVersion, newVersion);
    }
}

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です