Commit 447f067d authored by Bartek Fabiszewski's avatar Bartek Fabiszewski
Browse files

Add track export to GPX

parent fc7c0a48
Loading
Loading
Loading
Loading
+13 −6
Original line number Diff line number Diff line
@@ -18,10 +18,13 @@
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <!-- For web sync retries -->
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <!-- For exporting tracks -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <!-- Needed if app targets API >= 21 -->
    <uses-feature android:name="android.hardware.location.gps" />
    <uses-feature android:name="android.hardware.location.network" />

    <!-- Todo: attach camera image to position -->
    <!-- <uses-feature android:name="android.hardware.camera" android:required="false" /> -->

@@ -32,24 +35,28 @@
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name="net.fabiszewski.ulogger.MainActivity">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity android:name="net.fabiszewski.ulogger.SettingsActivity" />
        <activity android:name=".SettingsActivity" />

        <service
            android:name="net.fabiszewski.ulogger.LoggerService"
            android:name=".LoggerService"
            android:enabled="true"
            android:exported="false" />
        <service
            android:name="net.fabiszewski.ulogger.WebSyncService"
            android:name=".WebSyncService"
            android:exported="false" />

        <service
            android:name=".GpxExportService"
            android:exported="false" />

        <receiver
            android:name="net.fabiszewski.ulogger.BootCompletedReceiver"
            android:name=".BootCompletedReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
+41 −0
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@ package net.fabiszewski.ulogger;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.location.Location;
import android.util.Log;
@@ -90,6 +91,18 @@ class DbAccess {
        db.insert(DbContract.Positions.TABLE_NAME, null, values);
    }

    /**
     * Get result set containing all positions.
     *
     * @return Result set
     */
    Cursor getPositions() {
        return db.query(DbContract.Positions.TABLE_NAME,
                new String[] {"*"},
                null, null, null, null,
                DbContract.Positions._ID);
    }

    /**
     * Get result set containing positions marked as not synchronized.
     *
@@ -157,6 +170,15 @@ class DbAccess {
                new String[] { String.valueOf(id) });
    }

    /**
     * Get number of all positions in track
     *
     * @return Count
     */
    int countPositions() {
        return (int) DatabaseUtils.queryNumEntries(db, DbContract.Positions.TABLE_NAME);
    }

    /**
     * Get number of not synchronized items.
     *
@@ -186,6 +208,25 @@ class DbAccess {
        return (countUnsynced() > 0);
    }

    /**
     * Get first saved location time.
     *
     * @return UTC timestamp in seconds
     */
    long getFirstTimestamp() {
        Cursor query = db.query(DbContract.Positions.TABLE_NAME,
                new String[] {DbContract.Positions.COLUMN_TIME},
                null, null, null, null,
                DbContract.Positions._ID + " ASC",
                "1");
        long timestamp = 0;
        if (query.moveToFirst()) {
            timestamp = query.getInt(0);
        }
        query.close();
        return timestamp;
    }

    /**
     * Get last saved location time.
     *
+267 −0
Original line number Diff line number Diff line
/*
 * Copyright (c) 2017 Bartek Fabiszewski
 * http://www.fabiszewski.net
 *
 * This file is part of μlogger-android.
 * Licensed under GPL, either version 3, or any later.
 * See <http://www.gnu.org/licenses/>
 */

package net.fabiszewski.ulogger;

import android.Manifest;
import android.app.IntentService;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.os.Environment;
import android.support.annotation.NonNull;
import android.support.v4.app.ActivityCompat;
import android.text.format.DateFormat;
import android.util.Log;
import android.util.Xml;

import org.xmlpull.v1.XmlSerializer;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.StringWriter;

/**
 * Export track to GPX format
 */
public class GpxExportService extends IntentService {

    private static final String TAG = GpxExportService.class.getSimpleName();

    public static final String BROADCAST_WRITE_PERMISSION_DENIED = "net.fabiszewski.ulogger.broadcast.write_permission_denied";
    public static final String BROADCAST_EXPORT_FAILED = "net.fabiszewski.ulogger.broadcast.write_failed";
    public static final String BROADCAST_EXPORT_DONE = "net.fabiszewski.ulogger.broadcast.write_ok";

    private static final String ns_gpx = "http://www.topografix.com/GPX/1/1";
    private static final String ns_xsi = "http://www.w3.org/2001/XMLSchema-instance";
    private static final String schemaLocation = ns_gpx + " http://www.topografix.com/GPX/1/1/gpx.xsd";

    private static final String ULOGGER_DIR = "ulogger_tracks";
    private static final String GPX_EXTENSION = ".gpx";

    public GpxExportService() {
        super("GpxExportService");
    }

    private DbAccess db;

    @Override
    public void onCreate() {
        super.onCreate();
        if (Logger.DEBUG) { Log.d(TAG, "[gpx export create]"); }

        db = DbAccess.getInstance();
        db.open(this);
    }

    /**
     * Cleanup
     */
    @Override
    public void onDestroy() {
        if (Logger.DEBUG) { Log.d(TAG, "[gpx export stop]"); }
        if (db != null) {
            db.close();
        }
        super.onDestroy();
    }

    /**
     * Handle intent
     *
     * @param intent Intent
     */
    @Override
    protected void onHandleIntent(Intent intent) {

        if (!hasWritePermission()) {
            // no permission to write
            if (Logger.DEBUG) { Log.d(TAG, "[export gpx no permission]"); }
            sendBroadcast(BROADCAST_WRITE_PERMISSION_DENIED, null);
            return;
        }
        if (!isExternalStorageWritable()) {
            // no access to external storage
            if (Logger.DEBUG) { Log.d(TAG, "[export gpx not writable]"); }
            sendBroadcast(BROADCAST_EXPORT_FAILED, getString(R.string.e_external_not_writable));
            return;
        }

        try {
            String trackName = db.getTrackName();
            if (trackName == null) {
                trackName = getString(R.string.unknown_track);
            }
            File dir = getDir();
            if (dir == null) {
                if (Logger.DEBUG) { Log.d(TAG, "[export gpx failed to create output folder]"); }
                sendBroadcast(BROADCAST_EXPORT_FAILED, getString(R.string.e_output_dir));
                return;
            }
            File file = getFile(dir, trackName);
            int i = 0;
            while (file.exists()) {
                file = getFile(dir, trackName + "_" + (++i));
            }

            FileOutputStream fileOutputStream = new FileOutputStream(file);

            XmlSerializer serializer = Xml.newSerializer();
            StringWriter writer = new StringWriter();

            serializer.setOutput(writer);

            // header
            serializer.startDocument("UTF-8", true);
            serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
            serializer.setPrefix("xsi", ns_xsi);
            serializer.startTag("", "gpx");
            serializer.attribute(null, "xmlns", ns_gpx);
            serializer.attribute(ns_xsi, "schemaLocation", schemaLocation);
            serializer.attribute(null, "version", "1.1");
            String creator = getString(R.string.app_name) + " " + BuildConfig.VERSION_NAME;
            serializer.attribute(null, "creator", creator);

            // metadata
            long trackTimestamp = db.getFirstTimestamp();
            String trackTime = DateFormat.format("yyyy-MM-ddThh:mm:ss", trackTimestamp * 1000).toString();
            serializer.startTag(null, "metadata");
                writeTag(serializer, "name", trackName);
                writeTag(serializer, "time", trackTime);
            serializer.endTag(null, "metadata");

            // track
            serializer.startTag(null, "trk");
                writeTag(serializer, "name", trackName);
                writePositions(serializer);
            serializer.endTag(null, "trk");

            serializer.endTag("", "gpx");
            serializer.endDocument();
            serializer.flush();
            String dataWrite = writer.toString();
            fileOutputStream.write(dataWrite.getBytes());
            fileOutputStream.close();
            if (Logger.DEBUG) { Log.d(TAG, "[export gpx file written to " + file.getPath()); }
            sendBroadcast(BROADCAST_EXPORT_DONE, null);
        } catch (IOException|IllegalArgumentException|IllegalStateException e) {
            if (Logger.DEBUG) { Log.d(TAG, "[export gpx exception: " + e + "]"); }
            sendBroadcast(BROADCAST_EXPORT_FAILED, e.getMessage());
        }

    }

    /**
     * Write <trkseg> tag
     *
     * @param serializer XmlSerializer
     * @throws IOException IO exception
     * @throws IllegalArgumentException Xml illegal argument
     * @throws IllegalStateException Xml illegal state
     */
    private void writePositions(@NonNull XmlSerializer serializer)
            throws IOException, IllegalArgumentException, IllegalStateException {

        Cursor cursor = db.getPositions();
        serializer.startTag(null, "trkseg");

        while (cursor.moveToNext()) {
            serializer.startTag(null, "trkpt");
            serializer.attribute(null, "lat", cursor.getString(cursor.getColumnIndex(DbContract.Positions.COLUMN_LATITUDE)));
            serializer.attribute(null, "lon", cursor.getString(cursor.getColumnIndex(DbContract.Positions.COLUMN_LONGITUDE)));
            if (!cursor.isNull(cursor.getColumnIndex(DbContract.Positions.COLUMN_ALTITUDE))) {
                writeTag(serializer, "ele", cursor.getString(cursor.getColumnIndex(DbContract.Positions.COLUMN_ALTITUDE)));
            }
            long timestamp = cursor.getLong(cursor.getColumnIndex(DbContract.Positions.COLUMN_TIME));
            String time = DateFormat.format("yyyy-MM-ddThh:mm:ss", timestamp * 1000).toString();
            writeTag(serializer, "time", time);
            writeTag(serializer, "name", cursor.getString(cursor.getColumnIndex(DbContract.Positions._ID)));
            serializer.endTag(null, "trkpt");
        }
        cursor.close();

        serializer.endTag(null, "trkseg");
    }

    /**
     * Write tag
     *
     * @param serializer XmlSerializer
     * @param name Tag name
     * @param text Tag text
     * @throws IOException IO exception
     * @throws IllegalArgumentException Xml illegal argument
     * @throws IllegalStateException Xml illegal state
     */
    private void writeTag(@NonNull XmlSerializer serializer, @NonNull String name, @NonNull String text)
            throws IOException, IllegalArgumentException, IllegalStateException {
        serializer.startTag(null, name);
        serializer.text(text);
        serializer.endTag(null, name);
    }

    /**
     * Has user granted write permission?
     *
     * @return True if permission granted, false otherwise
     */
    private boolean hasWritePermission() {
        return (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED);
    }

    /**
     * Is there external storage we can write to?
     *
     * @return True if writable, false otherwise
     */
    private boolean isExternalStorageWritable() {
        String state = Environment.getExternalStorageState();
        return Environment.MEDIA_MOUNTED.equals(state);
    }

    /**
     * Set up directory in Downloads folder
     *
     * @return File instance or null in case of failure
     */
    private File getDir() {
        File dir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), ULOGGER_DIR);
        if (!dir.exists() && !dir.mkdirs()) {
            dir = null;
        }
        return dir;
    }

    /**
     * Set up file instance with given name in given folder
     *
     * @param dir Folder
     * @param trackName File name
     * @return File instance
     */
    private File getFile(@NonNull File dir, @NonNull String trackName) {
        String fileName = trackName.replaceAll("[?:\"'*|/\\\\<>]", "_") + GPX_EXTENSION;
        return new File(dir, fileName);
    }

    /**
     * Send broadcast message
     * @param broadcast Broadcast intent
     * @param message Optional extra message
     */
    private void sendBroadcast(String broadcast, String message) {
        Intent intent = new Intent(broadcast);
        if (message != null) {
            intent.putExtra("message", message);
        }
        sendBroadcast(intent);
    }

}
+40 −4
Original line number Diff line number Diff line
@@ -64,6 +64,7 @@ public class MainActivity extends AppCompatActivity {
    private final static int LED_YELLOW = 3;

    private final static int PERMISSION_LOCATION = 1;
    private final static int PERMISSION_WRITE = 2;
    private final static int RESULT_PREFS_UPDATED = 1;

    private String pref_units;
@@ -182,6 +183,9 @@ public class MainActivity extends AppCompatActivity {
            case R.id.menu_about:
                showAbout();
                return true;
            case R.id.menu_export:
                startExport();
                return true;

            default:
                return super.onOptionsItemSelected(item);
@@ -199,16 +203,23 @@ public class MainActivity extends AppCompatActivity {
     */
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) {
        // onPause closed db
        db.open(this);
        switch (requestCode) {
            case PERMISSION_LOCATION:
                if ((grantResults.length > 0) && (grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
                    // onPause closed db
                    db.open(this);
                    startLogger();
                    db.close();
                }
                break;
            case PERMISSION_WRITE:
                if ((grantResults.length > 0) && (grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
                    startExport();
                }
                break;
        }
        db.close();
    }

    /**
@@ -279,6 +290,19 @@ public class MainActivity extends AppCompatActivity {
        stopService(intent);
    }

    /**
     * Start export service
     */
    private void startExport() {
        if (db.countPositions() > 0) {
            Intent exportIntent = new Intent(MainActivity.this, GpxExportService.class);
            startService(exportIntent);
            showToast(getString(R.string.export_started));
        } else {
            showToast(getString(R.string.nothing_to_export));
        }
    }

    /**
     * Called when the user clicks the New track button
     * @param view View
@@ -650,6 +674,9 @@ public class MainActivity extends AppCompatActivity {
        filter.addAction(LoggerService.BROADCAST_LOCATION_GPS_ENABLED);
        filter.addAction(LoggerService.BROADCAST_LOCATION_NETWORK_ENABLED);
        filter.addAction(LoggerService.BROADCAST_LOCATION_PERMISSION_DENIED);
        filter.addAction(GpxExportService.BROADCAST_WRITE_PERMISSION_DENIED);
        filter.addAction(GpxExportService.BROADCAST_EXPORT_FAILED);
        filter.addAction(GpxExportService.BROADCAST_EXPORT_DONE);
        filter.addAction(WebSyncService.BROADCAST_SYNC_DONE);
        filter.addAction(WebSyncService.BROADCAST_SYNC_FAILED);
        registerReceiver(mBroadcastReceiver, filter);
@@ -661,9 +688,7 @@ public class MainActivity extends AppCompatActivity {
    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            if (Logger.DEBUG) {
                Log.d(TAG, "[broadcast received " + intent + "]");
            }
            if (Logger.DEBUG) { Log.d(TAG, "[broadcast received " + intent + "]"); }
            if (intent.getAction().equals(LoggerService.BROADCAST_LOCATION_UPDATED)) {
                updateLocationLabel(LoggerService.lastUpdateRealtime());
                setLocLed(LED_GREEN);
@@ -715,10 +740,21 @@ public class MainActivity extends AppCompatActivity {
                showToast(getString(R.string.using_network), Toast.LENGTH_LONG);
            } else if (intent.getAction().equals(LoggerService.BROADCAST_LOCATION_GPS_ENABLED)) {
                showToast(getString(R.string.using_gps), Toast.LENGTH_LONG);
            } else if (intent.getAction().equals(GpxExportService.BROADCAST_EXPORT_DONE)) {
                showToast(getString(R.string.export_done), Toast.LENGTH_LONG);
            } else if (intent.getAction().equals(GpxExportService.BROADCAST_EXPORT_FAILED)) {
                String message = getString(R.string.export_failed);
                if (intent.hasExtra("message")) {
                    message += "\n" + intent.getStringExtra("message");
                }
                showToast(message, Toast.LENGTH_LONG);
            } else if (intent.getAction().equals(LoggerService.BROADCAST_LOCATION_PERMISSION_DENIED)) {
                showToast(getString(R.string.location_permission_denied), Toast.LENGTH_LONG);
                setLocLed(LED_RED);
                ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, PERMISSION_LOCATION);
            } else if (intent.getAction().equals(GpxExportService.BROADCAST_WRITE_PERMISSION_DENIED)) {
                showToast(getString(R.string.write_permission_denied), Toast.LENGTH_LONG);
                ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_WRITE);
            }
        }
    };
+21 −0
Original line number Diff line number Diff line
<!--
  ~ Copyright (c) 2017 Bartek Fabiszewski
  ~ http://www.fabiszewski.net
  ~
  ~ This file is part of μlogger-android.
  ~ Licensed under GPL, either version 3, or any later.
  ~ See <http://www.gnu.org/licenses/>

  ~ Material Design icon by Google.
  ~ Released under Apache License 2.0.
  -->

<vector xmlns:android="http://schemas.android.com/apk/res/android"
        android:width="24dp"
        android:height="24dp"
        android:viewportWidth="24.0"
        android:viewportHeight="24.0">
    <path
        android:fillColor="#FFFFFFFF"
        android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z"/>
</vector>
Loading