Kotlin Rewrite - everything is new

master
y20k 2020-01-02 18:00:37 +01:00
parent b09259594f
commit 99265afe58
No known key found for this signature in database
GPG Key ID: 824D4259F41FAFF6
155 changed files with 6115 additions and 6802 deletions

74
.gitignore vendored
View File

@ -1,8 +1,68 @@
# Built application files
*.apk
*.ap_
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
/projectFilesBackup
.idea/
#.idea/workspace.xml
#.idea/tasks.xml
#.idea/gradle.xml
#.idea/assetWizardSettings.xml
#.idea/dictionaries
#.idea/libraries
#.idea/caches
# Keystore files
*.jks
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# Google Services (e.g. APIs or Firebase)
google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Misc
.DS_Store

View File

@ -1,7 +1,7 @@
The MIT License (MIT)
=====================
Copyright (c) 2016-19 - Y20K.org
Copyright (c) 2016-20 - Y20K.org
--------------------------------
Permission is hereby granted, free of charge, to any person obtaining a copy

View File

@ -4,7 +4,9 @@ README
Trackbook - Movement Recorder for Android
-----------------------------------------
**Version 1.2.x ("San Tropez")**
**Version 2.0.x ("Echoes")**
**Please note: Trackbook is currently being completely re-written in Kotlin. No line of code is left unchanged. The process is not finished yet.**
Trackbook is a bare bones app for recording your movements. Trackbook is great for hiking, vacation or workout. Once started it traces your movements on a map. The map data is provided by [OpenStreetMap (OSM)](https://www.openstreetmap.org/).

View File

@ -1,48 +1,89 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'androidx.navigation.safeargs.kotlin'
android {
compileSdkVersion project.ext.compileSdkVersion
compileSdkVersion 29
// buildToolsVersion is optional because the plugin uses a recommended version by default
defaultConfig {
applicationId project.ext.applicationId
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
versionCode project.ext.versionCode
versionName project.ext.versionName
applicationId 'org.y20k.trackbook'
minSdkVersion 25
targetSdkVersion 27
versionCode 27
versionName '2.0.0'
resConfigs "en"
}
vectorDrawables.useSupportLibrary = true
resConfigs "en", "da", "de", "fr", "id", "it", "ja", "nb-rNO", "nl", "sv", "zh-rCN"
kotlinOptions {
jvmTarget = '1.8'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
lintOptions{
disable 'MissingTranslation'
}
buildTypes {
release {
// Enables code shrinking, obfuscation, and optimization for only
// your project's release build type.
minifyEnabled true
// Enables resource shrinking, which is performed by the
// Android Gradle plugin.
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Includes the default ProGuard rules files that are packaged with
// the Android Gradle plugin. To learn more, go to the section about
// R8 configuration files.
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
debug {
// Comment out the below lines if you do not need to test resource shrinking
//minifyEnabled true
//shrinkResources true
//proguardFiles getDefaultProguardFile(
// 'proguard-android-optimize.txt'),
// 'proguard-rules.pro'
}
lintOptions {
warning 'MissingTranslation'
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2"
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation "androidx.constraintlayout:constraintlayout:$constraintlayoutVersion"
implementation "androidx.cardview:cardview:$cardviewVersion"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation "androidx.core:core-ktx:1.1.0"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
implementation "androidx.preference:preference-ktx:1.1.0"
implementation "com.google.android.material:material:$materialVersion"
implementation 'androidx.navigation:navigation-fragment-ktx:2.1.0'
implementation 'androidx.navigation:navigation-ui-ktx:2.1.0'
implementation "org.osmdroid:osmdroid-android:$osmdroidVersion"
implementation "com.google.code.gson:gson:$gsonVersion"
implementation "com.google.android.material:material:1.1.0-beta01"
implementation "com.google.code.gson:gson:2.8.6"
implementation "org.osmdroid:osmdroid-android:6.1.5"
}
androidExtensions {
experimental = true
}

View File

@ -1,14 +1,10 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /Users/solaris/Library/Android/sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
@ -16,6 +12,10 @@
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# stop re-ordering of gson elements
-dontshrink
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.y20k.trackbook">
xmlns:tools="http://schemas.android.com/tools"
package="org.y20k.trackbook">
<!-- EXCLUDE NON-GPS DEVICES -->
<!-- USE GPS AND NETWORK - EXCLUDE NON-GPS DEVICES -->
<uses-feature android:name="android.hardware.location.gps" android:required="true" />
<uses-feature android:name="android.hardware.location.network" />
<!-- NORMAL PERMISSIONS, automatically granted -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -18,27 +19,19 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:name=".Trackbook"
android:allowBackup="true"
android:fullBackupContent="@xml/backupscheme"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/TrackbookAppTheme"
tools:ignore="GoogleAppIndexingWarning">
<!-- MAIN ACTIVITY -->
<activity
android:name=".MainActivity"
android:name=".Trackbook"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/TrackbookAppTheme"
android:resizeableActivity="true"
android:launchMode="singleTop">
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- TODO App is not indexable by Google Search; consider adding at least one Activity with an ACTION-VIEW intent filter. See issue explanation for more details. -->
</activity>
<!-- TRACKER SERVICE -->
@ -53,17 +46,20 @@
</intent-filter>
</service>
<!-- EXPORT HELPER (FILE PROVIDER) -->
<provider
android:name=".helpers.ExportHelper"
android:authorities="org.y20k.trackbook.exporthelper.provider"
android:exported="false"
android:grantUriPermissions="true">
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
</provider>
</application>
</manifest>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,134 @@
/*
* Keys.kt
* Implements the keys used throughout the app
* This object hosts all keys used to control Trackbook's state
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook
import java.util.*
/*
* Keys object
*/
object Keys {
// application name
const val APPLICATION_NAME: String = "Trackbook"
// version numbers
const val CURRENT_TRACK_FORMAT_VERSION: Int = 4
const val CURRENT_TRACKLIST_FORMAT_VERSION: Int = 0
// other values
const val MAXIMUM_TRACK_FILES: Int = 25
const val FIFTY_METER_RADIUS: Int = 50
const val UNIT_METRIC: Int = 1
const val UNIT_IMPERIAL: Int = -1
// intent actions
const val ACTION_START: String = "org.y20k.trackbooks.action.START"
const val ACTION_STOP: String = "org.y20k.trackbooks.action.STOP"
const val ACTION_RESUME: String = "org.y20k.transistors.action.RESUME"
// args
const val ARG_TRACK_TITLE: String = "ArgTrackTitle"
const val ARG_TRACK_FILE_URI: String = "ArgTrackFileUri"
const val ARG_GPX_FILE_URI: String = "ArgGpxFileUri"
const val ARG_TRACK_ID: String = "ArgTrackId"
// preferences
const val PREF_ONE_TIME_HOUSEKEEPING_NECESSARY = "ONE_TIME_HOUSEKEEPING_NECESSARY_VERSIONCODE_37" // increment to current app version code to trigger housekeeping that runs only once
const val PREF_NIGHT_MODE_STATE: String= "prefNightModeState"
const val PREF_CURRENT_BEST_LOCATION_PROVIDER: String = "prefCurrentBestLocationProvider"
const val PREF_CURRENT_BEST_LOCATION_LATITUDE: String = "prefCurrentBestLocationLatitude"
const val PREF_CURRENT_BEST_LOCATION_LONGITUDE: String = "prefCurrentBestLocationLongitude"
const val PREF_CURRENT_BEST_LOCATION_ACCURACY: String = "prefCurrentBestLocationAccuracy"
const val PREF_CURRENT_BEST_LOCATION_ALTITUDE: String = "prefCurrentBestLocationAltitude"
const val PREF_CURRENT_BEST_LOCATION_TIME: String = "prefCurrentBestLocationTime"
const val PREF_MAP_ZOOM_LEVEL: String = "prefMapZoomLevel"
const val PREF_TRACKING_STATE: String = "prefTrackingState"
const val PREF_USE_IMPERIAL_UNITS: String = "prefUseImperialUnits"
const val PREF_GPS_ONLY: String = "prefGpsOnly"
const val PREF_LOCATION_ACCURACY_THRESHOLD: String = "prefLocationAccuracyThreshold"
const val PREF_LOCATION_AGE_THRESHOLD: String = "prefLocationAgeThreshold"
// states
const val STATE_NOT_TRACKING: Int = 0
const val STATE_TRACKING_ACTIVE: Int = 1
const val STATE_TRACKING_STOPPED: Int = 2
// dialog types
const val DIALOG_EMPTY_RECORDING: Int = 0
const val DIALOG_REMOVE_TRACK: Int = 1
// dialog results
const val DIALOG_RESULT_DEFAULT: Int = -1
const val DIALOG_EMPTY_PAYLOAD_STRING: String = ""
const val DIALOG_EMPTY_PAYLOAD_INT: Int = -1
const val DIALOG_RESULT_SAVE_DIALOG: Int = 1
const val DIALOG_RESULT_CLEAR_DIALOG: Int = 2
const val DIALOG_RESULT_DELETE_DIALOG: Int = 3
const val DIALOG_RESULT_EXPORT_DIALOG: Int = 4
const val DIALOG_RESULT_EMPTY_RECORDING_DIALOG: Int = 5
// folder names
const val FOLDER_TEMP: String = "temp"
const val FOLDER_TRACKS: String = "tracks"
const val FOLDER_GPX: String = "gpx"
// file names and extensions
const val GPX_FILE_EXTENSION: String = ".gpx"
const val TRACKBOOK_LEGACY_FILE_EXTENSION: String = ".trackbook"
const val TRACKBOOK_FILE_EXTENSION: String = ".json"
const val TEMP_FILE: String = "temp.json"
const val TRACKLIST_FILE: String = "tracklist.json"
const val PODCAST_COVER_FILE: String = "cover.jpg"
const val PODCAST_SMALL_COVER_FILE: String = "cover-small.jpg"
const val DEBUG_LOG_FILE: String = "log-can-be-deleted.txt"
const val FILE_TYPE_TEMP: Int = 0
const val FILE_TYPE_TRACK: Int = 1
// default values
val DEFAULT_DATE: Date = Date(0L)
const val DEFAULT_RFC2822_DATE: String = "Thu, 01 Jan 1970 01:00:00 +0100" // --> Date(0)
const val ONE_HOUR_IN_MILLISECONDS: Int = 3600000
const val EMPTY_STRING_RESOURCE: Int = 0
const val REQUEST_CURRENT_LOCATION_INTERVAL: Long = 1000L // 1 second in milliseconds
const val ADD_WAYPOINT_TO_TRACK_INTERVAL: Long = 15000L // 15 seconds in milliseconds
const val SIGNIFICANT_TIME_DIFFERENCE: Long = 120000L // 2 minutes in milliseconds
const val STOP_OVER_THRESHOLD: Long = 300000L // 5 minutes in milliseconds
const val DEFAULT_LATITUDE: Double = 71.172500 // latitude Nordkapp, Norway
const val DEFAULT_LONGITUDE: Double = 25.784444 // longitude Nordkapp, Norway
const val DEFAULT_ACCURACY: Float = 300f // in meters
const val DEFAULT_ALTITUDE: Double = 0.0
const val DEFAULT_TIME: Long = 0L
const val DEFAULT_THRESHOLD_LOCATION_ACCURACY: Int = 30 // 30 meters
const val DEFAULT_THRESHOLD_LOCATION_AGE: Long = 60000000000L // one minute in nanoseconds
const val DEFAULT_THRESHOLD_DISTANCE: Float = 15f // 15 meters
const val DEFAULT_ZOOM_LEVEL: Double = 16.0
const val ALTITUDE_MEASUREMENT_ERROR_THRESHOLD = 10 // altitude changes of 10 meter or more (per 15 seconds) are being discarded
const val REQUEST_CODE_FOREGROUND = 42
// requests
// results
// notification
const val TRACKER_SERVICE_NOTIFICATION_ID: Int = 1
const val NOTIFICATION_CHANNEL_RECORDING: String = "notificationChannelIdRecordingChannel"
}

View File

@ -1,925 +0,0 @@
/**
* MainActivity.java
* Implements the app's main activity
* The main activity sets up the main view
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook;
import android.Manifest;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.location.Location;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.IBinder;
import android.os.Vibrator;
import android.preference.PreferenceManager;
import android.util.SparseArray;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.cardview.widget.CardView;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapter;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
import org.osmdroid.config.Configuration;
import org.y20k.trackbook.helpers.DialogHelper;
import org.y20k.trackbook.helpers.ExportHelper;
import org.y20k.trackbook.helpers.LogHelper;
import org.y20k.trackbook.helpers.NightModeHelper;
import org.y20k.trackbook.helpers.TrackbookKeys;
import org.y20k.trackbook.layout.NonSwipeableViewPager;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* MainActivity class
*/
public class MainActivity extends AppCompatActivity implements TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = MainActivity.class.getSimpleName();
/* Main class variables */
private TrackerService mTrackerService;
private BottomNavigationView mBottomNavigationView;
private NonSwipeableViewPager mViewPager;
private SectionsPagerAdapter mSectionsPagerAdapter;
private boolean mTrackerServiceRunning;
private boolean mPermissionsGranted;
private boolean mFloatingActionButtonSubMenuVisible;
private List<String> mMissingPermissions;
private FloatingActionButton mFloatingActionButtonMain;
private FloatingActionButton mFloatingActionButtonSubSave;
private FloatingActionButton mFloatingActionButtonSubClear;
private FloatingActionButton mFloatingActionButtonSubResume;
private FloatingActionButton mFloatingActionButtonLocation;
private CardView mFloatingActionButtonSubSaveLabel;
private CardView mFloatingActionButtonSubClearLabel;
private CardView mFloatingActionButtonSubResumeLabel;
private BroadcastReceiver mTrackingChangedReceiver;
private int mFloatingActionButtonState;
private int mSelectedTab;
private boolean mBound = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// check state of External Storage
checkExternalStorageState();
// empty cache
ExportHelper.emptyCacheDirectory(this);
// load saved state of app
loadFloatingActionButtonState(this);
// check permissions on Android 6 and higher
mPermissionsGranted = false;
if (Build.VERSION.SDK_INT >= 23) {
// check permissions
mMissingPermissions = checkPermissions();
mPermissionsGranted = mMissingPermissions.size() == 0;
} else {
mPermissionsGranted = true;
}
// initialize state
if (savedInstanceState != null) {
// restore if saved instance is available
mTrackerServiceRunning = savedInstanceState.getBoolean(INSTANCE_TRACKING_STATE, false);
mSelectedTab = savedInstanceState.getInt(INSTANCE_SELECTED_TAB, FRAGMENT_ID_MAP);
mFloatingActionButtonSubMenuVisible = savedInstanceState.getBoolean(INSTANCE_FAB_SUB_MENU_VISIBLE, false);
} else {
// use default values
mTrackerServiceRunning = false;
mSelectedTab = FRAGMENT_ID_MAP;
mFloatingActionButtonSubMenuVisible = false;
}
// set user agent to prevent getting banned from the osm servers
Configuration.getInstance().setUserAgentValue(BuildConfig.APPLICATION_ID);
// set the path for osmdroid's files (e.g. tile cache)
Configuration.getInstance().setOsmdroidBasePath(this.getExternalFilesDir(null));
// set up main layout
setupLayout();
}
@Override
protected void onStart() {
super.onStart();
// bind to TrackerService
Intent intent = new Intent(this, TrackerService.class);
bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
// register broadcast receiver for changed tracking state
mTrackingChangedReceiver = createTrackingChangedReceiver();
IntentFilter trackingStoppedIntentFilter = new IntentFilter(ACTION_TRACKING_STATE_CHANGED);
LocalBroadcastManager.getInstance(this).registerReceiver(mTrackingChangedReceiver, trackingStoppedIntentFilter);
}
@Override
protected void onResume() {
super.onResume();
// load state of Floating Action Button
loadFloatingActionButtonState(this);
// handle incoming intent (from notification)
handleIncomingIntent();
// if not in onboarding mode: set state of FloatingActionButton
if (mFloatingActionButtonMain != null) {
setFloatingActionButtonState();
}
}
@Override
protected void onPause() {
super.onPause();
}
@Override
protected void onStop() {
super.onStop();
// unbind from TrackerService
unbindService(mConnection);
}
@Override
public void onDestroy() {
super.onDestroy();
LogHelper.v(LOG_TAG, "onDestroy called.");
// reset selected tab
mSelectedTab = FRAGMENT_ID_MAP;
// disable broadcast receiver
LocalBroadcastManager.getInstance(this).unregisterReceiver(mTrackingChangedReceiver);
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
switch (requestCode) {
case REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS: {
Map<String, Integer> perms = new HashMap<>();
perms.put(Manifest.permission.ACCESS_FINE_LOCATION, PackageManager.PERMISSION_GRANTED);
perms.put(Manifest.permission.WRITE_EXTERNAL_STORAGE, PackageManager.PERMISSION_GRANTED);
for (int i = 0; i < permissions.length; i++)
perms.put(permissions[i], grantResults[i]);
// check for ACCESS_FINE_LOCATION and WRITE_EXTERNAL_STORAGE
Boolean location = perms.get(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED;
// Boolean storage = perms.get(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED;
if (location) {
// permissions granted - notify user
Toast.makeText(this, R.string.toast_message_permissions_granted, Toast.LENGTH_SHORT).show();
mPermissionsGranted = true;
// switch to main map layout
setupLayout();
} else {
// permissions denied - notify user
Toast.makeText(this, R.string.toast_message_unable_to_start_app, Toast.LENGTH_SHORT).show();
mPermissionsGranted = false;
}
}
break;
default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
outState.putBoolean(INSTANCE_TRACKING_STATE, mTrackerServiceRunning);
outState.putInt(INSTANCE_SELECTED_TAB, mSelectedTab);
outState.putBoolean(INSTANCE_FAB_SUB_MENU_VISIBLE, mFloatingActionButtonSubMenuVisible);
super.onSaveInstanceState(outState);
}
/* Handles FloatingActionButton dialog results - called by MainActivityMapFragment after Saving and/or clearing the map */
public void onFloatingActionButtonResult(int requestCode, int resultCode) {
switch(requestCode) {
case RESULT_SAVE_DIALOG:
if (resultCode == Activity.RESULT_OK) {
// user chose SAVE
handleStateAfterSave();
LogHelper.v(LOG_TAG, "Save dialog result: SAVE");
} else if (resultCode == Activity.RESULT_CANCELED){
LogHelper.v(LOG_TAG, "Save dialog result: CANCEL");
}
break;
case RESULT_CLEAR_DIALOG:
if (resultCode == Activity.RESULT_OK) {
// user chose CLEAR
handleStateAfterClear();
LogHelper.v(LOG_TAG, "Clear map dialog result: CLEAR");
} else if (resultCode == Activity.RESULT_CANCELED){
LogHelper.v(LOG_TAG, "Clear map dialog result: User chose CANCEL.");
}
break;
case RESULT_EMPTY_RECORDING_DIALOG:
if (resultCode == Activity.RESULT_OK) {
// User chose RESUME RECORDING
handleResumeButtonClick((View)mFloatingActionButtonMain);
LogHelper.v(LOG_TAG, "Empty recording dialog result: RESUME");
} else if (resultCode == Activity.RESULT_CANCELED){
// User chose CANCEL - do nothing just hide the sub menu
showFloatingActionButtonMenu(false);
LogHelper.v(LOG_TAG, "Empty recording dialog result: CANCEL");
}
break;
}
}
/* Handles the visual state after a save action */
private void handleStateAfterSave() {
// display and update tracks tab
mBottomNavigationView.setSelectedItemId(R.id.navigation_last_tracks);
// dismiss notification
dismissNotification();
// hide Floating Action Button sub menu
showFloatingActionButtonMenu(false);
// update Floating Action Button icon
mFloatingActionButtonState = FAB_STATE_DEFAULT;
setFloatingActionButtonState();
}
/* Start tracker service */
private void startTrackerService() {
// start service so that it keeps running after unbind
Intent intent = new Intent(this, TrackerService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// ... start service in foreground to prevent it being killed on Oreo
startForegroundService(intent);
} else {
startService(intent);
}
}
/* Start recording movements */
private void startRecording(Location lastLocation) {
startTrackerService();
if (mBound) {
mTrackerService.startTracking(lastLocation);
}
}
/* Resume recording movements */
private void resumeRecording(Location lastLocation) {
startTrackerService();
if (mBound) {
mTrackerService.resumeTracking(lastLocation);
}
}
/* Stop recording movements */
private void stopRecording() {
if (mBound) {
mTrackerService.stopTracking();
}
}
/* Dismiss notification */
private void dismissNotification() {
if (mBound) {
mTrackerService.dismissNotification();
}
}
/* Handles the visual state after a save action */
private void handleStateAfterClear() {
// dismiss notification
dismissNotification();
// hide Floating Action Button sub menu
showFloatingActionButtonMenu(false);
// update Floating Action Button icon
mFloatingActionButtonState = FAB_STATE_DEFAULT;
setFloatingActionButtonState();
}
/* Handles tap on the button "save" */
private void handleSaveButtonClick() {
// save button click is handled by onActivityResult in MainActivityMapFragment
MainActivityMapFragment mainActivityMapFragment = (MainActivityMapFragment) mSectionsPagerAdapter.getFragment(FRAGMENT_ID_MAP);
mainActivityMapFragment.onActivityResult(RESULT_SAVE_DIALOG, Activity.RESULT_OK, getIntent());
}
/* Handles tap on the button "clear" */
private void handleClearButtonClick() {
// prepare delete dialog
int dialogTitle = -1;
String dialogMessage = getString(R.string.dialog_clear_content);
int dialogPositiveButton = R.string.dialog_clear_action_clear;
int dialogNegativeButton = R.string.dialog_default_action_cancel;
// show delete dialog
MainActivityMapFragment mainActivityMapFragment = (MainActivityMapFragment) mSectionsPagerAdapter.getFragment(FRAGMENT_ID_MAP);
DialogFragment dialogFragment = DialogHelper.newInstance(dialogTitle, dialogMessage, dialogPositiveButton, dialogNegativeButton);
dialogFragment.setTargetFragment(mainActivityMapFragment, RESULT_CLEAR_DIALOG);
dialogFragment.show(getSupportFragmentManager(), "ClearDialog");
// results of dialog are handled by onActivityResult in MainActivityMapFragment
}
/* Handles tap on the button "resume" */
private void handleResumeButtonClick(View view) {
// get last location from MainActivity Fragment // todo check -> may produce NullPointerException
MainActivityMapFragment mainActivityMapFragment = (MainActivityMapFragment) mSectionsPagerAdapter.getFragment(FRAGMENT_ID_MAP);
Location lastLocation = mainActivityMapFragment.getCurrentBestLocation();
if (lastLocation != null) {
// show snackbar
Snackbar.make(view, R.string.snackbar_message_tracking_resumed, Snackbar.LENGTH_SHORT).setAction("Action", null).show();
// resume tracking
resumeRecording(lastLocation);
// hide sub menu
showFloatingActionButtonMenu(false);
} else {
Toast.makeText(this, getString(R.string.toast_message_location_services_not_ready), Toast.LENGTH_LONG).show();
}
}
/* Loads state of Floating Action Button from preferences */
private void loadFloatingActionButtonState(Context context) {
SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
mFloatingActionButtonState = settings.getInt(PREFS_FAB_STATE, FAB_STATE_DEFAULT);
}
/* Set up main layout */
private void setupLayout() {
if (mPermissionsGranted) {
// point to the main map layout
setContentView(R.layout.activity_main);
// create adapter that returns fragments for the maim map and the last track display
mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager());
// set up the ViewPager with the sections adapter.
mViewPager = (NonSwipeableViewPager) findViewById(R.id.fragmentContainer);
mViewPager.setAdapter(mSectionsPagerAdapter);
// setup bottom navigation
mBottomNavigationView = findViewById(R.id.navigation);
mBottomNavigationView.setOnNavigationItemSelectedListener(getOnNavigationItemSelectedListener());
// get references to the record button and show/hide its sub menu
mFloatingActionButtonMain = findViewById(R.id.fabMainButton);
mFloatingActionButtonLocation = findViewById(R.id.fabLocationButton);
mFloatingActionButtonSubSave = findViewById(R.id.fabSubMenuButtonSave);
mFloatingActionButtonSubSaveLabel = findViewById(R.id.fabSubMenuLabelSave);
mFloatingActionButtonSubClear = findViewById(R.id.fabSubMenuButtonClear);
mFloatingActionButtonSubClearLabel = findViewById(R.id.fabSubMenuLabelClear);
mFloatingActionButtonSubResume = findViewById(R.id.fabSubMenuButtonResume);
mFloatingActionButtonSubResumeLabel = findViewById(R.id.fabSubMenuLabelResume);
if (mFloatingActionButtonSubMenuVisible) {
showFloatingActionButtonMenu(true);
} else {
showFloatingActionButtonMenu(false);
}
// restore selected tab
if (mSelectedTab == FRAGMENT_ID_TRACKS) {
mBottomNavigationView.setSelectedItemId(R.id.navigation_last_tracks);
} else {
mBottomNavigationView.setSelectedItemId(R.id.navigation_map);
}
// add listeners to buttons
addListenersToViews();
} else {
// point to the on main onboarding layout
setContentView(R.layout.main_onboarding);
// show the okay button and attach listener
Button okayButton = (Button) findViewById(R.id.button_okay);
okayButton.setOnClickListener(new View.OnClickListener() {
@TargetApi(Build.VERSION_CODES.M)
@Override
public void onClick(View view) {
if (mMissingPermissions != null && !mMissingPermissions.isEmpty()) {
// request permissions
String[] params = mMissingPermissions.toArray(new String[mMissingPermissions.size()]);
requestPermissions(params, REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS);
}
}
});
}
}
/* Add listeners to ui buttons */
private void addListenersToViews() {
mFloatingActionButtonMain.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
handleFloatingActionButtonClick(view);
}
});
mFloatingActionButtonSubSave.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
handleSaveButtonClick();
}
});
mFloatingActionButtonSubSaveLabel.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
handleSaveButtonClick();
}
});
mFloatingActionButtonSubClear.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
handleClearButtonClick();
}
});
mFloatingActionButtonSubClearLabel.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
handleClearButtonClick();
}
});
mFloatingActionButtonSubResume.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
handleResumeButtonClick(view);
}
});
mFloatingActionButtonSubResumeLabel.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
handleResumeButtonClick(view);
}
});
mFloatingActionButtonLocation.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
MainActivityMapFragment mainActivityMapFragment = (MainActivityMapFragment) mSectionsPagerAdapter.getFragment(FRAGMENT_ID_MAP);
mainActivityMapFragment.handleShowMyLocation();
}
});
// secret night mode switch
mFloatingActionButtonLocation.setOnLongClickListener(new View.OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
NightModeHelper.switchMode(MainActivity.this);
// vibrate 50 milliseconds
Vibrator vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
vibrator.vibrate(50);
// recreate activity
recreate();
return true;
}
});
}
/* Handles tap on the record button */
private void handleFloatingActionButtonClick(View view) {
switch (mFloatingActionButtonState) {
case FAB_STATE_DEFAULT:
// get last location from MainActivity Fragment // todo check -> may produce NullPointerException
MainActivityMapFragment mainActivityMapFragment = (MainActivityMapFragment) mSectionsPagerAdapter.getFragment(FRAGMENT_ID_MAP);
Location lastLocation = mainActivityMapFragment.getCurrentBestLocation();
if (lastLocation != null) {
// show snackbar
Snackbar.make(view, R.string.snackbar_message_tracking_started, Snackbar.LENGTH_SHORT).setAction("Action", null).show();
// start recording
startRecording(lastLocation);
} else {
Toast.makeText(this, getString(R.string.toast_message_location_services_not_ready), Toast.LENGTH_LONG).show();
}
break;
case FAB_STATE_RECORDING:
// show snackbar
Snackbar.make(view, R.string.snackbar_message_tracking_stopped, Snackbar.LENGTH_SHORT).setAction("Action", null).show();
// stop tracker service
stopRecording();
break;
case FAB_STATE_SAVE:
// toggle visibility floating action button sub menu
showFloatingActionButtonMenu(!mFloatingActionButtonSubMenuVisible);
break;
}
// update tracking state in MainActivityMapFragment // todo check -> may produce NullPointerException
MainActivityMapFragment mainActivityMapFragment = (MainActivityMapFragment) mSectionsPagerAdapter.getFragment(FRAGMENT_ID_MAP);
mainActivityMapFragment.setTrackingState(mTrackerServiceRunning);
}
/* Set state of FloatingActionButton */
private void setFloatingActionButtonState() {
switch (mFloatingActionButtonState) {
case FAB_STATE_DEFAULT:
mFloatingActionButtonMain.hide(); // workaround todo remove asap
mFloatingActionButtonMain.setImageResource(R.drawable.ic_fiber_manual_record_white_24dp);
mFloatingActionButtonMain.setContentDescription(getString(R.string.descr_fab_main_start));
if (mSelectedTab == FRAGMENT_ID_MAP) mFloatingActionButtonMain.show(); // workaround todo remove asap
break;
case FAB_STATE_RECORDING:
mFloatingActionButtonMain.hide(); // workaround todo remove asap
mFloatingActionButtonMain.setImageResource(R.drawable.ic_fiber_manual_record_red_24dp);
mFloatingActionButtonMain.setContentDescription(getString(R.string.descr_fab_main_stop));
if (mSelectedTab == FRAGMENT_ID_MAP) mFloatingActionButtonMain.show(); // workaround todo remove asap
break;
case FAB_STATE_SAVE:
mFloatingActionButtonMain.hide(); // workaround todo remove asap
mFloatingActionButtonMain.setImageResource(R.drawable.ic_save_white_24dp);
mFloatingActionButtonMain.setContentDescription(getString(R.string.descr_fab_main_options));
if (mSelectedTab == FRAGMENT_ID_MAP) mFloatingActionButtonMain.show(); // workaround todo remove asap
break;
default:
mFloatingActionButtonMain.hide(); // workaround todo remove asap
mFloatingActionButtonMain.setImageResource(R.drawable.ic_fiber_manual_record_white_24dp);
mFloatingActionButtonMain.setContentDescription(getString(R.string.descr_fab_main_start));
if (mSelectedTab == FRAGMENT_ID_MAP) mFloatingActionButtonMain.show(); // workaround todo remove asap
break;
}
}
/* Shows (and hides) the sub menu of the floating action button */
private void showFloatingActionButtonMenu(boolean visible) {
if (visible) {
mFloatingActionButtonSubResume.show();
mFloatingActionButtonSubResumeLabel.setVisibility(View.VISIBLE);
mFloatingActionButtonSubClear.show();
mFloatingActionButtonSubClearLabel.setVisibility(View.VISIBLE);
mFloatingActionButtonSubSave.show();
mFloatingActionButtonSubSaveLabel.setVisibility(View.VISIBLE);
mFloatingActionButtonSubMenuVisible = true;
} else {
mFloatingActionButtonSubResume.hide();
mFloatingActionButtonSubResumeLabel.setVisibility(View.INVISIBLE);
mFloatingActionButtonSubClear.hide();
mFloatingActionButtonSubClearLabel.setVisibility(View.INVISIBLE);
mFloatingActionButtonSubSave.hide();
mFloatingActionButtonSubSaveLabel.setVisibility(View.INVISIBLE);
mFloatingActionButtonSubMenuVisible = false;
}
}
/* Handles taps on the bottom navigation */
private BottomNavigationView.OnNavigationItemSelectedListener getOnNavigationItemSelectedListener() {
return new BottomNavigationView.OnNavigationItemSelectedListener() {
@Override
public boolean onNavigationItemSelected(@NonNull MenuItem item) {
switch (item.getItemId()) {
case R.id.navigation_map:
// show the Floating Action Button
mFloatingActionButtonMain.show();
// show the my location button
mFloatingActionButtonLocation.show();
// show map fragment
mSelectedTab = FRAGMENT_ID_MAP;
mViewPager.setCurrentItem(mSelectedTab);
return true;
case R.id.navigation_last_tracks:
// hide the Floating Action Button - and its sub menu
mFloatingActionButtonMain.hide();
showFloatingActionButtonMenu(false);
// hide the my location button
mFloatingActionButtonLocation.hide();
// show tracks fragment
mSelectedTab = FRAGMENT_ID_TRACKS;
mViewPager.setCurrentItem(mSelectedTab);
return true;
default:
// show the Floating Action Button
mFloatingActionButtonMain.show();
return false;
}
}
};
}
/* Handles new incoming intents */
private void handleIncomingIntent() {
Intent intent = getIntent();
LogHelper.v(LOG_TAG, "Main Activity received intent. Content: " + intent.toString());
String intentAction = intent.getAction();
switch (intentAction) {
case ACTION_SHOW_MAP:
// show map fragment
mBottomNavigationView.setSelectedItemId(R.id.navigation_map);
// clear intent
intent.setAction(ACTION_DEFAULT);
break;
case ACTION_CLEAR:
// show map fragment
mBottomNavigationView.setSelectedItemId(R.id.navigation_map);
// show clear dialog
handleClearButtonClick();
// clear intent
intent.setAction(ACTION_DEFAULT);
break;
default:
break;
}
}
/* Inform user and give haptic feedback (vibration) */
private void longPressFeedback(int stringResource) {
// inform user
Toast.makeText(this, stringResource, Toast.LENGTH_LONG).show();
// vibrate 50 milliseconds
Vibrator v = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
if (v != null) {
v.vibrate(50);
// v.vibrate(VibrationEffect.createOneShot(50, DEFAULT_AMPLITUDE)); // todo check if there is a support library vibrator
}
}
/* Check which permissions have been granted */
private List<String> checkPermissions() {
List<String> permissions = new ArrayList<>();
// check for location permission
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
// add missing permission
permissions.add(Manifest.permission.ACCESS_FINE_LOCATION);
}
// // check for storage permission
// if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
// // add missing permission
// permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
// }
return permissions;
}
/* Creates receiver for stopped tracking */
private BroadcastReceiver createTrackingChangedReceiver() {
return new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
// change state
mTrackerServiceRunning = intent.getBooleanExtra(EXTRA_TRACKING_STATE, false);
if (mTrackerServiceRunning) {
mFloatingActionButtonState = FAB_STATE_RECORDING;
} else {
mFloatingActionButtonState = FAB_STATE_SAVE;
}
setFloatingActionButtonState();
// pass tracking state to MainActivityMapFragment // todo check -> may produce NullPointerException
MainActivityMapFragment mainActivityMapFragment = (MainActivityMapFragment) mSectionsPagerAdapter.getFragment(FRAGMENT_ID_MAP);
mainActivityMapFragment.setTrackingState(mTrackerServiceRunning);
}
};
}
/* Checks the state of External Storage */
private void checkExternalStorageState() {
String state = Environment.getExternalStorageState();
if (!state.equals(Environment.MEDIA_MOUNTED)) {
LogHelper.e(LOG_TAG, "Error: Unable to mount External Storage. Current state: " + state);
// move MainActivity to back
moveTaskToBack(true);
// shutting down app
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(1);
}
}
// public class SectionsPagerAdapter extends FragmentPagerAdapter {
//
// public SectionsPagerAdapter(FragmentManager fm) {
// super(fm);
// }
//
// @Override
// public Fragment getItem(int position) {
// // getItem is called to instantiate the fragment for the given page.
// switch (position) {
// case FRAGMENT_ID_MAP:
// return new MainActivityMapFragment();
// case FRAGMENT_ID_TRACKS:
// return new MainActivityTrackFragment();
// }
// return null;
// }
//
// @Override
// public int getCount() {
// return 2;
// }
//
// public Fragment getFragment(int pos) {
// return getItem(pos);
// }
//
// }
/**
* Defines callbacks for service binding, passed to bindService()
*/
private ServiceConnection mConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName className, IBinder service) {
// We've bound to LocalService, cast the IBinder and get LocalService instance
TrackerService.LocalBinder binder = (TrackerService.LocalBinder) service;
mTrackerService = binder.getService();
mBound = true;
}
@Override
public void onServiceDisconnected(ComponentName arg0) {
mBound = false;
}
};
/**
* Inner class: SectionsPagerAdapter that returns a fragment corresponding to one of the tabs.
* see also: https://developer.android.com/reference/android/support/v4/app/FragmentPagerAdapter.html
* and: http://www.truiton.com/2015/12/android-activity-fragment-communication/
*/
public class SectionsPagerAdapter extends FragmentStatePagerAdapter {
private final SparseArray<WeakReference<Fragment>> instantiatedFragments = new SparseArray<>();
public SectionsPagerAdapter(FragmentManager fm) {
super(fm);
}
@Override
public Fragment getItem(int position) {
// getItem is called to instantiate the fragment for the given page.
switch (position) {
case FRAGMENT_ID_MAP:
return new MainActivityMapFragment();
case FRAGMENT_ID_TRACKS:
return new MainActivityTrackFragment();
}
return null;
}
@Override
public int getCount() {
// show 2 total pages.
return 2;
}
@Override
public CharSequence getPageTitle(int position) {
switch (position) {
case FRAGMENT_ID_MAP:
return getString(R.string.tab_map);
case FRAGMENT_ID_TRACKS:
return getString(R.string.tab_last_tracks);
}
return null;
}
@NonNull
@Override
public Object instantiateItem(final ViewGroup container, final int position) {
final Fragment fragment = (Fragment) super.instantiateItem(container, position);
instantiatedFragments.put(position, new WeakReference<>(fragment));
return fragment;
}
@Override
public void destroyItem(final ViewGroup container, final int position, final Object object) {
instantiatedFragments.remove(position);
super.destroyItem(container, position, object);
}
@Nullable
public Fragment getFragment(final int position) {
final WeakReference<Fragment> wr = instantiatedFragments.get(position);
if (wr != null) {
return wr.get();
} else {
return null;
}
}
}
/**
* End of inner class
*/
}

View File

@ -0,0 +1,85 @@
/*
* MainActivity.kt
* Implements the main activity of the app
* The MainActivity hosts fragments for: current map, track list, settings
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.bottomnavigation.BottomNavigationView
import org.osmdroid.config.Configuration
import org.y20k.trackbook.helpers.ImportHelper
import org.y20k.trackbook.helpers.LogHelper
import org.y20k.trackbook.helpers.PreferencesHelper
/*
* MainActivity class
*/
class MainActivity : AppCompatActivity() {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(MainActivity::class.java)
/* Main class variables */
private lateinit var navHostFragment: NavHostFragment
private lateinit var bottomNavigationView: BottomNavigationView
/* Overrides onCreate from AppCompatActivity */
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// set user agent to prevent getting banned from the osm servers
Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID
// set the path for osmdroid's files (e.g. tile cache)
Configuration.getInstance().osmdroidBasePath = this.getExternalFilesDir(null)
// set up views
setContentView(R.layout.activity_main)
navHostFragment = supportFragmentManager.findFragmentById(R.id.main_container) as NavHostFragment
bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottom_navigation_view)
bottomNavigationView.setupWithNavController(navController = navHostFragment.navController)
// listen for navigation changes
navHostFragment.navController.addOnDestinationChangedListener { _, destination, _ ->
when (destination.id) {
R.id.fragment_track -> {
runOnUiThread( Runnable() {
run(){
// mark menu item "Tracks" as checked
bottomNavigationView.menu.findItem(R.id.tracklist_fragment).setChecked(true)
}
})
}
else -> {
// do nothing
}
}
}
// convert old tracks (one-time import)
if (PreferencesHelper.isHouseKeepingNecessary(this)) {
ImportHelper.convertOldTracks(this)
PreferencesHelper.saveHouseKeepingNecessaryState(this)
}
}
}

View File

@ -1,741 +0,0 @@
/**
* MainActivityMapFragment.java
* Implements the map fragment used in the map tab of the main activity
* This fragment displays a map using osmdroid
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.database.ContentObserver;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.SystemClock;
import android.preference.PreferenceManager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.google.android.material.snackbar.Snackbar;
import org.osmdroid.api.IMapController;
import org.osmdroid.tileprovider.tilesource.TileSourceFactory;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.MapView;
import org.osmdroid.views.overlay.ItemizedIconOverlay;
import org.osmdroid.views.overlay.TilesOverlay;
import org.osmdroid.views.overlay.compass.CompassOverlay;
import org.osmdroid.views.overlay.compass.InternalCompassOrientationProvider;
import org.y20k.trackbook.core.Track;
import org.y20k.trackbook.helpers.DialogHelper;
import org.y20k.trackbook.helpers.LocationHelper;
import org.y20k.trackbook.helpers.LogHelper;
import org.y20k.trackbook.helpers.MapHelper;
import org.y20k.trackbook.helpers.NightModeHelper;
import org.y20k.trackbook.helpers.StorageHelper;
import org.y20k.trackbook.helpers.TrackbookKeys;
import java.util.List;
/**
* MainActivityMapFragment class
*/
public class MainActivityMapFragment extends Fragment implements TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = MainActivityMapFragment.class.getSimpleName();
/* Main class variables */
private Activity mActivity;
private Track mTrack;
private boolean mFirstStart;
private Snackbar mLocationOffBar;
private BroadcastReceiver mTrackUpdatedReceiver;
private SettingsContentObserver mSettingsContentObserver;
private MapView mMapView;
private IMapController mController;
private StorageHelper mStorageHelper;
private LocationManager mLocationManager;
private LocationListener mGPSListener;
private LocationListener mNetworkListener;
private ItemizedIconOverlay mMyLocationOverlay;
private ItemizedIconOverlay mTrackOverlay;
private Location mCurrentBestLocation;
private boolean mTrackerServiceRunning;
private boolean mLocalTrackerRunning;
private boolean mLocationSystemSetting;
private boolean mFragmentVisible;
/* Constructor (default) */
public MainActivityMapFragment() {
}
/* Return a new Instance of MainActivityMapFragment */
public static MainActivityMapFragment newInstance() {
return new MainActivityMapFragment();
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// get activity
mActivity = getActivity();
// restore first start state and tracking state
mFirstStart = true;
mTrackerServiceRunning = false;
loadTrackerServiceState(mActivity);
if (savedInstanceState != null) {
mFirstStart = savedInstanceState.getBoolean(INSTANCE_FIRST_START, true);
}
// create storage helper
mStorageHelper = new StorageHelper(mActivity);
// acquire reference to Location Manager
mLocationManager = (LocationManager) mActivity.getSystemService(Context.LOCATION_SERVICE);
// CASE 1: get saved location if possible
if (savedInstanceState != null) {
Location savedLocation = savedInstanceState.getParcelable(INSTANCE_CURRENT_LOCATION);
// check if saved location is still current
if (LocationHelper.isCurrent(savedLocation)) {
mCurrentBestLocation = savedLocation;
} else {
mCurrentBestLocation = null;
}
}
// CASE 2: get last known location if no saved location or saved location is too old
if (mCurrentBestLocation == null && mLocationManager.getProviders(true).size() > 0) {
mCurrentBestLocation = LocationHelper.determineLastKnownLocation(mLocationManager);
}
// CASE 3: location services are available but unable to get location - this should not happen
if (mCurrentBestLocation == null) {
mCurrentBestLocation = new Location(LocationManager.NETWORK_PROVIDER);
mCurrentBestLocation.setLatitude(DEFAULT_LATITUDE);
mCurrentBestLocation.setLongitude(DEFAULT_LONGITUDE);
}
// get state of location system setting
mLocationSystemSetting = LocationHelper.checkLocationSystemSetting(mActivity);
// create content observer for changes in System Settings
mSettingsContentObserver = new SettingsContentObserver( new Handler());
// register broadcast receiver for new WayPoints
mTrackUpdatedReceiver = createTrackUpdatedReceiver();
IntentFilter trackUpdatedIntentFilter = new IntentFilter(ACTION_TRACK_UPDATED);
LocalBroadcastManager.getInstance(mActivity).registerReceiver(mTrackUpdatedReceiver, trackUpdatedIntentFilter);
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
// create basic map
mMapView = new MapView(inflater.getContext());
// get map controller
mController = mMapView.getController();
// basic map setup
mMapView.setTileSource(TileSourceFactory.MAPNIK);
mMapView.setTilesScaledToDpi(true);
// set dark map tiles, if necessary
if (NightModeHelper.getNightMode(mActivity)) {
mMapView.getOverlayManager().getTilesOverlay().setColorFilter(TilesOverlay.INVERT_COLORS);
}
// add multi-touch capability
mMapView.setMultiTouchControls(true);
// disable default zoom controls
mMapView.getZoomController().setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER);
// add compass to map
CompassOverlay compassOverlay = new CompassOverlay(mActivity, new InternalCompassOrientationProvider(mActivity), mMapView);
compassOverlay.enableCompass();
mMapView.getOverlays().add(compassOverlay);
// initiate map state
if (savedInstanceState != null) {
// restore saved instance of map
GeoPoint position = new GeoPoint(savedInstanceState.getDouble(INSTANCE_LATITUDE_MAIN_MAP, DEFAULT_LATITUDE), savedInstanceState.getDouble(INSTANCE_LONGITUDE_MAIN_MAP, DEFAULT_LONGITUDE));
mController.setCenter(position);
mController.setZoom(savedInstanceState.getDouble(INSTANCE_ZOOM_LEVEL_MAIN_MAP, 16f));
// restore current location
mCurrentBestLocation = savedInstanceState.getParcelable(INSTANCE_CURRENT_LOCATION);
} else if (mCurrentBestLocation != null) {
// fallback or first run: set map to current position
GeoPoint position = convertToGeoPoint(mCurrentBestLocation);
mController.setCenter(position);
mController.setZoom(16f);
}
// inform user that new/better location is on its way
if (mFirstStart && !mTrackerServiceRunning) {
Toast.makeText(mActivity, mActivity.getString(R.string.toast_message_acquiring_location), Toast.LENGTH_LONG).show();
mFirstStart = false;
}
// // load track from saved instance
// if (savedInstanceState != null) {
// mTrack = savedInstanceState.getParcelable(INSTANCE_TRACK_MAIN_MAP);
// }
// mark user's location on map
if (mCurrentBestLocation != null && !mTrackerServiceRunning) {
mMyLocationOverlay = MapHelper.createMyLocationOverlay(mActivity, mCurrentBestLocation, LocationHelper.isCurrent(mCurrentBestLocation), false);
mMapView.getOverlays().add(mMyLocationOverlay);
}
return mMapView;
}
@Override
public void onResume() {
super.onResume();
// set visibility
mFragmentVisible = true;
// load state of tracker service - see if anything changed
loadTrackerServiceState(mActivity);
// load track from temp file if it exists
if (mStorageHelper.tempFileExists()) {
LoadTempTrackAsyncHelper loadTempTrackAsyncHelper = new LoadTempTrackAsyncHelper();
loadTempTrackAsyncHelper.execute();
}
// // CASE 1: recording active
// if (mTrackerServiceRunning) {
// // request an updated track recording from service
// ((MainActivity)mActivity).requestTrack();
// }
//
// // CASE 2: recording stopped - temp file exists
// else if (mStorageHelper.tempFileExists()) {
// // load track from temp file if it exists
// LoadTempTrackAsyncHelper loadTempTrackAsyncHelper = new LoadTempTrackAsyncHelper();
// loadTempTrackAsyncHelper.execute();
// }
// // CASE 3: not recording and no temp file
// else if (mTrack != null) {
// // just draw existing track data (from saved instance)
// drawTrackOverlay(mTrack);
// }
// show/hide the location off notification bar
toggleLocationOffBar();
// start preliminary tracking - if no TrackerService is running
if (!mTrackerServiceRunning && mFragmentVisible) {
startPreliminaryTracking();
}
// register content observer for changes in System Settings
mActivity.getContentResolver().registerContentObserver(android.provider.Settings.Secure.CONTENT_URI, true, mSettingsContentObserver );
}
@Override
public void onPause() {
super.onPause();
// set visibility
mFragmentVisible = false;
// disable preliminary location listeners
stopPreliminaryTracking();
// disable content observer for changes in System Settings
mActivity.getContentResolver().unregisterContentObserver(mSettingsContentObserver);
}
@Override
public void onDestroyView(){
super.onDestroyView();
// deactivate map
mMapView.onDetach();
}
@Override
public void onDestroy() {
LogHelper.v(LOG_TAG, "onDestroy called.");
// reset first start state
mFirstStart = true;
// disable broadcast receivers
LocalBroadcastManager.getInstance(mActivity).unregisterReceiver(mTrackUpdatedReceiver);
super.onDestroy();
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
switch(requestCode) {
case RESULT_SAVE_DIALOG:
if (resultCode == Activity.RESULT_OK) {
// user chose SAVE
if (mTrack.getSize() > 0) {
// Track is not empty - clear map AND save track
clearMap(true);
// FloatingActionButton state is already being handled in MainActivity
((MainActivity)mActivity).onFloatingActionButtonResult(requestCode, resultCode);
LogHelper.v(LOG_TAG, "Save dialog result: SAVE");
} else {
// track is empty
handleEmptyRecordingSaveRequest();
}
} else if (resultCode == Activity.RESULT_CANCELED){
LogHelper.v(LOG_TAG, "Save dialog result: CANCEL");
}
break;
case RESULT_CLEAR_DIALOG:
if (resultCode == Activity.RESULT_OK) {
// User chose CLEAR
if (mTrack.getSize() > 0) {
// Track is not empty - notify user
Toast.makeText(mActivity, getString(R.string.toast_message_track_clear), Toast.LENGTH_LONG).show();
}
// clear map, DO NOT save track
clearMap(false);
// handle FloatingActionButton state in MainActivity
((MainActivity)mActivity).onFloatingActionButtonResult(requestCode, resultCode);
} else if (resultCode == Activity.RESULT_CANCELED){
LogHelper.v(LOG_TAG, "Clear dialog result: CANCEL");
}
break;
case RESULT_EMPTY_RECORDING_DIALOG:
// handle FloatingActionButton state and possible Resume-Action in MainActivity
((MainActivity)mActivity).onFloatingActionButtonResult(requestCode, resultCode);
break;
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
outState.putBoolean(INSTANCE_FIRST_START, mFirstStart);
outState.putBoolean(INSTANCE_TRACKING_STATE, mTrackerServiceRunning);
outState.putParcelable(INSTANCE_CURRENT_LOCATION, mCurrentBestLocation);
outState.putDouble(INSTANCE_LATITUDE_MAIN_MAP, mMapView.getMapCenter().getLatitude());
outState.putDouble(INSTANCE_LONGITUDE_MAIN_MAP, mMapView.getMapCenter().getLongitude());
outState.putDouble(INSTANCE_ZOOM_LEVEL_MAIN_MAP, mMapView.getZoomLevelDouble());
// outState.putParcelable(INSTANCE_TRACK_MAIN_MAP, mTrack);
super.onSaveInstanceState(outState);
}
/* Setter for tracking state */
public void setTrackingState(boolean trackingState) {
mTrackerServiceRunning = trackingState;
// turn on/off tracking for MainActivity Fragment - prevent double tracking
if (mTrackerServiceRunning) {
stopPreliminaryTracking();
} else if (!mLocalTrackerRunning && mFragmentVisible) {
startPreliminaryTracking();
}
if (mTrack != null) {
drawTrackOverlay(mTrack); // TODO check if redundant
}
// update marker
updateMyLocationMarker();
LogHelper.v(LOG_TAG, "TrackingState: " + trackingState);
}
/* Getter for current best location */
public Location getCurrentBestLocation() {
if (mLocationSystemSetting) {
return mCurrentBestLocation;
} else {
return null;
}
}
/* Handles tap on the my location button */
public boolean handleShowMyLocation() {
// do nothing if location setting is off
if (toggleLocationOffBar()) {
stopPreliminaryTracking();
return false;
}
GeoPoint position;
// get current position
if (mTrackerServiceRunning && mTrack != null && mTrack.getSize() > 0) {
// get current Location from tracker service
mCurrentBestLocation = mTrack.getWayPointLocation(mTrack.getSize() - 1);
} else if (mCurrentBestLocation == null) {
// app does not have any location fix
mCurrentBestLocation = LocationHelper.determineLastKnownLocation(mLocationManager);
}
// check if really got a position
if (mCurrentBestLocation != null) {
position = convertToGeoPoint(mCurrentBestLocation);
// center map on current position
mController.setCenter(position);
// mark user's new location on map and remove last marker
updateMyLocationMarker();
// inform user about location quality
String locationInfo;
long locationAge = (SystemClock.elapsedRealtimeNanos() - mCurrentBestLocation.getElapsedRealtimeNanos()) / 1000000;
String locationAgeString = LocationHelper.convertToReadableTime(locationAge, false);
if (locationAgeString == null) {
locationAgeString = mActivity.getString(R.string.toast_message_last_location_age_one_hour);
}
locationInfo = " " + locationAgeString + " | " + mCurrentBestLocation.getProvider();
Toast.makeText(mActivity, mActivity.getString(R.string.toast_message_last_location) + locationInfo, Toast.LENGTH_LONG).show();
return true;
} else {
Toast.makeText(mActivity, mActivity.getString(R.string.toast_message_location_services_not_ready), Toast.LENGTH_LONG).show();
return false;
}
}
/* Removes track crumbs from map */
private void clearMap(boolean saveTrack) {
// clear map
if (mTrackOverlay != null) {
mMapView.getOverlays().remove(mTrackOverlay);
mTrackOverlay = null;
}
if (saveTrack) {
// save track object if requested
SaveTrackAsyncHelper saveTrackAsyncHelper = new SaveTrackAsyncHelper();
saveTrackAsyncHelper.execute();
Toast.makeText(mActivity, mActivity.getString(R.string.toast_message_save_track), Toast.LENGTH_LONG).show();
} else {
// clear track object and delete temp file
mTrack = null;
mStorageHelper.deleteTempFile();
}
}
/* Handles case when user chose to save recording with zero waypoints */ // todo implement
private void handleEmptyRecordingSaveRequest() {
// prepare empty recording dialog ("Unable to save")
int dialogTitle = R.string.dialog_error_empty_recording_title;
String dialogMessage = getString(R.string.dialog_error_empty_recording_content);
int dialogPositiveButton = R.string.dialog_error_empty_recording_action_resume;
int dialogNegativeButton = R.string.dialog_default_action_cancel;
// show empty recording dialog
DialogFragment dialogFragment = DialogHelper.newInstance(dialogTitle, dialogMessage, dialogPositiveButton, dialogNegativeButton);
dialogFragment.setTargetFragment(this, RESULT_EMPTY_RECORDING_DIALOG);
dialogFragment.show(((AppCompatActivity)mActivity).getSupportFragmentManager(), "EmptyRecordingDialog");
// results of dialog are handled by onActivityResult
}
/* Start preliminary tracking for map */
private void startPreliminaryTracking() {
if (mLocationSystemSetting && !mLocalTrackerRunning) {
// create location listeners
List locationProviders = mLocationManager.getAllProviders();
if (locationProviders.contains(LocationManager.GPS_PROVIDER)) {
mGPSListener = createLocationListener();
mLocalTrackerRunning = true;
}
if (locationProviders.contains(LocationManager.NETWORK_PROVIDER)) {
mNetworkListener = createLocationListener();
mLocalTrackerRunning = true;
}
// register listeners
LocationHelper.registerLocationListeners(mLocationManager, mGPSListener, mNetworkListener);
LogHelper.v(LOG_TAG, "Starting preliminary tracking.");
}
}
/* Removes gps and network location listeners */
private void stopPreliminaryTracking() {
if (mLocalTrackerRunning) {
mLocalTrackerRunning = false;
// remove listeners
LocationHelper.removeLocationListeners(mLocationManager, mGPSListener, mNetworkListener);
LogHelper.v(LOG_TAG, "Stopping preliminary tracking.");
}
}
/* Creates listener for changes in location status */
private LocationListener createLocationListener() {
return new LocationListener() {
public void onLocationChanged(Location location) {
// check if the new location is better
if (mCurrentBestLocation == null || LocationHelper.isBetterLocation(location, mCurrentBestLocation)) {
// save location
mCurrentBestLocation = location;
// mark user's new location on map and remove last marker
updateMyLocationMarker();
}
}
public void onStatusChanged(String provider, int status, Bundle extras) {
LogHelper.v(LOG_TAG, "Location provider status change: " + provider + " | " + status);
}
public void onProviderEnabled(String provider) {
LogHelper.v(LOG_TAG, "Location provider enabled: " + provider);
}
public void onProviderDisabled(String provider) {
LogHelper.v(LOG_TAG, "Location provider disabled: " + provider);
}
};
}
/* Updates marker for current user location */
private void updateMyLocationMarker() {
mMapView.getOverlays().remove(mMyLocationOverlay);
// only update while not tracking
if (!mTrackerServiceRunning) {
mMyLocationOverlay = MapHelper.createMyLocationOverlay(mActivity, mCurrentBestLocation, LocationHelper.isCurrent(mCurrentBestLocation), false);
mMapView.getOverlays().add(mMyLocationOverlay);
}
}
/* Draws track onto overlay */
private void drawTrackOverlay(Track track) {
mMapView.getOverlays().remove(mTrackOverlay);
mTrackOverlay = null;
if (track == null || track.getSize() == 0) {
LogHelper.i(LOG_TAG, "Waiting for a track. Showing preliminary location.");
mTrackOverlay = MapHelper.createMyLocationOverlay(mActivity, mCurrentBestLocation, false, mTrackerServiceRunning);
Toast.makeText(mActivity, mActivity.getString(R.string.toast_message_acquiring_location), Toast.LENGTH_LONG).show();
} else {
LogHelper.v(LOG_TAG, "Drawing track overlay.");
mTrackOverlay = MapHelper.createTrackOverlay(mActivity, track, mTrackerServiceRunning);
}
mMapView.getOverlays().add(mTrackOverlay);
}
/* Toggles snackbar indicating that location setting is off */
private boolean toggleLocationOffBar() {
// create snackbar indicator for location setting off
if (mLocationOffBar == null) {
mLocationOffBar = Snackbar.make(mMapView, R.string.snackbar_message_location_offline, Snackbar.LENGTH_INDEFINITE).setAction("Action", null);
}
// get state of location system setting
mLocationSystemSetting = LocationHelper.checkLocationSystemSetting(mActivity);
// show snackbar if necessary
if (!mLocationSystemSetting) {
// show snackbar
mLocationOffBar.show();
return true;
} else {
// hide snackbar
mLocationOffBar.dismiss();
return false;
}
}
/* Creates receiver for new WayPoints */
private BroadcastReceiver createTrackUpdatedReceiver() {
return new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.hasExtra(EXTRA_TRACK) && intent.hasExtra(EXTRA_LAST_LOCATION)) {
// draw track on map
mTrack = intent.getParcelableExtra(EXTRA_TRACK);
drawTrackOverlay(mTrack);
// center map over last location
mCurrentBestLocation = intent.getParcelableExtra(EXTRA_LAST_LOCATION);
mController.setCenter(convertToGeoPoint(mCurrentBestLocation));
// clear intent
intent.setAction(ACTION_DEFAULT);
}
}
};
}
/* Converts Location to GeoPoint */
private GeoPoint convertToGeoPoint (Location location) {
if (location != null) {
return new GeoPoint(location.getLatitude(), location.getLongitude());
} else {
return new GeoPoint(DEFAULT_LATITUDE, DEFAULT_LONGITUDE);
}
}
/* Loads state tracker service from preferences */
private void loadTrackerServiceState(Context context) {
// TODO: get state directly from service, create a ServiceConnection.
// see: https://github.com/ena1106/FragmentBoundServiceExample/blob/master/app/src/main/java/it/ena1106/fragmentboundservice/BoundFragment.java
SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
mTrackerServiceRunning = settings.getBoolean(PREFS_TRACKER_SERVICE_RUNNING, false);
}
/**
* Inner class: SettingsContentObserver is a custom ContentObserver for changes in Android Settings
*/
private class SettingsContentObserver extends ContentObserver {
SettingsContentObserver(Handler handler) {
super(handler);
}
@Override
public boolean deliverSelfNotifications() {
return super.deliverSelfNotifications();
}
@Override
public void onChange(boolean selfChange) {
super.onChange(selfChange);
LogHelper.v(LOG_TAG, "System Setting change detected.");
// check if location setting was changed
boolean previousLocationSystemSetting = mLocationSystemSetting;
mLocationSystemSetting = LocationHelper.checkLocationSystemSetting(mActivity);
if (previousLocationSystemSetting != mLocationSystemSetting) {
LogHelper.v(LOG_TAG, "Location Setting change detected.");
toggleLocationOffBar();
}
// start / stop preliminary tracking
if (!mLocationSystemSetting) {
stopPreliminaryTracking();
} else if (!mTrackerServiceRunning && mFragmentVisible) {
startPreliminaryTracking();
}
}
}
/**
* Inner class: Saves track to external storage using AsyncTask
*/
private class SaveTrackAsyncHelper extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... voids) {
LogHelper.v(LOG_TAG, "Saving track object in background.");
// save track object
mStorageHelper.saveTrack(mTrack, FILE_MOST_CURRENT_TRACK);
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
super.onPostExecute(aVoid);
// clear track object
LogHelper.v(LOG_TAG, "Saving finished.");
mTrack = null;
// notify track fragment that save is finished
Intent i = new Intent();
i.setAction(ACTION_TRACK_SAVE);
i.putExtra(EXTRA_SAVE_FINISHED, true);
LocalBroadcastManager.getInstance(mActivity).sendBroadcast(i);
}
}
/**
* End of inner class
*/
/**
* Inner class: Loads track from external storage using AsyncTask
*/
private class LoadTempTrackAsyncHelper extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... voids) {
LogHelper.v(LOG_TAG, "Loading temporary track object in background.");
// load track object
mTrack = mStorageHelper.loadTrack(FILE_TEMP_TRACK);
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
super.onPostExecute(aVoid);
LogHelper.v(LOG_TAG, "Loading finished.");
// draw track on map
if (mTrack != null) {
drawTrackOverlay(mTrack);
}
// delete temp file
// mStorageHelper.deleteTempFile(); // todo check if necessary
}
}
/**
* End of inner class
*/
}

View File

@ -1,729 +0,0 @@
/**
* MainActivityTrackFragment.java
* Implements the track fragment used in the track tab of the main activity
* This fragment displays a saved track
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.location.Location;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Vibrator;
import android.view.GestureDetector;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.Group;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import org.osmdroid.api.IMapController;
import org.osmdroid.events.MapEventsReceiver;
import org.osmdroid.tileprovider.tilesource.TileSourceFactory;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.MapView;
import org.osmdroid.views.overlay.ItemizedIconOverlay;
import org.osmdroid.views.overlay.MapEventsOverlay;
import org.osmdroid.views.overlay.TilesOverlay;
import org.osmdroid.views.overlay.compass.CompassOverlay;
import org.osmdroid.views.overlay.compass.InternalCompassOrientationProvider;
import org.y20k.trackbook.core.Track;
import org.y20k.trackbook.helpers.DialogHelper;
import org.y20k.trackbook.helpers.DropdownAdapter;
import org.y20k.trackbook.helpers.ExportHelper;
import org.y20k.trackbook.helpers.LengthUnitHelper;
import org.y20k.trackbook.helpers.LocationHelper;
import org.y20k.trackbook.helpers.LogHelper;
import org.y20k.trackbook.helpers.MapHelper;
import org.y20k.trackbook.helpers.NightModeHelper;
import org.y20k.trackbook.helpers.StorageHelper;
import org.y20k.trackbook.helpers.TrackbookKeys;
import java.io.File;
import java.text.DateFormat;
import java.util.Locale;
/**
* MainActivityTrackFragment class
*/
public class MainActivityTrackFragment extends Fragment implements AdapterView.OnItemSelectedListener, MapEventsReceiver, TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = MainActivityTrackFragment.class.getSimpleName();
/* Main class variables */
private FragmentActivity mActivity;
private View mRootView;
private MapView mMapView;
private LinearLayout mOnboardingView;
private IMapController mController;
private ItemizedIconOverlay mTrackOverlay;
private DropdownAdapter mDropdownAdapter;
private ConstraintLayout mTrackManagementLayout;
private Spinner mDropdown;
private View mStatisticsSheet;
private View mStatisticsView;
private TextView mDistanceView;
private TextView mStepsView;
private TextView mWaypointsView;
private TextView mDurationView;
private TextView mRecordingStartView;
private TextView mRecordingStopView;
private TextView mMaxAltitudeView;
private TextView mMinAltitudeView;
private TextView mPositiveElevationView;
private TextView mNegativeElevationView;
private Group mElevationDataViews;
private Group mStatisticsHeaderViews;
private BottomSheetBehavior mStatisticsSheetBehavior;
private int mCurrentTrack;
private Track mTrack;
private BroadcastReceiver mTrackSavedReceiver;
/* Return a new Instance of MainActivityTrackFragment */
public static MainActivityTrackFragment newInstance() {
return new MainActivityTrackFragment();
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// action bar has options menu
setHasOptionsMenu(true);
// store activity
mActivity = getActivity();
// get current track
if (savedInstanceState != null) {
mCurrentTrack = savedInstanceState.getInt(INSTANCE_CURRENT_TRACK, 0);
} else {
mCurrentTrack = 0;
}
// create drop-down adapter
mDropdownAdapter = new DropdownAdapter(mActivity);
// listen for finished save operation
mTrackSavedReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.hasExtra(EXTRA_SAVE_FINISHED) && intent.getBooleanExtra(EXTRA_SAVE_FINISHED, false)) {
LogHelper.v(LOG_TAG, "Save operation detected. Start loading the new track.");
// update dropdown menu (and load track in onItemSelected)
mDropdownAdapter.refresh();
mDropdownAdapter.notifyDataSetChanged();
mDropdown.setAdapter(mDropdownAdapter);
mDropdown.setSelection(0, true);
// remove onboarding if necessary
switchOnboardingLayout();
}
}
};
IntentFilter trackSavedReceiverIntentFilter = new IntentFilter(ACTION_TRACK_SAVE);
LocalBroadcastManager.getInstance(mActivity).registerReceiver(mTrackSavedReceiver, trackSavedReceiverIntentFilter);
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
// inflate root view from xml
mRootView = inflater.inflate(R.layout.fragment_main_track, container, false);
// get reference to onboarding layout
mOnboardingView = (LinearLayout) mRootView.findViewById(R.id.track_tab_onboarding);
// get reference to basic map
mMapView = (MapView) mRootView.findViewById(R.id.track_map);
// get map controller
mController = mMapView.getController();
// basic map setup
mMapView.setTileSource(TileSourceFactory.MAPNIK);
mMapView.setTilesScaledToDpi(true);
// set dark map tiles, if necessary
if (NightModeHelper.getNightMode(mActivity)) {
mMapView.getOverlayManager().getTilesOverlay().setColorFilter(TilesOverlay.INVERT_COLORS);
}
// add multi-touch capability
mMapView.setMultiTouchControls(true);
// disable default zoom controls
mMapView.getZoomController().setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER);
// add compass to map
CompassOverlay compassOverlay = new CompassOverlay(mActivity, new InternalCompassOrientationProvider(mActivity), mMapView);
compassOverlay.enableCompass();
// move the compass overlay down a bit
compassOverlay.setCompassCenter(35.0f, 96.0f);
mMapView.getOverlays().add(compassOverlay);
// initiate map state
if (savedInstanceState != null) {
// restore saved instance of map
GeoPoint position = new GeoPoint(savedInstanceState.getDouble(INSTANCE_LATITUDE_TRACK_MAP, DEFAULT_LATITUDE), savedInstanceState.getDouble(INSTANCE_LONGITUDE_TRACK_MAP, DEFAULT_LONGITUDE));
mController.setCenter(position);
mController.setZoom(savedInstanceState.getDouble(INSTANCE_ZOOM_LEVEL_MAIN_MAP, 16f));
} else {
mController.setZoom(16f);
}
// get views for track selector
mTrackManagementLayout = (ConstraintLayout) mRootView.findViewById(R.id.track_management_layout);
mDropdown = (Spinner) mRootView.findViewById(R.id.track_selector);
// attach listeners to export and delete buttons
ImageButton shareButton = (ImageButton) mRootView.findViewById(R.id.share_button);
ImageButton exportButton = (ImageButton) mRootView.findViewById(R.id.export_button);
ImageButton deleteButton = (ImageButton) mRootView.findViewById(R.id.delete_button);
shareButton.setOnClickListener(getShareButtonListener());
exportButton.setOnClickListener(getExportButtonListener());
deleteButton.setOnClickListener(getDeleteButtonListener());
// get views for statistics sheet
mStatisticsView = mRootView.findViewById(R.id.statistics_view);
mStatisticsSheet = mRootView.findViewById(R.id.statistics_sheet);
mDistanceView = (TextView) mRootView.findViewById(R.id.statistics_data_distance);
mStepsView = (TextView) mRootView.findViewById(R.id.statistics_data_steps);
mWaypointsView = (TextView) mRootView.findViewById(R.id.statistics_data_waypoints);
mDurationView = (TextView) mRootView.findViewById(R.id.statistics_data_duration);
mRecordingStartView = (TextView) mRootView.findViewById(R.id.statistics_data_recording_start);
mRecordingStopView = (TextView) mRootView.findViewById(R.id.statistics_data_recording_stop);
mMaxAltitudeView = (TextView) mRootView.findViewById(R.id.statistics_data_max_altitude);
mMinAltitudeView = (TextView) mRootView.findViewById(R.id.statistics_data_min_altitude);
mPositiveElevationView = (TextView) mRootView.findViewById(R.id.statistics_data_positive_elevation);
mNegativeElevationView = (TextView) mRootView.findViewById(R.id.statistics_data_negative_elevation);
mElevationDataViews = (Group) mRootView.findViewById(R.id.elevation_data);
mStatisticsHeaderViews = (Group) mRootView.findViewById(R.id.statistics_header);
// display map and statistics
if (savedInstanceState != null) {
// get track from saved instance and display map and statistics
mTrack = savedInstanceState.getParcelable(INSTANCE_TRACK_TRACK_MAP);
displayTrack();
} else if (mTrack == null) {
// load track and display map and statistics
LoadTrackAsyncHelper loadTrackAsyncHelper = new LoadTrackAsyncHelper();
loadTrackAsyncHelper.execute();
} else {
// just display map and statistics
displayTrack();
}
// set up and show statistics sheet
mStatisticsSheetBehavior = BottomSheetBehavior.from(mStatisticsSheet);
mStatisticsSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
mStatisticsSheetBehavior.setBottomSheetCallback(getStatisticsSheetCallback());
// attach listener for taps on elevation views
attachTapListenerToElevationViews();
// attach listener for taps on statistics sheet header
attachTapListenerToStatisticHeaderViews();
// attach listener for taps on statistics - for US and other states plagued by Imperial units
if (LengthUnitHelper.getUnitSystem() == IMPERIAL || Locale.getDefault().getCountry().equals("GB")) {
attachTapListenerToStatisticsSheet();
}
// enable additional gestures
MapEventsOverlay OverlayEventos = new MapEventsOverlay(this);
mMapView.getOverlays().add(OverlayEventos);
return mRootView;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
mDropdown.setAdapter(mDropdownAdapter);
mDropdown.setOnItemSelectedListener(this);
}
@Override
public void onResume() {
super.onResume();
// show / hide the onboarding layout
switchOnboardingLayout();
}
@Override
public void onPause() {
super.onPause();
}
@Override
public void onDestroyView(){
super.onDestroyView();
// deactivate map
mMapView.onDetach();
}
@Override
public void onDestroy() {
LogHelper.v(LOG_TAG, "onDestroy called.");
// remove listener
LocalBroadcastManager.getInstance(mActivity).unregisterReceiver(mTrackSavedReceiver);
super.onDestroy();
}
@Override
public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
// update current track
mCurrentTrack = i;
// load track and display map and statistics
LoadTrackAsyncHelper loadTrackAsyncHelper = new LoadTrackAsyncHelper();
loadTrackAsyncHelper.execute(i);
}
@Override
public void onNothingSelected(AdapterView<?> adapterView) {
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
outState.putDouble(INSTANCE_LATITUDE_TRACK_MAP, mMapView.getMapCenter().getLatitude());
outState.putDouble(INSTANCE_LONGITUDE_TRACK_MAP, mMapView.getMapCenter().getLongitude());
outState.putDouble(INSTANCE_ZOOM_LEVEL_TRACK_MAP, mMapView.getZoomLevelDouble());
outState.putParcelable(INSTANCE_TRACK_TRACK_MAP, mTrack);
outState.putInt(INSTANCE_CURRENT_TRACK, mCurrentTrack);
super.onSaveInstanceState(outState);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
switch(requestCode) {
case RESULT_DELETE_DIALOG:
if (resultCode == Activity.RESULT_OK) {
deleteCurrentTrack();
} else if (resultCode == Activity.RESULT_CANCELED){
LogHelper.v(LOG_TAG, "Delete dialog result: CANCEL");
}
break;
case RESULT_EXPORT_DIALOG:
if (resultCode == Activity.RESULT_OK) {
// user chose EXPORT
ExportHelper.exportToGpx(mActivity, mTrack);
} else if (resultCode == Activity.RESULT_CANCELED){
// User chose CANCEL
LogHelper.v(LOG_TAG, "Export to GPX: User chose CANCEL.");
}
break;
}
}
@Override
public boolean singleTapConfirmedHelper(GeoPoint p) {
return false;
}
@Override
public boolean longPressHelper(GeoPoint p) {
if (mTrack != null) {
// vibrate 50 milliseconds
Vibrator vibrator = (Vibrator) mActivity.getSystemService(Context.VIBRATOR_SERVICE);
vibrator.vibrate(50);
// zoom to bounding box (= edge coordinates of map)
mMapView.zoomToBoundingBox(mTrack.getBoundingBox(), true);
}
return true;
}
/* Displays map and statistics for track */
private void displayTrack() {
GeoPoint position;
if (mTrack != null && mTrack.getSize() > 0) {
// set end of track as position
Location lastLocation = mTrack.getWayPointLocation(mTrack.getSize() -1);
position = new GeoPoint(lastLocation.getLatitude(), lastLocation.getLongitude());
String recordingStart = DateFormat.getDateInstance(DateFormat.SHORT, Locale.getDefault()).format(mTrack.getRecordingStart()) + " " +
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(mTrack.getRecordingStart());
String recordingStop = DateFormat.getDateInstance(DateFormat.SHORT, Locale.getDefault()).format(mTrack.getRecordingStop()) + " " +
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(mTrack.getRecordingStop());
String stepsTaken;
if (mTrack.getStepCount() == -1) {
stepsTaken = getString(R.string.statistics_sheet_p_steps_no_pedometer);
} else {
stepsTaken = String.valueOf(Math.round(mTrack.getStepCount()));
}
// populate length views
displayCurrentLengthUnits();
// populate other views
mStepsView.setText(stepsTaken);
mWaypointsView.setText(String.valueOf(mTrack.getWayPoints().size()));
mDurationView.setText(LocationHelper.convertToReadableTime(mTrack.getTrackDuration(), true));
mRecordingStartView.setText(recordingStart);
mRecordingStopView.setText(recordingStop);
// show/hide elevation views depending on file format version
if (mTrack.getTrackFormatVersion() > 1 && mTrack.getMinAltitude() > 0) {
// show elevation views
mElevationDataViews.setVisibility(View.VISIBLE);
} else {
// hide elevation views
mElevationDataViews.setVisibility(View.GONE);
}
// draw track on map
drawTrackOverlay(mTrack);
} else {
position = new GeoPoint(DEFAULT_LATITUDE, DEFAULT_LONGITUDE);
}
// center map over position
mController.setCenter(position);
}
/* Draws track onto overlay */
private void drawTrackOverlay(Track track) {
mMapView.getOverlays().remove(mTrackOverlay);
mTrackOverlay = MapHelper.createTrackOverlay(mActivity, track, false);
mMapView.getOverlays().add(mTrackOverlay);
}
/* show the onboarding layout, if no track has been recorded yet */
private void switchOnboardingLayout() {
if (mDropdownAdapter.isEmpty()){
// show onboarding layout
mMapView.setVisibility(View.GONE);
mOnboardingView.setVisibility(View.VISIBLE);
mTrackManagementLayout.setVisibility(View.GONE);
mStatisticsSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
mStatisticsSheet.setVisibility(View.GONE);
} else {
// show normal layout
mOnboardingView.setVisibility(View.GONE);
mMapView.setVisibility(View.VISIBLE);
mTrackManagementLayout.setVisibility(View.VISIBLE);
mStatisticsSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
mStatisticsSheet.setVisibility(View.VISIBLE);
}
}
/* Displays views in statistic sheet according to current locale */
private void displayCurrentLengthUnits() {
mDistanceView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getTrackDistance()));
mPositiveElevationView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getPositiveElevation()));
mNegativeElevationView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getNegativeElevation()));
mMaxAltitudeView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getMaxAltitude()));
mMinAltitudeView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getMinAltitude()));
}
/* Switches views in statistic sheet between Metric and Imperial */
private void displayOppositeLengthUnits() {
int oppositeLengthUnit = LengthUnitHelper.getUnitSystem() * -1;
mDistanceView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getTrackDistance(), oppositeLengthUnit));
mPositiveElevationView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getPositiveElevation(), oppositeLengthUnit));
mNegativeElevationView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getNegativeElevation(), oppositeLengthUnit));
mMaxAltitudeView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getMaxAltitude(), oppositeLengthUnit));
mMinAltitudeView.setText(LengthUnitHelper.convertDistanceToString(mTrack.getMinAltitude(), oppositeLengthUnit));
}
/* Deletes currently visible track */
private void deleteCurrentTrack() {
// delete track file and refresh dropdown adapter
if (mDropdownAdapter.getItem(mCurrentTrack).getTrackFile().delete()) {
mDropdownAdapter.refresh();
mDropdownAdapter.notifyDataSetChanged();
mDropdown.setAdapter(mDropdownAdapter);
} else {
LogHelper.e(LOG_TAG, "Unable to delete recording.");
return;
}
if (mDropdownAdapter.isEmpty()) {
// show onboarding
switchOnboardingLayout();
} else {
// show next track
mDropdown.setSelection(0, true);
mCurrentTrack = 0;
}
}
/* Creates BottomSheetCallback for the statistics sheet - needed in onCreateView */
private BottomSheetBehavior.BottomSheetCallback getStatisticsSheetCallback() {
return new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull View bottomSheet, int newState) {
// react to state change
switch (newState) {
case BottomSheetBehavior.STATE_EXPANDED:
// statistics sheet expanded
mTrackManagementLayout.setVisibility(View.INVISIBLE);
mStatisticsSheet.setBackgroundColor(ContextCompat.getColor(mActivity, R.color.statistic_sheet_background_expanded));
break;
case BottomSheetBehavior.STATE_COLLAPSED:
// statistics sheet collapsed
mTrackManagementLayout.setVisibility(View.VISIBLE);
mStatisticsSheet.setBackgroundColor(ContextCompat.getColor(mActivity, R.color.statistic_sheet_background_collapsed));
mStatisticsSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
break;
case BottomSheetBehavior.STATE_HIDDEN:
// statistics sheet hidden
mStatisticsSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
break;
default:
break;
}
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
// reset length unit displays
displayCurrentLengthUnits();
// react to dragging events
if (slideOffset < 0.5f) {
mTrackManagementLayout.setVisibility(View.VISIBLE);
} else {
mTrackManagementLayout.setVisibility(View.INVISIBLE);
}
if (slideOffset < 0.125f) {
mStatisticsSheet.setBackgroundColor(ContextCompat.getColor(mActivity, R.color.statistic_sheet_background_collapsed));
} else {
mStatisticsSheet.setBackgroundColor(ContextCompat.getColor(mActivity, R.color.statistic_sheet_background_expanded));
}
}
};
}
/* Creates OnClickListener for the export button - needed in onCreateView */
private View.OnClickListener getShareButtonListener() {
return new View.OnClickListener() {
@Override
public void onClick(View view) {
Intent intent = ExportHelper.getGpxFileIntent(mActivity, mTrack);
// create intent to show chooser
String title = getString(R.string.dialog_share_gpx);
// String title = getResources().getString(R.string.chooser_title);
Intent chooser = Intent.createChooser(intent, title);
if (intent.resolveActivity(mActivity.getPackageManager()) != null) {
startActivity(chooser);
} else {
Toast.makeText(mActivity, R.string.toast_message_install_file_helper, Toast.LENGTH_LONG).show();
}
}
};
}
/* Creates OnClickListener for the export button - needed in onCreateView */
private View.OnClickListener getExportButtonListener() {
return new View.OnClickListener() {
@Override
public void onClick(View view) {
// dialog text components
int dialogTitle;
int dialogPositiveButton;
int dialogNegativeButton;
DateFormat df = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault());
String recordingStartDate = df.format(mTrack.getRecordingStart());
String dialogMessage;
// get text elements for delete dialog
if (ExportHelper.gpxFileExists(mTrack)) {
// CASE: OVERWRITE - GPX file exists
dialogTitle = R.string.dialog_export_title_overwrite;
dialogMessage = getString(R.string.dialog_export_content_overwrite) + " (" + recordingStartDate + " | " + LengthUnitHelper.convertDistanceToString(mTrack.getTrackDistance()) + ")";
dialogPositiveButton = R.string.dialog_export_action_overwrite;
dialogNegativeButton = R.string.dialog_default_action_cancel;
} else {
// CASE: EXPORT - GPX file does NOT yet exits
dialogTitle = R.string.dialog_export_title_export;
dialogMessage = getString(R.string.dialog_export_content_export) + " (" + recordingStartDate + " | " + LengthUnitHelper.convertDistanceToString(mTrack.getTrackDistance()) + ")";
dialogPositiveButton = R.string.dialog_export_action_export;
dialogNegativeButton = R.string.dialog_default_action_cancel;
}
// show delete dialog - results are handles by onActivityResult
DialogFragment dialogFragment = DialogHelper.newInstance(dialogTitle, dialogMessage, dialogPositiveButton, dialogNegativeButton);
dialogFragment.setTargetFragment(MainActivityTrackFragment.this, RESULT_EXPORT_DIALOG);
dialogFragment.show(mActivity.getSupportFragmentManager(), "ExportDialog");
}
};
}
/* Creates OnClickListener for the delete button - needed in onCreateView */
private View.OnClickListener getDeleteButtonListener() {
return new View.OnClickListener() {
@Override
public void onClick(View view) {
// get text elements for delete dialog
int dialogTitle = R.string.dialog_delete_title;
int dialogPositiveButton = R.string.dialog_delete_action_delete;
int dialogNegativeButton = R.string.dialog_default_action_cancel;
DateFormat df = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault());
String recordingStartDate = df.format(mTrack.getRecordingStart());
String dialogMessage = getString(R.string.dialog_delete_content) + " " + recordingStartDate + " | " + LengthUnitHelper.convertDistanceToString(mTrack.getTrackDistance());
// show delete dialog - results are handles by onActivityResult
DialogFragment dialogFragment = DialogHelper.newInstance(dialogTitle, dialogMessage, dialogPositiveButton, dialogNegativeButton);
dialogFragment.setTargetFragment(MainActivityTrackFragment.this, RESULT_DELETE_DIALOG);
dialogFragment.show(mActivity.getSupportFragmentManager(), "DeleteDialog");
}
};
}
/* Add tap listener to elevation data views */
private void attachTapListenerToElevationViews() {
int referencedIds[] = mElevationDataViews.getReferencedIds();
for (int id : referencedIds) {
mRootView.findViewById(id).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// inform user about possible issues with altitude measurements
Toast.makeText(mActivity, R.string.toast_message_elevation_info, Toast.LENGTH_LONG).show();
}
});
}
}
/* Add tap listener to statistic header views */
private void attachTapListenerToStatisticHeaderViews() {
int referencedIds[] = mStatisticsHeaderViews.getReferencedIds();
for (int id : referencedIds) {
mRootView.findViewById(id).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (mStatisticsSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
mStatisticsSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
} else {
mStatisticsSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
}
}
});
}
}
/* Add tap listener to statistics sheet */
private void attachTapListenerToStatisticsSheet() {
mStatisticsView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if(event.getAction() == MotionEvent.ACTION_DOWN) {
displayOppositeLengthUnits();
} else if (event.getAction() == MotionEvent.ACTION_UP) {
displayCurrentLengthUnits();
}
return true;
}
});
}
/**
* Inner class: Loads track from external storage using AsyncTask
*/
private class LoadTrackAsyncHelper extends AsyncTask<Integer, Void, Void> {
@Override
protected Void doInBackground(Integer... ints) {
LogHelper.v(LOG_TAG, "Loading track object in background.");
StorageHelper storageHelper = new StorageHelper(mActivity);
if (ints.length > 0) {
// get track file from dropdown adapter
int item = ints[0];
File trackFile = mDropdownAdapter.getItem(item).getTrackFile();
LogHelper.v(LOG_TAG, "Loading track number " + item);
mTrack = storageHelper.loadTrack(trackFile);
} else {
// load track object from most current file
LogHelper.v(LOG_TAG, "No specific track specified. Loading most current one.");
mTrack = storageHelper.loadTrack(FILE_MOST_CURRENT_TRACK);
}
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
super.onPostExecute(aVoid);
// display track on map
displayTrack();
}
}
/**
* End of inner class
*/
}

View File

@ -0,0 +1,289 @@
/*
* MapFragment.kt
* Implements the MapFragment fragment
* A MapFragment displays a map using osmdroid as well as the controls to start / stop a recording
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook
import YesNoDialog
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.location.Location
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.TracklistElement
import org.y20k.trackbook.helpers.*
import org.y20k.trackbook.ui.MapFragmentLayoutHolder
/*
* MapFragment class
*/
class MapFragment : Fragment(), YesNoDialog.YesNoDialogListener {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(MapFragment::class.java)
/* Main class variables */
private var bound: Boolean = false
private val handler: Handler = Handler()
private var trackingState: Int = Keys.STATE_NOT_TRACKING
private var gpsProviderActive: Boolean = false
private var networkProviderActive: Boolean = false
private var track: Track = Track()
private lateinit var currentBestLocation: Location
private lateinit var layout: MapFragmentLayoutHolder
private lateinit var trackerService: TrackerService
/* Overrides onCreate from Fragment */
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// get current best location
currentBestLocation = LocationHelper.getLastKnownLocation(activity as Context)
// get saved tracking state
trackingState = PreferencesHelper.loadTrackingState(activity as Context)
}
/* Overrides onStop from Fragment */
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
// initialize layout
layout = MapFragmentLayoutHolder(activity as Context, inflater, container, currentBestLocation, trackingState)
// set up buttons
layout.currentLocationButton.setOnClickListener {
layout.centerMap(currentBestLocation, animated = true)
}
layout.recordingButton.setOnClickListener {
handleTrackingManagementMenu()
}
layout.saveButton.setOnClickListener {
saveTrack()
}
layout.clearButton.setOnClickListener {
trackerService.clearTrack()
}
layout.resumeButton.setOnClickListener {
// start service via intent so that it keeps running after unbind
startTrackerService()
trackerService.resumeTracking()
}
return layout.rootView
}
/* Overrides onStart from Fragment */
override fun onStart() {
super.onStart()
// request location permission if denied
if (ContextCompat.checkSelfPermission(activity as Context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_DENIED) {
this.requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), Keys.REQUEST_CODE_FOREGROUND)
}
// bind to TrackerService
activity?.bindService(Intent(activity, TrackerService::class.java), connection, Context.BIND_AUTO_CREATE)
}
/* Overrides onResume from Fragment */
override fun onResume() {
super.onResume()
// show hide the location error snackbar
layout.toggleLocationErrorBar(gpsProviderActive, networkProviderActive)
// set map center
layout.centerMap(currentBestLocation)
}
/* Overrides onPause from Fragment */
override fun onPause() {
super.onPause()
layout.saveState(currentBestLocation)
}
/* Overrides onStop from Fragment */
override fun onStop() {
super.onStop()
// unbind from TrackerService
activity?.unbindService(connection)
}
/* Overrides onRequestPermissionsResult from Fragment */
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
Keys.REQUEST_CODE_FOREGROUND -> {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
// permission was granted - re-bind service
activity?.unbindService(connection)
activity?.bindService(Intent(activity, TrackerService::class.java), connection, Context.BIND_AUTO_CREATE)
LogHelper.i(TAG, "Request result: Location permission has been granted.")
} else {
// permission denied - unbind service
activity?.unbindService(connection)
}
layout.toggleLocationErrorBar(gpsProviderActive, networkProviderActive)
return
}
}
}
/* Overrides onYesNoDialog from YesNoDialogListener */
override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) {
super.onYesNoDialog(type, dialogResult, payload, payloadString)
when (type) {
Keys.DIALOG_EMPTY_RECORDING -> {
when (dialogResult) {
// user tapped resume
true -> {
trackerService.resumeTracking()
}
}
}
}
}
/* Start tracker service */
private fun startTrackerService() {
val intent = Intent(activity, TrackerService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// ... start service in foreground to prevent it being killed on Oreo
activity?.startForegroundService(intent)
} else {
activity?.startService(intent)
}
}
/* Starts / pauses tracking and toggles the recording sub menu_bottom_navigation */
private fun handleTrackingManagementMenu() {
when (trackingState) {
Keys.STATE_TRACKING_STOPPED -> layout.toggleRecordingButtonSubMenu()
Keys.STATE_TRACKING_ACTIVE -> trackerService.stopTracking()
Keys.STATE_NOT_TRACKING -> {
// start service via intent so that it keeps running after unbind
startTrackerService()
trackerService.startTracking()
}
}
}
/* Saves track - shows dialog, if recording is still empty */
private fun saveTrack() {
if (track.wayPoints.isEmpty()) {
YesNoDialog(this as YesNoDialog.YesNoDialogListener).show(activity as Context, type = Keys.DIALOG_EMPTY_RECORDING, title = R.string.dialog_error_empty_recording_title, message = R.string.dialog_error_empty_recording_message, yesButton = R.string.dialog_error_empty_recording_action_resume)
} else {
GlobalScope.launch {
// step 1: create and store filenames for json and gpx files
track.trackUriString = FileHelper.getTrackFileUri(activity as Context, track).toString()
track.gpxUriString = FileHelper.getGpxFileUri(activity as Context, track).toString()
// step 2: save track
FileHelper.saveTrackSuspended(track, saveGpxToo = true)
// step 3: save tracklist - suspended
FileHelper.addTrackAndSaveTracklistSuspended(activity as Context, track)
// step 3: clear track
trackerService.clearTrack()
// step 4: open track in TrackFragement
openTrack(track.toTracklistElement(activity as Context))
}
}
}
/* Opens a track in TrackFragment */
private fun openTrack(tracklistElement: TracklistElement) {
val bundle: Bundle = Bundle()
bundle.putString(Keys.ARG_TRACK_TITLE, tracklistElement.name)
bundle.putString(Keys.ARG_TRACK_FILE_URI, tracklistElement.trackUriString)
bundle.putString(Keys.ARG_GPX_FILE_URI, tracklistElement.gpxUriString)
bundle.putLong(Keys.ARG_TRACK_ID, TrackHelper.getTrackId(tracklistElement))
findNavController().navigate(R.id.fragment_track, bundle)
}
/*
* Defines callbacks for service binding, passed to bindService()
*/
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
// We've bound to LocalService, cast the IBinder and get LocalService instance
val binder = service as TrackerService.LocalBinder
trackerService = binder.service
bound = true
// start listening for location updates
handler.removeCallbacks(periodicLocationRequestRunnable)
handler.postDelayed(periodicLocationRequestRunnable, 0)
}
override fun onServiceDisconnected(arg0: ComponentName) {
bound = false
// stop receiving location updates
handler.removeCallbacks(periodicLocationRequestRunnable)
}
}
/*
* End of declaration
*/
/*
* Runnable: Periodically requests location
*/
private val periodicLocationRequestRunnable: Runnable = object : Runnable {
override fun run() {
// pull values from service
currentBestLocation = trackerService.currentBestLocation
track = trackerService.track
gpsProviderActive = trackerService.gpsProviderActive
networkProviderActive = trackerService.networkProviderActive
trackingState = trackerService.trackingState
// update location and track
layout.markCurrentPosition(currentBestLocation, trackingState)
layout.overlayCurrentTrack(track, trackingState)
layout.updateRecordingButton(trackingState)
// center map, if it had not been dragged/zoomed before
if (!layout.userInteraction) { layout.centerMap(currentBestLocation, true)}
// show error snackbar if necessary
layout.toggleLocationErrorBar(gpsProviderActive, networkProviderActive)
// use the handler to start runnable again after specified delay
handler.postDelayed(this, Keys.REQUEST_CURRENT_LOCATION_INTERVAL)
}
}
/*
* End of declaration
*/
}

View File

@ -0,0 +1,109 @@
/*
* SettingsFragment.kt
* Implements the SettingsFragment fragment
* A SettingsFragment displays the user accessible settings of the app
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook
import android.content.Context
import android.os.Bundle
import android.view.View
import androidx.preference.*
import org.y20k.trackbook.helpers.LengthUnitHelper
import org.y20k.trackbook.helpers.LogHelper
/*
* SettingsFragment class
*/
class SettingsFragment : PreferenceFragmentCompat() {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(SettingsFragment::class.java)
/* Overrides onViewCreated from PreferenceFragmentCompat */
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// set the background color
view.setBackgroundColor(resources.getColor(R.color.app_window_background, null))
// add padding - necessary because translucent status bar is used
val topPadding = this.resources.displayMetrics.density * 24 // 24 dp * display density
view.setPadding(0, topPadding.toInt(), 0, 0)
}
/* Overrides onCreatePreferences from PreferenceFragmentCompat */
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val context = preferenceManager.context
val screen = preferenceManager.createPreferenceScreen(context)
// set up "Enable Imperial Measurements" preference
val preferenceImperialMeasurementUnits: SwitchPreferenceCompat = SwitchPreferenceCompat(activity as Context)
preferenceImperialMeasurementUnits.title = getString(R.string.pref_imperial_measurement_units_title)
preferenceImperialMeasurementUnits.key = Keys.PREF_USE_IMPERIAL_UNITS
preferenceImperialMeasurementUnits.summaryOn = getString(R.string.pref_imperial_measurement_units_summary_imperial)
preferenceImperialMeasurementUnits.summaryOff = getString(R.string.pref_imperial_measurement_units_summary_metric)
preferenceImperialMeasurementUnits.setDefaultValue(LengthUnitHelper.useImperialUnits())
// set up "Restrict to GPS" preference
val preferenceGpsOnly: SwitchPreferenceCompat = SwitchPreferenceCompat(activity as Context)
preferenceGpsOnly.title = getString(R.string.pref_gps_only_title)
preferenceGpsOnly.key = Keys.PREF_GPS_ONLY
preferenceGpsOnly.summaryOn = getString(R.string.pref_gps_only_summary_gps_only)
preferenceGpsOnly.summaryOff = getString(R.string.pref_gps_only_summary_gps_and_network)
preferenceGpsOnly.setDefaultValue(false)
// set up "Accuracy Threshold" preference
val preferenceAccuracyThreshold: SeekBarPreference = SeekBarPreference(activity as Context)
preferenceAccuracyThreshold.title = getString(R.string.pref_accuracy_threshold_title)
preferenceAccuracyThreshold.key = Keys.PREF_LOCATION_ACCURACY_THRESHOLD
preferenceAccuracyThreshold.summary = getString(R.string.pref_accuracy_threshold_summary)
preferenceAccuracyThreshold.showSeekBarValue = true
preferenceAccuracyThreshold.max = 50
preferenceAccuracyThreshold.setDefaultValue(Keys.DEFAULT_THRESHOLD_LOCATION_ACCURACY)
// set up "Reset" preference
val preferenceResetAdvanced: Preference = Preference(activity as Context)
preferenceResetAdvanced.title = getString(R.string.pref_reset_advanced_title)
preferenceResetAdvanced.summary = getString(R.string.pref_reset_advanced_summary)
preferenceResetAdvanced.setOnPreferenceClickListener{
preferenceAccuracyThreshold.value = Keys.DEFAULT_THRESHOLD_LOCATION_ACCURACY
return@setOnPreferenceClickListener true
}
// set preference categories
val preferenceCategoryGeneral: PreferenceCategory = PreferenceCategory(activity as Context)
preferenceCategoryGeneral.title = getString(R.string.pref_general_title)
preferenceCategoryGeneral.contains(preferenceImperialMeasurementUnits)
preferenceCategoryGeneral.contains(preferenceGpsOnly)
val preferenceCategoryAdvanced: PreferenceCategory = PreferenceCategory(activity as Context)
preferenceCategoryAdvanced.title = getString(R.string.pref_advanced_title)
preferenceCategoryAdvanced.contains(preferenceAccuracyThreshold)
preferenceCategoryAdvanced.contains(preferenceResetAdvanced)
// setup preference screen
screen.addPreference(preferenceCategoryGeneral)
screen.addPreference(preferenceImperialMeasurementUnits)
screen.addPreference(preferenceGpsOnly)
screen.addPreference(preferenceCategoryAdvanced)
screen.addPreference(preferenceAccuracyThreshold)
screen.addPreference(preferenceResetAdvanced)
preferenceScreen = screen
}
}

View File

@ -0,0 +1,143 @@
/*
* TrackFragment.kt
* Implements the TrackFragment fragment
* A TrackFragment displays a previously recorded track
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook
import YesNoDialog
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.y20k.trackbook.Keys.ARG_TRACK_ID
import org.y20k.trackbook.dialogs.RenameTrackDialog
import org.y20k.trackbook.helpers.FileHelper
import org.y20k.trackbook.helpers.LogHelper
import org.y20k.trackbook.ui.TrackFragmentLayoutHolder
class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDialog.YesNoDialogListener {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(TrackFragment::class.java)
/* Main class variables */
private lateinit var layout:TrackFragmentLayoutHolder
/* Overrides onCreateView from Fragment */
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
// initialize layout
layout = TrackFragmentLayoutHolder(activity as Context, inflater, container, arguments)
// set up share button
layout.shareButton.setOnClickListener {
shareGpXTrack()
}
// set up delete button
layout.deleteButton.setOnClickListener {
val dialogMessage: String = "${getString(R.string.dialog_yes_no_message_remove_recording)}\n\n- ${layout.trackNameView.text}"
YesNoDialog(this@TrackFragment as YesNoDialog.YesNoDialogListener).show(context = activity as Context, type = Keys.DIALOG_REMOVE_TRACK, messageString = dialogMessage, yesButton = R.string.dialog_yes_no_positive_button_remove_recording)
}
// set up rename button
layout.editButton.setOnClickListener {
RenameTrackDialog(this as RenameTrackDialog.RenameTrackListener).show(activity as Context, layout.trackNameView.text.toString())
}
return layout.rootView
}
/* Overrides onResume from Fragment */
override fun onResume() {
super.onResume()
// update zoom level and map center
layout.updateMapView()
}
/* Overrides onPause from Fragment */
override fun onPause() {
super.onPause()
// save zoom level and map center
layout.saveViewStateToTrack()
}
/* Overrides onRenameTrackDialog from RenameTrackDialog */
override fun onRenameTrackDialog(textInput: String) {
// rename track async (= fire & forget - no return value needed)
GlobalScope.launch { FileHelper.renameTrackSuspended(activity as Context, layout.track, textInput) }
// update name in layout
layout.track.name = textInput
layout.trackNameView.text = textInput
}
/* Overrides onYesNoDialog from YesNoDialogListener */
override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) {
when (type) {
Keys.DIALOG_REMOVE_TRACK -> {
when (dialogResult) {
// user tapped remove track
true -> {
// switch to TracklistFragment and remove track there
val trackId: Long = arguments?.getLong(ARG_TRACK_ID, -1L) ?: -1L
val bundle: Bundle = bundleOf(Keys.ARG_TRACK_ID to trackId)
findNavController().navigate(R.id.tracklist_fragment, bundle)
}
}
}
}
}
/* Share track as GPX via share sheet */
private fun shareGpXTrack() {
val gpxFile = Uri.parse(layout.track.gpxUriString).toFile()
val gpxShareUri = FileProvider.getUriForFile(this.activity as Context, "${activity!!.applicationContext.packageName}.provider", gpxFile)
val shareIntent: Intent = Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
data = gpxShareUri
type = "application/gpx+xml"
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
putExtra(Intent.EXTRA_STREAM, gpxShareUri)
putExtra(Intent.EXTRA_TITLE, getString(R.string.dialog_share_gpx))
}, null)
// show share sheet - if file helper is available
val packageManager: PackageManager? = activity?.packageManager
if (packageManager != null && shareIntent.resolveActivity(packageManager) != null) {
startActivity(shareIntent)
} else {
Toast.makeText(activity, R.string.toast_message_install_file_helper, Toast.LENGTH_LONG).show()
}
}
}

View File

@ -1,59 +0,0 @@
/**
* Trackbook.java
* Implements the Trackbook class
* Trackbook starts up the app and sets up the basic theme (Day / Night)
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook;
import android.app.Application;
import org.y20k.trackbook.helpers.LogHelper;
import org.y20k.trackbook.helpers.NightModeHelper;
/**
* Trackbook.class
*/
public class Trackbook extends Application {
/* Define log tag */
private static final String LOG_TAG = Trackbook.class.getSimpleName();
@Override
public void onCreate() {
super.onCreate();
// set Day / Night theme state
NightModeHelper.restoreSavedState(this);
// todo remove
// if (Build.VERSION.SDK_INT >= 28) {
// // Android P might introduce a system wide theme option - in that case: follow system (28 = Build.VERSION_CODES.P)
// AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
// } else {
// // try to get last state the user chose
// NightModeHelper.restoreSavedState(this);
// }
}
@Override
public void onTerminate() {
super.onTerminate();
LogHelper.v(LOG_TAG, "Trackbook application terminated.");
}
}

View File

@ -0,0 +1,51 @@
/*
* Trackbook.kt
* Implements the Trackbook class
* Trackbook is the base Application class that sets up day and night theme
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook
import android.app.Application
import org.y20k.trackbook.helpers.LogHelper
import org.y20k.trackbook.helpers.NightModeHelper
/*
* Trackbook.class
*/
class Trackbook: Application() {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(Trackbook::class.java)
/* Implements onCreate */
override fun onCreate() {
super.onCreate()
LogHelper.v(TAG, "Trackbook application started.")
// set Day / Night theme state
NightModeHelper.restoreSavedState(this)
}
/* Implements onTerminate */
override fun onTerminate() {
super.onTerminate()
LogHelper.v(TAG, "Trackbook application terminated.")
}
}

View File

@ -1,618 +0,0 @@
/**
* TrackerService.java
* Implements the app's movement tracker service
* The TrackerService creates a Track object and displays a notification
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.ContentObserver;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.Bundle;
import android.os.CountDownTimer;
import android.os.Handler;
import android.os.IBinder;
import android.preference.PreferenceManager;
import android.widget.Toast;
import androidx.core.app.NotificationCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.y20k.trackbook.core.Track;
import org.y20k.trackbook.helpers.LocationHelper;
import org.y20k.trackbook.helpers.LogHelper;
import org.y20k.trackbook.helpers.NotificationHelper;
import org.y20k.trackbook.helpers.StorageHelper;
import org.y20k.trackbook.helpers.TrackbookKeys;
import java.util.List;
import static android.hardware.Sensor.TYPE_STEP_COUNTER;
/**
* TrackerService class
*/
public class TrackerService extends Service implements TrackbookKeys, SensorEventListener {
/* Define log tag */
private static final String LOG_TAG = TrackerService.class.getSimpleName();
/* Main class variables */
private Track mTrack;
private CountDownTimer mTimer;
private LocationManager mLocationManager;
private SensorManager mSensorManager;
private float mStepCountOffset;
private LocationListener mGPSListener = null;
private LocationListener mNetworkListener = null;
private SettingsContentObserver mSettingsContentObserver;
private Location mCurrentBestLocation;
private Notification mNotification;
private NotificationCompat.Builder mNotificationBuilder;
private NotificationManager mNotificationManager;
private boolean mTrackerServiceRunning;
private boolean mLocationSystemSetting;
private boolean mResumedFlag;
private final IBinder mBinder = new LocalBinder(); // todo move to onCreate
@Override
public void onCreate() {
super.onCreate();
// prepare notification channel and get NotificationManager
mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
NotificationHelper.createNotificationChannel(this);
// acquire reference to Location Manager
mLocationManager = (LocationManager) this.getSystemService(Context.LOCATION_SERVICE);
// acquire reference to Sensor Manager
mSensorManager = (SensorManager) this.getSystemService(Context.SENSOR_SERVICE);
// get state of location system setting
mLocationSystemSetting = LocationHelper.checkLocationSystemSetting(getApplicationContext());
// create content observer for changes in System Settings
mSettingsContentObserver = new SettingsContentObserver(new Handler());
// initialize the resume flag
mResumedFlag = false;
}
@Override
public IBinder onBind(Intent intent) {
// a client is binding to the service with bindService()
return mBinder;
}
@Override
public boolean onUnbind(Intent intent) {
// return true if you would like to have the service's onRebind(Intent) method later called when new clients bind to it.
return true;
}
@Override
public void onRebind(Intent intent) {
// a client is binding to the service with bindService(), after onUnbind() has already been called
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// SERVICE RESTART (via START_STICKY)
if (intent == null) {
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(PREFS_TRACKER_SERVICE_RUNNING, false)) {
LogHelper.w(LOG_TAG, "Trackbook has been killed by the operating system. Trying to resume recording.");
resumeTracking(LocationHelper.determineLastKnownLocation(mLocationManager));
}
}
// ACTION STOP
else if (ACTION_STOP.equals(intent.getAction())) {
stopTracking();
}
// ACTION RESUME
else if (ACTION_RESUME.equals(intent.getAction())) {
resumeTracking(LocationHelper.determineLastKnownLocation(mLocationManager));
}
// START_STICKY is used for services that are explicitly started and stopped as needed
return START_STICKY;
}
@Override
public void onTaskRemoved(Intent rootIntent) {
super.onTaskRemoved(rootIntent);
LogHelper.v(LOG_TAG, "onTaskRemoved called.");
}
@Override
public void onDestroy() {
LogHelper.v(LOG_TAG, "onDestroy called.");
if (mTrackerServiceRunning) {
stopTracking();
}
// remove TrackerService from foreground state
stopForeground(true);
super.onDestroy();
}
@Override
public void onSensorChanged(SensorEvent sensorEvent) {
// save the step count offset (steps previously recorded by the system) and subtract any steps recorded during this session in case the app was killed
if (mStepCountOffset == 0) {
mStepCountOffset = (sensorEvent.values[0] - 1) - mTrack.getStepCount();
}
// calculate step count
float stepCount = sensorEvent.values[0] - mStepCountOffset;
// set step count in track
mTrack.setStepCount(stepCount);
}
@Override
public void onAccuracyChanged(Sensor sensor, int i) {
}
/* Start tracking location */
public void startTracking(Location lastLocation) {
if (mLocationSystemSetting) {
LogHelper.v(LOG_TAG, "Start tracking");
// create a new track - if requested
mTrack = new Track();
// get last location
if (lastLocation != null) {
mCurrentBestLocation = lastLocation;
} else {
mCurrentBestLocation = LocationHelper.determineLastKnownLocation(mLocationManager);
}
// begin recording
startMovementRecording();
} else {
LogHelper.i(LOG_TAG, "Location Setting is turned off.");
Toast.makeText(getApplicationContext(), R.string.toast_message_location_offline, Toast.LENGTH_LONG).show();
}
}
/* Resume tracking after stop/pause */
public void resumeTracking(Location lastLocation) {
if (mLocationSystemSetting) {
LogHelper.v(LOG_TAG, "Recording resumed");
// switch the resume flag
mResumedFlag = true;
// create a new track - if requested
StorageHelper storageHelper = new StorageHelper(this);
if (storageHelper.tempFileExists()) {
// load temp track file
mTrack = storageHelper.loadTrack(FILE_TEMP_TRACK);
// try to mark last waypoint as stopover
int lastWayPoint = mTrack.getSize() - 1;
if (lastWayPoint >= 0) {
mTrack.getWayPoints().get(lastWayPoint).setIsStopOver(true);
}
} else {
// fallback, if tempfile did not exist
LogHelper.e(LOG_TAG, "Unable to find previously saved track temp file.");
mTrack = new Track();
}
// get last location
mCurrentBestLocation = lastLocation;
// FALLBACK: use last recorded location
if (mCurrentBestLocation == null && mTrack.getSize() > 0) {
mCurrentBestLocation = mTrack.getWayPointLocation(mTrack.getSize() -1);
}
// begin recording
startMovementRecording();
} else {
LogHelper.i(LOG_TAG, "Location Setting is turned off.");
Toast.makeText(getApplicationContext(), R.string.toast_message_location_offline, Toast.LENGTH_LONG).show();
}
}
/* Stop tracking location */
public void stopTracking() {
LogHelper.v(LOG_TAG, "Recording stopped");
// catches a bug that leaves the ui in a incorrect state after a crash
if (!mTrackerServiceRunning) {
saveTrackerServiceState(mTrackerServiceRunning, FAB_STATE_SAVE);
broadcastTrackingStateChange();
return;
}
// store current date and time
mTrack.setRecordingEnd();
// stop timer
mTimer.cancel();
// broadcast an updated track
broadcastTrackUpdate();
// save a temp file in case the activity has been killed
SaveTempTrackAsyncHelper saveTempTrackAsyncHelper = new SaveTempTrackAsyncHelper();
saveTempTrackAsyncHelper.execute();
// change notification
displayNotification(false);
// reset resume flag
mResumedFlag = false;
// remove listeners
stopFindingLocation();
mSensorManager.unregisterListener(this);
// disable content observer for changes in System Settings
this.getContentResolver().unregisterContentObserver(mSettingsContentObserver);
// remove TrackerService from foreground state
stopForeground(false);
}
/* Dismiss notification */
public void dismissNotification() {
// save state
saveTrackerServiceState(mTrackerServiceRunning, FAB_STATE_DEFAULT);
// cancel notification
mNotificationManager.cancel(TRACKER_SERVICE_NOTIFICATION_ID); // todo check if necessary?
stopForeground(true);
}
/* Starts to record movements */
private void startMovementRecording() {
// initialize step counter
mStepCountOffset = 0;
// add last location as WayPoint to track
addWayPointToTrack();
// put up notification
displayNotification(true);
// create gps and network location listeners
startFindingLocation();
// start timer that periodically request a location update
startRequestingLocationChanges();
// start counting steps
startStepCounter();
// register content observer for changes in System Settings
this.getContentResolver().registerContentObserver(android.provider.Settings.Secure.CONTENT_URI, true, mSettingsContentObserver);
// start service in foreground
startForeground(TRACKER_SERVICE_NOTIFICATION_ID, mNotification);
}
/* Registers a step counter listener */
private void startStepCounter() {
boolean stepCounterAvailable = mSensorManager.registerListener(this, mSensorManager.getDefaultSensor(TYPE_STEP_COUNTER), SensorManager.SENSOR_DELAY_UI);
if (stepCounterAvailable) {
LogHelper.v(LOG_TAG, "Pedometer sensor available: Registering listener.");
} else {
LogHelper.i(LOG_TAG, "Pedometer sensor not available.");
mTrack.setStepCount(-1);
}
}
/* Set timer to periodically retrieve new locations and to prevent endless tracking */
private void startRequestingLocationChanges() {
final long previouslyRecordedDuration = mTrack.getTrackDuration();
mTimer = new CountDownTimer(EIGHT_HOURS_IN_MILLISECONDS, FIFTEEN_SECONDS_IN_MILLISECONDS) {
@Override
public void onTick(long millisUntilFinished) {
// update track duration - and add duration from previously interrupted / paused session
long duration = EIGHT_HOURS_IN_MILLISECONDS - millisUntilFinished + previouslyRecordedDuration;
mTrack.setDuration(duration);
// try to add WayPoint to Track
addWayPointToTrack();
// update notification
mNotification = NotificationHelper.getUpdatedNotification(TrackerService.this, mNotificationBuilder, mTrack);
mNotificationManager.notify(TRACKER_SERVICE_NOTIFICATION_ID, mNotification);
// save a temp file in case the service has been killed by the system
SaveTempTrackAsyncHelper saveTempTrackAsyncHelper = new SaveTempTrackAsyncHelper();
saveTempTrackAsyncHelper.execute();
}
@Override
public void onFinish() {
// stop tracking after eight hours
stopTracking();
}
};
mTimer.start();
}
/* Display notification */
private void displayNotification(boolean trackingState) {
mNotificationBuilder = new NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID_RECORDING_CHANNEL);
mNotification = NotificationHelper.getNotification(this, mNotificationBuilder, mTrack, trackingState);
mNotificationManager.notify(TRACKER_SERVICE_NOTIFICATION_ID, mNotification); // todo check if necessary in pre Android O
}
/* Adds a new WayPoint to current track */
private void addWayPointToTrack() {
boolean success = false;
Location previousLocation = null;
int trackSize = mTrack.getSize();
if (trackSize == 0) {
// if accurate AND current
if (LocationHelper.isAccurate(mCurrentBestLocation) && LocationHelper.isCurrent(mCurrentBestLocation)) {
// add first location to track
success = mTrack.addWayPoint(previousLocation, mCurrentBestLocation);
} else {
// just send a broadcast indicating that current location fix not not suited
broadcastTrackUpdate();
}
} else {
// get location of previous WayPoint
previousLocation = mTrack.getWayPointLocation(trackSize - 1);
// default value for average speed
float averageSpeed = 0f;
// compute average speed if new location came from network provider
if (trackSize > 1 && LocationManager.NETWORK_PROVIDER.equals(mCurrentBestLocation.getProvider())) {
Location firstWayPoint = mTrack.getWayPointLocation(0);
float distance = firstWayPoint.distanceTo(previousLocation);
long timeDifference = previousLocation.getElapsedRealtimeNanos() - firstWayPoint.getElapsedRealtimeNanos();
averageSpeed = distance / ((float) timeDifference / ONE_SECOND_IN_NANOSECOND);
}
// if accurate AND new
if (LocationHelper.isAccurate(mCurrentBestLocation) && LocationHelper.isNewWayPoint(previousLocation, mCurrentBestLocation, averageSpeed)) {
// add current best location to track
success = mTrack.addWayPoint(previousLocation, mCurrentBestLocation);
}
}
if (success) {
if (mResumedFlag) {
int lastWayPoint = mTrack.getSize() - 2;
if (lastWayPoint >= 0) {
// mark last location as stop over
mTrack.getWayPoints().get(lastWayPoint).setIsStopOver(true);
}
mResumedFlag = false;
} else {
// update distance, if not resumed
mTrack.updateDistance(previousLocation, mCurrentBestLocation);
}
// send local broadcast if new WayPoint was added
broadcastTrackUpdate();
}
}
/* Broadcasts a track update */
private void broadcastTrackUpdate() {
if (mTrack != null) {
Intent i = new Intent();
i.setAction(ACTION_TRACK_UPDATED);
i.putExtra(EXTRA_TRACK, mTrack);
i.putExtra(EXTRA_LAST_LOCATION, mCurrentBestLocation);
LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(i);
}
}
/* Creates a location listener */
private LocationListener createLocationListener() {
return new LocationListener() {
public void onLocationChanged(Location location) {
// check if the new location is better
if (LocationHelper.isBetterLocation(location, mCurrentBestLocation)) {
// save location
mCurrentBestLocation = location;
}
}
public void onStatusChanged(String provider, int status, Bundle extras) {
LogHelper.v(LOG_TAG, "Location provider status change: " + provider + " | " + status);
}
public void onProviderEnabled(String provider) {
LogHelper.v(LOG_TAG, "Location provider enabled: " + provider);
}
public void onProviderDisabled(String provider) {
LogHelper.v(LOG_TAG, "Location provider disabled: " + provider);
}
};
}
/* Creates gps and network location listeners */
private void startFindingLocation() {
// register location listeners and request updates
List locationProviders = mLocationManager.getAllProviders();
if (locationProviders.contains(LocationManager.GPS_PROVIDER)) {
mGPSListener = createLocationListener();
mTrackerServiceRunning = true;
}
if (locationProviders.contains(LocationManager.NETWORK_PROVIDER)) {
mNetworkListener = createLocationListener();
mTrackerServiceRunning = true;
}
LocationHelper.registerLocationListeners(mLocationManager, mGPSListener, mNetworkListener);
saveTrackerServiceState(mTrackerServiceRunning, FAB_STATE_RECORDING);
// notify MainActivity
broadcastTrackingStateChange();
}
/* Removes gps and network location listeners */
private void stopFindingLocation() {
// remove listeners
LocationHelper.removeLocationListeners(mLocationManager, mGPSListener, mNetworkListener);
mTrackerServiceRunning = false;
saveTrackerServiceState(mTrackerServiceRunning, FAB_STATE_SAVE);
// notify MainActivity
broadcastTrackingStateChange();
}
/* Sends a broadcast with tracking changed */
private void broadcastTrackingStateChange() {
Intent i = new Intent();
i.setAction(ACTION_TRACKING_STATE_CHANGED);
i.putExtra(EXTRA_TRACK, mTrack);
i.putExtra(EXTRA_LAST_LOCATION, mCurrentBestLocation);
i.putExtra(EXTRA_TRACKING_STATE, mTrackerServiceRunning);
LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(i);
}
/* Saves state of Tracker Service and floating Action Button */
private void saveTrackerServiceState(boolean trackerServiceRunning, int fabState) {
SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
SharedPreferences.Editor editor = settings.edit();
editor.putBoolean(PREFS_TRACKER_SERVICE_RUNNING, trackerServiceRunning);
editor.putInt(PREFS_FAB_STATE, fabState);
editor.apply();
}
/**
* Inner class: Local Binder that returns this service
*/
public class LocalBinder extends Binder {
TrackerService getService() {
// return this instance of TrackerService so clients can call public methods
return TrackerService.this;
}
}
/**
* End of inner class
*/
/**
* Inner class: SettingsContentObserver is a custom ContentObserver for changes in Android Settings
*/
public class SettingsContentObserver extends ContentObserver {
public SettingsContentObserver(Handler handler) {
super(handler);
}
@Override
public boolean deliverSelfNotifications() {
return super.deliverSelfNotifications();
}
@Override
public void onChange(boolean selfChange) {
super.onChange(selfChange);
LogHelper.v(LOG_TAG, "System Setting change detected.");
// check if location setting was changed
boolean previousLocationSystemSetting = mLocationSystemSetting;
mLocationSystemSetting = LocationHelper.checkLocationSystemSetting(getApplicationContext());
if (previousLocationSystemSetting != mLocationSystemSetting && !mLocationSystemSetting && mTrackerServiceRunning) {
LogHelper.v(LOG_TAG, "Location Setting turned off while tracking service running.");
if (mTrack != null) {
stopTracking();
}
stopForeground(true);
}
}
}
/**
* End of inner class
*/
/**
* Inner class: Saves track to external storage using AsyncTask
*/
private class SaveTempTrackAsyncHelper extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... voids) {
LogHelper.v(LOG_TAG, "Saving temporary track object in background.");
// save track object
StorageHelper storageHelper = new StorageHelper(TrackerService.this);
storageHelper.saveTrack(mTrack, FILE_TEMP_TRACK);
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
super.onPostExecute(aVoid);
LogHelper.v(LOG_TAG, "Saving finished.");
}
}
/**
* End of inner class
*/
}

View File

@ -0,0 +1,356 @@
/*
* TrackerService.kt
* Implements the app's movement tracker service
* The TrackerService keeps track of the current location
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook
import android.Manifest
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Binder
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import kotlinx.coroutines.*
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.helpers.*
import java.util.*
import kotlin.coroutines.CoroutineContext
/*
* TrackerService class
*/
class TrackerService(): Service(), CoroutineScope, SensorEventListener {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(TrackerService::class.java)
/* Main class variables */
var trackingState: Int = Keys.STATE_NOT_TRACKING
var gpsProviderActive: Boolean = false
var networkProviderActive: Boolean = false
var useImperial: Boolean = false
var gpsOnly: Boolean = false
var locationAccuracyThreshold: Int = Keys.DEFAULT_THRESHOLD_LOCATION_ACCURACY
var currentBestLocation: Location = LocationHelper.getDefaultLocation()
var stepCountOffset: Float = 0f
var track: Track = Track()
private val binder = LocalBinder()
private val handler: Handler = Handler()
private lateinit var locationManager: LocationManager
private lateinit var sensorManager: SensorManager
private lateinit var notificationManager: NotificationManager
private lateinit var notificationHelper: NotificationHelper
private lateinit var gpsLocationListener: LocationListener
private lateinit var networkLocationListener: LocationListener
private lateinit var backgroundJob: Job
/* Overrides coroutineContext variable */
override val coroutineContext: CoroutineContext get() = backgroundJob + Dispatchers.Main
/* Overrides onCreate from Service */
override fun onCreate() {
super.onCreate()
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationHelper = NotificationHelper(this)
gpsProviderActive = LocationHelper.isGpsEnabled(locationManager)
networkProviderActive = LocationHelper.isNetworkEnabled(locationManager)
gpsLocationListener = createLocationListener()
networkLocationListener = createLocationListener()
useImperial = PreferencesHelper.loadUseImperialUnits(this)
locationAccuracyThreshold = PreferencesHelper.loadAccuracyThreshold(this)
trackingState = PreferencesHelper.loadTrackingState(this)
currentBestLocation = LocationHelper.getLastKnownLocation(this)
track = FileHelper.readTrack(this, FileHelper.getTempFileUri(this))
backgroundJob = Job()
PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
}
/* Overrides onStartCommand from Service */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// SERVICE RESTART (via START_STICKY)
if (intent == null) {
if (trackingState == Keys.STATE_TRACKING_ACTIVE) {
LogHelper.w(TAG, "Trackbook has been killed by the operating system. Trying to resume recording.")
resumeTracking()
}
// ACTION STOP
} else if (Keys.ACTION_STOP == intent.action) {
stopTracking()
// ACTION START
} else if (Keys.ACTION_START == intent.action) {
startTracking()
// ACTION RESUME
} else if (Keys.ACTION_RESUME == intent.action) {
resumeTracking()
}
// START_STICKY is used for services that are explicitly started and stopped as needed
return Service.START_STICKY
}
/* Overrides onBind from Service */
override fun onBind(p0: Intent?): IBinder? {
addLocationListeners()
return binder
}
/* Overrides onDestroy from Service */
override fun onDestroy() {
super.onDestroy()
LogHelper.i(TAG, "onDestroy called.")
if (trackingState == Keys.STATE_TRACKING_ACTIVE) stopTracking()
stopForeground(true)
PreferenceManager.getDefaultSharedPreferences(this).unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
removeLocationListeners()
backgroundJob.cancel()
}
/* Overrides onAccuracyChanged from SensorEventListener */
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
LogHelper.v(TAG, "Accuracy changed: $accuracy")
}
/* Overrides onSensorChanged from SensorEventListener */
override fun onSensorChanged(sensorEvent: SensorEvent?) {
var steps: Float = 0f
if (sensorEvent != null) {
if (stepCountOffset == 0f) {
// store steps previously recorded by the system
stepCountOffset = (sensorEvent.values[0] - 1) - track.stepCount // subtract any steps recorded during this session in case the app was killed
}
// calculate step count - subtract steps previously recorded
steps = sensorEvent.values[0] - stepCountOffset
}
// update step count in track
track.stepCount = steps
}
/* Resume tracking after stop/pause */
fun resumeTracking() {
// load temp track - returns an empty track if not available
track = FileHelper.readTrack(this, FileHelper.getTempFileUri(this))
// try to mark last waypoint as stopover
if (track.wayPoints.size > 0) {
val lastWayPointIndex = track.wayPoints.size - 1
track.wayPoints.get(lastWayPointIndex).isStopOver = true
}
// start tracking
startTracking(newTrack = false)
}
/* Start tracking location */
fun startTracking(newTrack: Boolean = true) {
if (newTrack) {
track.recordingStart = GregorianCalendar.getInstance().time
track.recordingStop = track.recordingStart
track.name = DateTimeHelper.convertToReadableDate(track.recordingStart)
stepCountOffset = 0f
}
trackingState = Keys.STATE_TRACKING_ACTIVE
PreferencesHelper.saveTrackingState(this, trackingState)
startStepCounter()
handler.postDelayed(periodicTrackUpdate, 0)
startForeground(Keys.TRACKER_SERVICE_NOTIFICATION_ID, displayNotification())
}
/* Stop tracking location */
fun stopTracking() {
track.recordingStop = GregorianCalendar.getInstance().time
trackingState = Keys.STATE_TRACKING_STOPPED
PreferencesHelper.saveTrackingState(this, trackingState)
sensorManager.unregisterListener(this)
handler.removeCallbacks(periodicTrackUpdate)
displayNotification()
stopForeground(false)
}
/* Clear track recording */
fun clearTrack() {
track = Track()
FileHelper.deleteTempFile(this)
trackingState = Keys.STATE_NOT_TRACKING
PreferencesHelper.saveTrackingState(this, trackingState)
stopForeground(true)
}
/* Saves track recording to storage */
fun saveTrack() {
// save track using "deferred await"
launch {
// step 1: create and store filenames for json and gpx files
track.trackUriString = FileHelper.getTrackFileUri(this@TrackerService, track).toString()
track.gpxUriString = FileHelper.getGpxFileUri(this@TrackerService, track).toString()
// step 2: save track
FileHelper.saveTrackSuspended(track, saveGpxToo = true)
// step 3: save tracklist
FileHelper.addTrackAndSaveTracklistSuspended(this@TrackerService, track)
// step 3: clear track
clearTrack()
}
}
/* Creates location listener */
private fun createLocationListener(): LocationListener {
return object : LocationListener {
override fun onLocationChanged(location: Location) {
// update currentBestLocation if a better location is available
if (LocationHelper.isBetterLocation(location, currentBestLocation)) {
currentBestLocation = location
}
}
override fun onProviderEnabled(provider: String) {
LogHelper.v(TAG, "onProviderEnabled $provider")
when (provider) {
LocationManager.GPS_PROVIDER -> gpsProviderActive = LocationHelper.isGpsEnabled(locationManager)
LocationManager.NETWORK_PROVIDER -> networkProviderActive = LocationHelper.isNetworkEnabled(locationManager)
}
}
override fun onProviderDisabled(provider: String) {
LogHelper.v(TAG, "onProviderDisabled $provider")
when (provider) {
LocationManager.GPS_PROVIDER -> gpsProviderActive = LocationHelper.isGpsEnabled(locationManager)
LocationManager.NETWORK_PROVIDER -> networkProviderActive = LocationHelper.isNetworkEnabled(locationManager)
}
}
override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?) {
// deprecated method
}
}
}
/* Adds location listeners to location manager */
private fun addLocationListeners() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
if (gpsProviderActive) {
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0f,gpsLocationListener)
}
if (networkProviderActive) {
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0f,networkLocationListener)
}
} else {
LogHelper.w(TAG, "Unable to request device location. Permission is not granted.")
}
}
/* Removes location listeners from location manager */
private fun removeLocationListeners() {
locationManager.removeUpdates(gpsLocationListener)
locationManager.removeUpdates(networkLocationListener)
}
/* Registers a step counter listener */
private fun startStepCounter() {
val stepCounterAvailable = sensorManager.registerListener(this, sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER), SensorManager.SENSOR_DELAY_UI)
if (!stepCounterAvailable) {
LogHelper.w(TAG, "Pedometer sensor not available.")
track.stepCount = -1f
}
}
/* Displays / updates notification */
private fun displayNotification(): Notification {
val notification: Notification = notificationHelper.createNotification(trackingState, track.length, track.duration, useImperial)
notificationManager.notify(Keys.TRACKER_SERVICE_NOTIFICATION_ID, notification)
return notification
}
/*
* Defines the listener for changes in shared preferences
*/
val sharedPreferenceChangeListener = object : SharedPreferences.OnSharedPreferenceChangeListener {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
Keys.PREF_GPS_ONLY -> gpsOnly = PreferencesHelper.loadGpsOnly(this@TrackerService)
Keys.PREF_USE_IMPERIAL_UNITS -> useImperial = PreferencesHelper.loadUseImperialUnits(this@TrackerService)
Keys.PREF_LOCATION_ACCURACY_THRESHOLD -> locationAccuracyThreshold = PreferencesHelper.loadAccuracyThreshold(this@TrackerService)
}
}
}
/*
* End of declaration
*/
/*
* Inner class: Local Binder that returns this service
*/
inner class LocalBinder : Binder() {
val service: TrackerService = this@TrackerService
}
/*
* End of inner class
*/
/*
* Runnable: Periodically track updates (if recording active)
*/
private val periodicTrackUpdate: Runnable = object : Runnable {
override fun run() {
// add waypoint to track - step count is continuously updated in onSensorChanged
track = TrackHelper.addWayPointToTrack(track, currentBestLocation, locationAccuracyThreshold)
// update notification
displayNotification()
// save temp track using GlobalScope.launch = fire & forget (no return value from save)
GlobalScope.launch { FileHelper.saveTempTrackSuspended(this@TrackerService, track) }
// re-run this in 10 seconds
handler.postDelayed(this, Keys.ADD_WAYPOINT_TO_TRACK_INTERVAL)
}
}
/*
* End of declaration
*/
}

View File

@ -0,0 +1,161 @@
/*
* TracklistFragment.kt
* Implements the TracklistFragment fragment
* A TracklistFragment displays a list recorded tracks
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook
import YesNoDialog
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.y20k.escapepod.helpers.UiHelper
import org.y20k.trackbook.core.TracklistElement
import org.y20k.trackbook.helpers.LogHelper
import org.y20k.trackbook.helpers.TrackHelper
import org.y20k.trackbook.tracklist.TracklistAdapter
/*
* TracklistFragment class
*/
class TracklistFragment : Fragment(), TracklistAdapter.TracklistAdapterListener, YesNoDialog.YesNoDialogListener {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(TracklistFragment::class.java)
/* Main class variables */
private lateinit var tracklistAdapter: TracklistAdapter
private lateinit var trackElementList: RecyclerView
private lateinit var tracklistOnboarding: ConstraintLayout
/* Overrides onCreateView from Fragment */
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// create tracklist adapter
tracklistAdapter = TracklistAdapter(this)
}
/* Overrides onCreateView from Fragment */
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
// find views
val rootView = inflater.inflate(R.layout.fragment_tracklist, container, false)
trackElementList = rootView.findViewById(R.id.track_element_list)
tracklistOnboarding = rootView.findViewById(R.id.track_list_onboarding)
// set up recycler view
trackElementList.layoutManager = CustomLinearLayoutManager(activity as Context)
trackElementList.itemAnimator = DefaultItemAnimator()
trackElementList.adapter = tracklistAdapter
// enable swipe to delete
val swipeHandler = object : UiHelper.SwipeToDeleteCallback(activity as Context) {
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// ask user
val adapterPosition: Int = viewHolder.adapterPosition
val dialogMessage: String = "${getString(R.string.dialog_yes_no_message_remove_recording)}\n\n- ${tracklistAdapter.getTrackName(adapterPosition)}"
YesNoDialog(this@TracklistFragment as YesNoDialog.YesNoDialogListener).show(context = activity as Context, type = Keys.DIALOG_REMOVE_TRACK, messageString = dialogMessage, yesButton = R.string.dialog_yes_no_positive_button_remove_recording, payload = adapterPosition)
}
}
val itemTouchHelper = ItemTouchHelper(swipeHandler)
itemTouchHelper.attachToRecyclerView(rootView.findViewById(R.id.track_element_list))
// toggle onboarding layout
toggleOnboardingLayout(tracklistAdapter.itemCount)
return rootView
}
/* Overrides onTrackElementTapped from TracklistElementAdapterListener */
override fun onTrackElementTapped(tracklistElement: TracklistElement) {
val bundle: Bundle = Bundle()
bundle.putString(Keys.ARG_TRACK_TITLE, tracklistElement.name)
bundle.putString(Keys.ARG_TRACK_FILE_URI, tracklistElement.trackUriString)
bundle.putString(Keys.ARG_GPX_FILE_URI, tracklistElement.gpxUriString)
bundle.putLong(Keys.ARG_TRACK_ID, TrackHelper.getTrackId(tracklistElement))
findNavController().navigate(R.id.fragment_track, bundle)
}
/* Overrides onYesNoDialog from YesNoDialogListener */
override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) {
when (type) {
Keys.DIALOG_REMOVE_TRACK -> {
when (dialogResult) {
// user tapped remove track
true -> {
toggleOnboardingLayout(tracklistAdapter.itemCount -1)
tracklistAdapter.removeTrack(activity as Context, payload)
}
// user tapped cancel
false -> {
tracklistAdapter.notifyItemChanged(payload)
}
}
}
}
}
// toggle onboarding layout
private fun toggleOnboardingLayout(trackCount: Int) {
when (trackCount == 0) {
true -> tracklistOnboarding.visibility = View.VISIBLE // show onboarding layout
false -> tracklistOnboarding.visibility = View.GONE // hide onboarding layout
}
}
/*
* Inner class: custom LinearLayoutManager that overrides onLayoutCompleted
*/
inner class CustomLinearLayoutManager(context: Context): LinearLayoutManager(context, VERTICAL, false) {
override fun supportsPredictiveItemAnimations(): Boolean {
return true
}
override fun onLayoutCompleted(state: RecyclerView.State?) {
super.onLayoutCompleted(state)
// handle delete request from TrackFragment - after layout calculations are complete
val deleteTrackId: Long = arguments?.getLong(Keys.ARG_TRACK_ID, -1L) ?: -1L
arguments?.putLong(Keys.ARG_TRACK_ID, -1L)
if (deleteTrackId != -1L) {
val position: Int = tracklistAdapter.findPosition(deleteTrackId)
tracklistAdapter.removeTrack(this@TracklistFragment.activity as Context, position)
toggleOnboardingLayout(tracklistAdapter.itemCount -1)
}
}
}
/*
* End of inner class
*/
}

View File

@ -1,337 +0,0 @@
/**
* Track.java
* Implements the Track class
* A Track stores a list of WayPoints
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.core;
import android.location.Location;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.Nullable;
import org.osmdroid.util.BoundingBox;
import org.y20k.trackbook.helpers.LocationHelper;
import org.y20k.trackbook.helpers.TrackbookKeys;
import java.util.ArrayList;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
/**
* Track class
*/
public class Track implements TrackbookKeys, Parcelable {
/* Define log tag */
private static final String LOG_TAG = Track.class.getSimpleName();
/* Main class variables */
private final int mTrackFormatVersion;
private final List<WayPoint> mWayPoints;
private float mTrackLength;
private long mDuration;
private float mStepCount;
private final Date mRecordingStart;
private Date mRecordingStop;
private double mMaxAltitude;
private double mMinAltitude;
private double mPositiveElevation;
private double mNegativeElevation;
private BoundingBox mBoundingBox;
/* Generic Constructor */
public Track(int trackFormatVersion, List<WayPoint> wayPoints, float trackLength, long duration, float stepCount, Date recordingStart, Date recordingStop, double maxAltitude, double minAltitude, double positiveElevation, double negativeElevation, BoundingBox boundingBox) {
mTrackFormatVersion = trackFormatVersion;
mWayPoints = wayPoints;
mTrackLength = trackLength;
mDuration = duration;
mStepCount = stepCount;
mRecordingStart = recordingStart;
mRecordingStop = recordingStop;
mMaxAltitude = maxAltitude;
mMinAltitude = minAltitude;
mPositiveElevation = positiveElevation;
mNegativeElevation = negativeElevation;
mBoundingBox = boundingBox;
}
/* Copy Constructor */
public Track(Track track) {
this(track.getTrackFormatVersion(), track.getWayPoints(), track.getTrackLength(), track.getTrackDuration(), track.getStepCount(), track.getRecordingStart(), track.getRecordingStop(), track.getMaxAltitude(), track.getMinAltitude(), track.getPositiveElevation(), track.getNegativeElevation(), track.getBoundingBox());
}
/* Constructor */
public Track() {
mTrackFormatVersion = CURRENT_TRACK_FORMAT_VERSION;
mWayPoints = new ArrayList<WayPoint>();
mTrackLength = 0f;
mDuration = 0;
mStepCount = 0f;
mRecordingStart = GregorianCalendar.getInstance().getTime();
mRecordingStop = mRecordingStart;
mMaxAltitude = 0f;
mMinAltitude = 0f;
mPositiveElevation = 0f;
mNegativeElevation = 0f;
mBoundingBox = new BoundingBox();
}
/* Constructor used by CREATOR */
protected Track(Parcel in) {
mTrackFormatVersion = in.readInt();
mWayPoints = in.createTypedArrayList(WayPoint.CREATOR);
mTrackLength = in.readFloat();
mDuration = in.readLong();
mStepCount = in.readFloat();
mRecordingStart = new Date(in.readLong());
mRecordingStop = new Date(in.readLong());
mMaxAltitude = in.readDouble();
mMinAltitude = in.readDouble();
mPositiveElevation = in.readDouble();
mNegativeElevation = in.readDouble();
mBoundingBox = new BoundingBox(in.readDouble(), in.readDouble(),in.readDouble(),in.readDouble());
// BoundingBox(double north, double east, double south, double west)
}
/* CREATOR for Track object used to do parcel related operations */
public static final Creator<Track> CREATOR = new Creator<Track>() {
@Override
public Track createFromParcel(Parcel in) {
return new Track(in);
}
@Override
public Track[] newArray(int size) {
return new Track[size];
}
};
/* Adds new WayPoint */
public boolean addWayPoint(@Nullable Location previousLocation, Location newLocation) {
// toggle stop over status, if necessary
boolean isStopOver = LocationHelper.isStopOver(previousLocation, newLocation);
if (isStopOver) {
int wayPointCount = mWayPoints.size();
mWayPoints.get(wayPointCount-1).setIsStopOver(isStopOver);
}
// create new WayPoint
WayPoint wayPoint = new WayPoint(newLocation, false, mTrackLength);
// add new WayPoint to track
return mWayPoints.add(wayPoint);
}
/* Updates distance */
public boolean updateDistance(@Nullable Location previousLocation, Location newLocation){
// two data points needed to calculate distance
if (previousLocation != null) {
// add up distance
mTrackLength = mTrackLength + previousLocation.distanceTo(newLocation);
return true;
} else {
// this was the first waypoint
return false;
}
}
/* Toggles stop over status of last waypoint */
public void toggleLastWayPointStopOverStatus(boolean stopOver) {
int wayPointCount = mWayPoints.size();
mWayPoints.get(wayPointCount-1).setIsStopOver(stopOver);
}
/* Sets end time and date of recording */
public void setRecordingEnd() {
mRecordingStop = GregorianCalendar.getInstance().getTime();
}
/* Setter for duration of track */
public void setDuration(long duration) {
mDuration = duration;
}
/* Setter for step count of track */
public void setStepCount(float stepCount) {
mStepCount = stepCount;
}
/* Setter for maximum altitude of recording */
public void setMaxAltitude(double maxAltitude) {
mMaxAltitude = maxAltitude;
}
/* Setter for lowest altitude of recording */
public void setMinAltitude(double minAltitude) {
mMinAltitude = minAltitude;
}
/* Setter for positive elevation of recording (cumulative altitude difference) */
public void setPositiveElevation(double positiveElevation) {
mPositiveElevation = positiveElevation;
}
/* Setter for negative elevation of recording (cumulative altitude difference) */
public void setNegativeElevation(double negativeElevation) {
mNegativeElevation = negativeElevation;
}
/* Setter for this track's BoundingBox - a data structure describing the edge coordinates of a track */
public void setBoundingBox(BoundingBox boundingBox) {
mBoundingBox = boundingBox;
}
/* Getter for file/track format version */
public int getTrackFormatVersion() {
return mTrackFormatVersion;
}
/* Getter for mWayPoints */
public List<WayPoint> getWayPoints() {
return mWayPoints;
}
/* Getter size of Track / number of WayPoints */
public int getSize() {
return mWayPoints.size();
}
/* Getter for track length */
public float getTrackLength() {
return mTrackLength;
}
/* Getter for duration of track */
public long getTrackDuration() {
return mDuration;
}
/* Getter for start date of recording */
public Date getRecordingStart() {
return mRecordingStart;
}
/* Getter for stop date of recording */
public Date getRecordingStop() {
return mRecordingStop;
}
/* Getter for step count of recording */
public float getStepCount() {
return mStepCount;
}
/* Getter for maximum altitude of recording */
public double getMaxAltitude() {
return mMaxAltitude;
}
/* Getter for lowest altitude of recording */
public double getMinAltitude() {
return mMinAltitude;
}
/* Getter for positive elevation of recording (cumulative altitude difference) */
public double getPositiveElevation() {
return mPositiveElevation;
}
/* Getter for negative elevation of recording (cumulative altitude difference) */
public double getNegativeElevation() {
return mNegativeElevation;
}
/* Getter for this track's BoundingBox - a data structure describing the edge coordinates of a track */
public BoundingBox getBoundingBox() { return mBoundingBox; }
/* Getter recorded distance */
public Double getTrackDistance() {
int size = mWayPoints.size();
if (size > 0) {
return (double)mWayPoints.get(size - 1).getDistanceToStartingPoint();
} else {
return (double)0f;
}
}
/* Getter for location of specific WayPoint */
public Location getWayPointLocation(int index) {
return mWayPoints.get(index).getLocation();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel parcel, int flags) {
parcel.writeInt(mTrackFormatVersion);
parcel.writeTypedList(mWayPoints);
parcel.writeFloat(mTrackLength);
parcel.writeLong(mDuration);
parcel.writeFloat(mStepCount);
parcel.writeLong(mRecordingStart.getTime());
parcel.writeLong(mRecordingStop.getTime());
parcel.writeDouble(mMaxAltitude);
parcel.writeDouble(mMinAltitude);
parcel.writeDouble(mPositiveElevation);
parcel.writeDouble(mNegativeElevation);
parcel.writeDouble(mBoundingBox.getLatNorth());
parcel.writeDouble(mBoundingBox.getLonEast());
parcel.writeDouble(mBoundingBox.getLatSouth());
parcel.writeDouble(mBoundingBox.getLonWest());
// BoundingBox(double north, double east, double south, double west)
}
}

View File

@ -0,0 +1,77 @@
/*
* Track.kt
* Implements the Track data class
* A Track stores a list of WayPoints
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.core
import android.content.Context
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.Expose
import kotlinx.android.parcel.Parcelize
import org.y20k.trackbook.Keys
import org.y20k.trackbook.helpers.DateTimeHelper
import java.util.*
/*
* Track data class
*/
@Keep
@Parcelize
data class Track (@Expose var trackFormatVersion: Int = Keys.CURRENT_TRACK_FORMAT_VERSION,
@Expose val wayPoints: MutableList<WayPoint> = mutableListOf<WayPoint>(),
@Expose var length: Float = 0f,
@Expose var duration: Long = 0L,
@Expose var stepCount: Float = 0f,
@Expose var recordingStart: Date = GregorianCalendar.getInstance().time,
@Expose var recordingStop: Date = recordingStart,
@Expose var maxAltitude: Double = 0.0,
@Expose var minAltitude: Double = 0.0,
@Expose var positiveElevation: Double = 0.0,
@Expose var negativeElevation: Double = 0.0,
@Expose var trackUriString: String = String(),
@Expose var gpxUriString: String = String(),
@Expose var latitude: Double = Keys.DEFAULT_LATITUDE,
@Expose var longitude: Double = Keys.DEFAULT_LONGITUDE,
@Expose var zoomLevel: Double = Keys.DEFAULT_ZOOM_LEVEL,
@Expose var name: String = String()): Parcelable {
/* Creates a TracklistElement */
fun toTracklistElement(context: Context): TracklistElement {
val readableDateString: String = DateTimeHelper.convertToReadableDate(recordingStart)
val readableDurationString: String = DateTimeHelper.convertToReadableTime(context, duration)
return TracklistElement(
name = name,
date = recordingStart,
dateString = readableDateString,
length = length,
durationString = readableDurationString,
trackUriString = trackUriString,
gpxUriString = gpxUriString,
starred = false
)
}
/* Returns unique ID for Track - currently the start date */
fun getTrackId(): Long {
return recordingStart.time
}
}

View File

@ -1,87 +0,0 @@
/**
* TrackBuilder.java
* Implements a builder for the Track class
* A TrackBuilder can build a track object depending on the version of its file format
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.core;
import org.osmdroid.util.BoundingBox;
import org.y20k.trackbook.helpers.LogHelper;
import org.y20k.trackbook.helpers.MapHelper;
import java.util.Date;
import java.util.List;
/**
* TrackBuilder class
*/
public class TrackBuilder {
/* Define log tag */
private static final String LOG_TAG = TrackBuilder.class.getSimpleName();
/* Main class variables */
private final int mTrackFormatVersion;
private final List<WayPoint> mWayPoints;
private final float mTrackLength;
private final long mDuration;
private final float mStepCount;
private final Date mRecordingStart;
private final Date mRecordingStop;
private final double mMaxAltitude;
private final double mMinAltitude;
private final double mPositiveElevation;
private final double mNegativeElevation;
private BoundingBox mBoundingBox;
/* Generic Constructor */
public TrackBuilder(int trackFormatVersion, List<WayPoint> wayPoints, float trackLength, long duration, float stepCount, Date recordingStart, Date recordingStop, double maxAltitude, double minAltitude, double positiveElevation, double negativeElevation, BoundingBox boundingBox) {
mTrackFormatVersion = trackFormatVersion;
mWayPoints = wayPoints;
mTrackLength = trackLength;
mDuration = duration;
mStepCount = stepCount;
mRecordingStart = recordingStart;
mRecordingStop = recordingStop;
mMaxAltitude = maxAltitude;
mMinAltitude = minAltitude;
mPositiveElevation = positiveElevation;
mNegativeElevation = negativeElevation;
mBoundingBox = boundingBox;
}
/* Builds and return a Track object */
public Track toTrack() {
switch (mTrackFormatVersion) {
case 1:
// file format version 1 - does not have elevation data stored
return new Track(mTrackFormatVersion, mWayPoints, mTrackLength, mDuration, mStepCount, mRecordingStart, mRecordingStop, 0f, 0f, 0f, 0f, new BoundingBox());
case 2:
// file format version 2 - does not have edge coordinates stored
return new Track(mTrackFormatVersion, mWayPoints, mTrackLength, mDuration, mStepCount, mRecordingStart, mRecordingStop, mMaxAltitude, mMinAltitude, mPositiveElevation, mNegativeElevation, MapHelper.calculateBoundingBox(mWayPoints));
case 3:
// file format version 3 (current version)
return new Track(mTrackFormatVersion, mWayPoints, mTrackLength, mDuration, mStepCount, mRecordingStart, mRecordingStop, mMaxAltitude, mMinAltitude, mPositiveElevation, mNegativeElevation, new BoundingBox());
default:
LogHelper.e(LOG_TAG, "Unknown file format version: " + mTrackFormatVersion);
return null;
}
}
}

View File

@ -1,85 +0,0 @@
/**
* TrackBundle.java
* Implements a TrackBundle
* TrackBundle is a container for file and corresponding name of a track
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.core;
import org.y20k.trackbook.helpers.LogHelper;
import java.io.File;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
* TrackBundle class
*/
public class TrackBundle {
/* Define log tag */
private static final String LOG_TAG = TrackBundle.class.getSimpleName();
/* Main class variables */
private final File mTrackFile;
private final String mTrackName;
/* Constructor */
public TrackBundle(File file) {
mTrackFile = file;
mTrackName = buildTrackName(file);
}
/* Getter for track file */
public File getTrackFile() {
return mTrackFile;
}
/* Getter for track name */
public String getTrackName() {
return mTrackName;
}
/* Builds a readable track name from the track's file name */
private String buildTrackName(File file) {
// get file name without extension
String readableTrackName = file.getName();
readableTrackName = readableTrackName.substring(0, readableTrackName.indexOf(".trackbook"));
try {
// convert file name to date
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US);
Date trackDate = dateFormat.parse(readableTrackName);
// convert date to track name string according to current locale
readableTrackName = DateFormat.getDateInstance(DateFormat.LONG, Locale.getDefault()).format(trackDate) + " - " +
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(trackDate);
} catch (ParseException e) {
LogHelper.w(LOG_TAG, "Unable to parse file name into date object (yyyy-MM-dd-HH-mm-ss): " + e);
}
return readableTrackName;
}
}

View File

@ -0,0 +1,53 @@
/*
* Tracklist.kt
* Implements the Tracklist data class
* A Tracklist stores a list of Tracks
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.core
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.Expose
import kotlinx.android.parcel.Parcelize
import org.y20k.trackbook.Keys
import org.y20k.trackbook.helpers.TrackHelper
import java.util.*
/*
* Tracklist data class
*/
@Keep
@Parcelize
data class Tracklist (@Expose val tracklistFormatVersion: Int = Keys.CURRENT_TRACKLIST_FORMAT_VERSION,
@Expose val tracklistElements: MutableList<TracklistElement> = mutableListOf<TracklistElement>(),
@Expose var modificationDate: Date = Date()): Parcelable {
/* Return trackelement for given track id */
fun getTrackElement(trackId: Long): TracklistElement? {
tracklistElements.forEach { tracklistElement ->
if (TrackHelper.getTrackId(tracklistElement) == trackId) {
return tracklistElement
}
}
return null
}
/* Create a deep copy */
fun deepCopy(): Tracklist {
return Tracklist(tracklistFormatVersion, mutableListOf<TracklistElement>().apply { addAll(tracklistElements) }, modificationDate)
}
}

View File

@ -0,0 +1,46 @@
/*
* TracklistElement.kt
* Implements the TracklistElement data class
* A TracklistElement data about a Track
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.core
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.Expose
import kotlinx.android.parcel.Parcelize
import java.util.*
/*
* TracklistElement data class
*/
@Keep
@Parcelize
data class TracklistElement(@Expose var name: String,
@Expose val date: Date,
@Expose val dateString: String,
@Expose val durationString: String,
@Expose val length: Float,
@Expose val trackUriString: String,
@Expose val gpxUriString: String,
@Expose var starred: Boolean = false): Parcelable {
/* Returns unique ID for TracklistElement - currently the start date */
fun getTrackId(): Long {
return date.time
}
}

View File

@ -1,124 +0,0 @@
/**
* WayPoint.java
* Implements the WayPoint class
* A WayPoint stores a location plus additional metadata
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.core;
import android.location.Location;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
/**
* WayPoint class
*/
public class WayPoint implements Parcelable {
private Location mLocation;
private boolean mIsStopOver;
private float mDistanceToStartingPoint;
private final int mNumberSatellites;
/* Constructor */
public WayPoint(Location location, boolean isStopOver, float distanceToStartingPoint) {
mLocation = location;
mIsStopOver = isStopOver;
mDistanceToStartingPoint = distanceToStartingPoint;
// save number of satellites
Bundle extras = location.getExtras();
if (extras != null && extras.containsKey("satellites")) {
mNumberSatellites = extras.getInt("satellites", 0);
mLocation.setExtras(null); // necessary because Location Extras cause cannot be serialized properly by GSON
} else {
mNumberSatellites = 0;
}
}
/* Constructor used by CREATOR */
protected WayPoint(Parcel in) {
mLocation = Location.CREATOR.createFromParcel(in);
mIsStopOver = in.readByte() != 0;
mDistanceToStartingPoint = in.readFloat();
mNumberSatellites = in.readInt();
}
/* CREATOR for WayPoint object used to do parcel related operations */
public static final Creator<WayPoint> CREATOR = new Creator<WayPoint>() {
@Override
public WayPoint createFromParcel(Parcel in) {
return new WayPoint(in);
}
@Override
public WayPoint[] newArray(int size) {
return new WayPoint[size];
}
};
/* Getter for mLocation */
public Location getLocation() {
return mLocation;
}
/* Getter for mIsStopOver */
public boolean getIsStopOver() {
return mIsStopOver;
}
/* Getter for mDistanceToStartingPoint */
public float getDistanceToStartingPoint() {
return mDistanceToStartingPoint;
}
/* Setter for mLocation */
public void setLocation(Location location) {
mLocation = location;
}
/* Setter for mIsStopOver */
public void setIsStopOver(boolean isStopOver) {
mIsStopOver = isStopOver;
}
/* Setter for mDistanceToStartingPoint */
public void setDistanceToStartingPoint(float distanceToStartingPoint) {
mDistanceToStartingPoint = distanceToStartingPoint;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel parcel, int flags) {
mLocation.setExtras(null); // necessary because Location Extras cause cannot be serialized properly by GSON
mLocation.writeToParcel(parcel, flags);
parcel.writeByte((byte) (mIsStopOver ? 1 : 0));
parcel.writeFloat(mDistanceToStartingPoint);
parcel.writeInt(mNumberSatellites);
}
}

View File

@ -0,0 +1,54 @@
/*
* WayPoint.kt
* Implements the WayPoint data class
* A WayPoint stores a location plus additional metadata
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.core
import android.location.Location
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.Expose
import kotlinx.android.parcel.Parcelize
/*
* WayPoint data class
*/
@Keep
@Parcelize
data class WayPoint(@Expose val provider: String,
@Expose val latitude: Double,
@Expose val longitude: Double,
@Expose val altitude: Double,
@Expose val accuracy: Float,
@Expose val time: Long,
@Expose val distanceToStartingPoint: Float = 0f,
@Expose val numberSatellites: Int = 0,
@Expose var isStopOver: Boolean = false): Parcelable {
/* Converts WayPoint into Location */
fun toLocation(): Location {
val location: Location = Location(provider)
location.latitude = latitude
location.longitude = longitude
location.altitude = altitude
location.accuracy = accuracy
location.time = time
return location
}
}

View File

@ -0,0 +1,92 @@
/*
* ErrorDialog.kt
* Implements the ErrorDialog object
* A ErrorDialog shows an error dialog with details
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.dialogs
import android.content.Context
import android.content.DialogInterface
import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.y20k.trackbook.R
import org.y20k.trackbook.helpers.LogHelper
/*
* ErrorDialog object
*/
object ErrorDialog {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(ErrorDialog::class.java)
/* Construct and show dialog */
fun show(context: Context, errorTitle: Int, errorMessage: Int, errorDetails: String = String()) {
// prepare dialog builder
val builder: MaterialAlertDialogBuilder = MaterialAlertDialogBuilder(context, R.style.AlertDialogTheme)
// set title
builder.setTitle(context.getString(errorTitle))
// get views
val inflater: LayoutInflater = LayoutInflater.from(context)
val view: View = inflater.inflate(R.layout.dialog_generic_with_details, null)
val errorMessageView: TextView = view.findViewById(R.id.dialog_message) as TextView
val errorDetailsLinkView: TextView = view.findViewById(R.id.dialog_details_link) as TextView
val errorDetailsView: TextView = view.findViewById(R.id.dialog_details) as TextView
// set dialog view
builder.setView(view)
// set detail view
if (errorDetails.isNotEmpty()) {
// show details link
errorDetailsLinkView.visibility = View.VISIBLE
// allow scrolling on details view
errorDetailsView.movementMethod = ScrollingMovementMethod()
// show and hide details on click
errorDetailsLinkView.setOnClickListener {
when (errorDetailsView.visibility) {
View.GONE -> errorDetailsView.visibility = View.VISIBLE
View.VISIBLE -> errorDetailsView.visibility = View.GONE
}
}
// set details text view
errorDetailsView.text = errorDetails
} else {
// hide details link
errorDetailsLinkView.visibility = View.GONE
}
// set text views
errorMessageView.text = context.getString(errorMessage)
// add okay button
builder.setPositiveButton(R.string.dialog_generic_button_okay, DialogInterface.OnClickListener { _, _ ->
// listen for click on okay button
// do nothing
})
// display error dialog
builder.show()
}
}

View File

@ -0,0 +1,82 @@
/*
* RenameTrackDialog.kt
* Implements the RenameTrackDialog class
* A RenameTrackDialog offers user to change name of track
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.dialogs
import android.content.Context
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.widget.EditText
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.y20k.trackbook.R
import org.y20k.trackbook.helpers.LogHelper
/*
* RenameTrackDialog class
*/
class RenameTrackDialog (private var renameTrackListener: RenameTrackListener) {
/* Interface used to communicate back to activity */
interface RenameTrackListener {
fun onRenameTrackDialog(textInput: String) {
}
}
/* Define log tag */
private val TAG = LogHelper.makeLogTag(RenameTrackDialog::class.java.simpleName)
/* Construct and show dialog */
fun show(context: Context, trackName: String) {
// prepare dialog builder
val builder: MaterialAlertDialogBuilder = MaterialAlertDialogBuilder(context)
// get input field
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(R.layout.dialog_rename_track, null)
val inputField = view.findViewById<View>(R.id.dialog_rename_track_input_edit_text) as EditText
// pre-fill with current track name
inputField.setText(trackName, TextView.BufferType.EDITABLE)
inputField.setSelection(trackName.length)
inputField.inputType = InputType.TYPE_CLASS_TEXT
// set dialog view
builder.setView(view)
// add "add" button
builder.setPositiveButton(R.string.dialog_rename_track_button) { _, _ ->
// hand text over to initiating activity
inputField.text?.let {
renameTrackListener.onRenameTrackDialog(it.toString())
}
}
// add cancel button
builder.setNegativeButton(R.string.dialog_generic_button_cancel) { _, _ ->
// listen for click on cancel button
// do nothing
}
// display add dialog
builder.show()
}
}

View File

@ -0,0 +1,97 @@
/*
* YesNoDialog
* Implements the YesNoDialog class
* A YesNoDialog asks the user if he/she wants to do something or notpackage org.y20k.trackbook.dialogs
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.y20k.trackbook.Keys
import org.y20k.trackbook.R
import org.y20k.trackbook.helpers.LogHelper
/*
* YesNoDialog class
*/
class YesNoDialog (private var yesNoDialogListener: YesNoDialogListener) {
/* Interface used to communicate back to activity */
interface YesNoDialogListener {
fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) {
}
}
/* Define log tag */
private val TAG = LogHelper.makeLogTag(YesNoDialog::class.java.simpleName)
/* Construct and show dialog - variant: message from string */
fun show(context: Context,
type: Int,
title: Int = Keys.EMPTY_STRING_RESOURCE,
message: Int,
yesButton: Int = R.string.dialog_yes_no_positive_button_default,
noButton: Int = R.string.dialog_generic_button_cancel,
payload: Int = Keys.DIALOG_EMPTY_PAYLOAD_INT,
payloadString: String = Keys.DIALOG_EMPTY_PAYLOAD_STRING) {
// extract string from message resource and feed into main show method
show(context, type, title, context.getString(message), yesButton, noButton, payload, payloadString)
}
/* Construct and show dialog */
fun show(context: Context,
type: Int,
title: Int = Keys.EMPTY_STRING_RESOURCE,
messageString: String,
yesButton: Int = R.string.dialog_yes_no_positive_button_default,
noButton: Int = R.string.dialog_generic_button_cancel,
payload: Int = Keys.DIALOG_EMPTY_PAYLOAD_INT,
payloadString: String = Keys.DIALOG_EMPTY_PAYLOAD_STRING) {
// prepare dialog builder
val builder: MaterialAlertDialogBuilder = MaterialAlertDialogBuilder(context, R.style.AlertDialogTheme)
// set title and message
builder.setMessage(messageString)
if (title != Keys.EMPTY_STRING_RESOURCE) {
builder.setTitle(context.getString(title))
}
// add yes button
builder.setPositiveButton(yesButton) { _, _ ->
// listen for click on yes button
yesNoDialogListener.onYesNoDialog(type, true, payload, payloadString)
}
// add no button
builder.setNegativeButton(noButton) { _, _ ->
// listen for click on no button
yesNoDialogListener.onYesNoDialog(type, false, payload, payloadString)
}
// handle outside-click as "no"
builder.setOnCancelListener(){
yesNoDialogListener.onYesNoDialog(type, false, payload, payloadString)
}
// display dialog
builder.show()
}
}

View File

@ -0,0 +1,29 @@
/*
* SharedPreferencesExt.kt
* Implements the SharedPreferencesExt extension functions
* SharedPreferencesExt displays provides additional functions for dealing with shared preferences
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.extensions
import android.content.SharedPreferences
/* Puts a Double value in SharedPreferences */
fun SharedPreferences.Editor.putDouble(key: String, double: Double) = putLong(key, java.lang.Double.doubleToRawLongBits(double))
/* gets a Double value from SharedPreferences */
fun SharedPreferences.getDouble(key: String, default: Double) = java.lang.Double.longBitsToDouble(getLong(key, java.lang.Double.doubleToRawLongBits(default)))

View File

@ -0,0 +1,84 @@
/*
* DateTimeHelper.kt
* Implements the DateTimeHelper object
* A DateTimeHelper provides helper methods for converting Date and Time objects
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.content.Context
import android.location.Location
import org.y20k.trackbook.Keys
import org.y20k.trackbook.R
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
/*
* DateTimeHelper object
*/
object DateTimeHelper {
/* Converts milliseconds to mm:ss or hh:mm:ss */
fun convertToReadableTime(context: Context, milliseconds: Long): String {
var timeString: String = String()
val hours: Long = TimeUnit.MILLISECONDS.toHours(milliseconds)
val minutes: Long = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % TimeUnit.HOURS.toMinutes(1)
val seconds: Long = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % TimeUnit.MINUTES.toSeconds(1)
val h: String = context.getString(R.string.abbreviation_hours)
val m: String = context.getString(R.string.abbreviation_minutes)
val s: String = context.getString(R.string.abbreviation_seconds)
when (milliseconds >= Keys.ONE_HOUR_IN_MILLISECONDS) {
// CASE: format hh:mm:ss
true -> {
timeString = "$hours $h $minutes $m $seconds $s"
}
// CASE: format mm:ss
false -> {
timeString = "$minutes $m $seconds $s"
}
}
return timeString
}
/* Create sortable string from date - used for filenames */
fun convertToSortableDateString(date: Date): String {
val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US)
return dateFormat.format(date)
}
/* Creates a readable string from date - used in the UI */
fun convertToReadableDate(date: Date, dateStyle: Int = DateFormat.LONG): String {
return DateFormat.getDateInstance(dateStyle, Locale.getDefault()).format(date)
}
/* Calculates time difference between two locations */
fun calculateTimeDistance(previousLocation: Location?, location: Location): Long {
var timeDifference: Long = 0L
// two data points needed to calculate time difference
if (previousLocation != null) {
// get time difference
timeDifference = location.time - previousLocation.time
}
return timeDifference
}
}

View File

@ -1,87 +0,0 @@
/**
* DialogHelper.java
* Implements the DialogHelper class
* A DialogHelper creates a customizable alert dialog
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
/**
* DialogHelper class
*/
public class DialogHelper extends DialogFragment implements TrackbookKeys {
/* Constructs a new instance */
public static DialogHelper newInstance(int title, String message, int positiveButton, int negativeButton) {
DialogHelper fragment = new DialogHelper();
Bundle args = new Bundle();
args.putInt(ARG_DIALOG_TITLE, title);
args.putString(ARG_DIALOG_MESSAGE, message);
args.putInt(ARG_DIALOG_BUTTON_POSITIVE, positiveButton);
args.putInt(ARG_DIALOG_BUTTON_NEGATIVE, negativeButton);
fragment.setArguments(args);
return fragment;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle args = getArguments();
// get text elements
int title = args.getInt(ARG_DIALOG_TITLE);
String message = args.getString(ARG_DIALOG_MESSAGE);
int positiveButton = args.getInt(ARG_DIALOG_BUTTON_POSITIVE);
int negativeButton = args.getInt(ARG_DIALOG_BUTTON_NEGATIVE);
// build dialog
AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity());
if (title != -1) {
dialogBuilder.setTitle(title);
}
dialogBuilder.setMessage(message);
dialogBuilder.setPositiveButton(positiveButton,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
Fragment target = getTargetFragment();
if (target != null) {
target.onActivityResult(getTargetRequestCode(), Activity.RESULT_OK, getActivity().getIntent());
}
}
}
); dialogBuilder.setNegativeButton(negativeButton,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int whichButton) {
Fragment target = getTargetFragment();
if (target != null) {
target.onActivityResult(getTargetRequestCode(), Activity.RESULT_CANCELED, getActivity().getIntent());
}
}
}
);
return dialogBuilder.create();
}
}

View File

@ -1,187 +0,0 @@
/**
* DropdownHelper.java
* Implements a dropdown menu
* The dropdown menu used to select tracks
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import android.app.Activity;
import android.content.res.Resources;
import android.database.DataSetObserver;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.TextView;
import org.y20k.trackbook.R;
import org.y20k.trackbook.core.TrackBundle;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.ThemedSpinnerAdapter;
/**
* DropdownHelper class
*/
public class DropdownAdapter extends BaseAdapter implements ThemedSpinnerAdapter, TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = DropdownAdapter.class.getSimpleName();
/* Main class variables */
private final Activity mActivity;
private final ThemedSpinnerAdapter.Helper mDropdownAdapterHelper;
private List<TrackBundle> mTrackBundleList;
/* Constructor */
public DropdownAdapter(Activity activity) {
// store activity
mActivity = activity;
// fill list with track bundles
initializeTrackBundleList();
// create an adapter helper
mDropdownAdapterHelper = new ThemedSpinnerAdapter.Helper(activity);
}
@Override
public void setDropDownViewTheme(@Nullable Resources.Theme theme) {
mDropdownAdapterHelper.setDropDownViewTheme(theme);
}
@Nullable
@Override
public Resources.Theme getDropDownViewTheme() {
return mDropdownAdapterHelper.getDropDownViewTheme();
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
// getView -> collapsed view of dropdown
View view = convertView;
if (view == null) {
LayoutInflater inflater = mDropdownAdapterHelper.getDropDownViewInflater();
view = inflater.inflate(R.layout.custom_dropdown_item_collapsed, parent, false);
}
((TextView) view).setText(getItem(position).getTrackName());
return view;
}
@Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {
// getDropDownView -> expanded view of dropdown
View view = convertView;
if (view == null) {
LayoutInflater inflater = mDropdownAdapterHelper.getDropDownViewInflater();
// view = inflater.inflate(R.layout.custom_dropdown_item_expanded, parent, false);
view = inflater.inflate(R.layout.support_simple_spinner_dropdown_item, parent, false);
}
((TextView) view).setText(getItem(position).getTrackName());
return view;
}
@Override
public void registerDataSetObserver(DataSetObserver dataSetObserver) {
}
@Override
public void unregisterDataSetObserver(DataSetObserver dataSetObserver) {
}
@Override
public int getCount() {
return mTrackBundleList.size();
}
@Override
public TrackBundle getItem(int i) {
return mTrackBundleList.get(i);
}
@Override
public long getItemId(int i) {
return 0;
}
@Override
public boolean hasStableIds() {
return false;
}
@Override
public int getItemViewType(int i) {
return 0;
}
@Override
public int getViewTypeCount() {
return 1;
}
@Override
public boolean isEmpty() {
return mTrackBundleList.size() == 0;
}
/* Refreshes the adapter data */
public void refresh() {
// re-initialize the adapter's array list
initializeTrackBundleList();
}
/* Initializes list of track bundles */
private void initializeTrackBundleList() {
// get list of files from storage
StorageHelper storageHelper = new StorageHelper(mActivity);
File files[] = storageHelper.getListOfTrackbookFiles();
// fill list with track bundles
mTrackBundleList = new ArrayList<>();
for (File file : files) {
String fileName = file.getName();
if (fileName.endsWith(FILE_TYPE_TRACKBOOK_EXTENSION) && !fileName.startsWith(FILE_NAME_TEMP)) {
mTrackBundleList.add(new TrackBundle(file));
}
}
}
}

View File

@ -1,224 +0,0 @@
/**
* ExportHelper.java
* Implements the ExportHelper class
* A ExportHelper can convert Track object into a GPX string
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import android.content.Context;
import android.content.Intent;
import android.location.Location;
import android.os.Environment;
import android.widget.Toast;
import org.y20k.trackbook.R;
import org.y20k.trackbook.core.Track;
import org.y20k.trackbook.core.WayPoint;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import androidx.core.content.FileProvider;
/**
* ExportHelper class
*/
public final class ExportHelper extends FileProvider implements TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = ExportHelper.class.getSimpleName();
/* Checks if a GPX file for given track is already present */
public static boolean gpxFileExists(Track track) {
File folder = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
return createFile(track, folder).exists();
}
/* Exports given track to GPX */
public static boolean exportToGpx(Context context, Track track) {
// get file for given track
File gpxFile = createFile(track, getDownloadFolder());
// get GPX string representation for given track
String gpxString = createGpxString(track);
// write GPX file
if (writeGpxToFile(gpxString, gpxFile)) {
String toastMessage = context.getResources().getString(R.string.toast_message_export_success) + " " + gpxFile.toString();
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
return true;
} else {
String toastMessage = context.getResources().getString(R.string.toast_message_export_fail) + " " + gpxFile.toString();
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
return false;
}
}
/* Creates Intent used to bring up an Android share sheet */
public static Intent getGpxFileIntent(Context context, Track track) {
// create file in Cache directory for given track
File gpxFile = createFile(track, context.getCacheDir());
// get GPX string representation for given track
String gpxString = createGpxString(track);
// write GPX file
if (writeGpxToFile(gpxString, gpxFile)) {
String toastMessage = context.getResources().getString(R.string.toast_message_export_success) + " " + gpxFile.toString();
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
} else {
String toastMessage = context.getResources().getString(R.string.toast_message_export_fail) + " " + gpxFile.toString();
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
}
// create intent
String authority = "org.y20k.trackbook.exporthelper.provider";
Intent intent = new Intent();
intent.setAction(Intent.ACTION_SEND);
intent.setDataAndType(FileProvider.getUriForFile(context, authority, gpxFile), "application/gpx+xml");
intent.setType("application/gpx+xml");
intent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(context, authority, gpxFile));
return intent;
}
/* Empties the internal chache directory */
public static void emptyCacheDirectory(Context context) {
// todo implement a date check - delete only stuff that is a week old
File[] cacheFiles = context.getCacheDir().listFiles();
for (File file: cacheFiles) {
file.delete();
}
}
/* Get "Download" folder */
private static File getDownloadFolder() {
File folder = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
if (folder != null && !folder.exists()) {
LogHelper.v(LOG_TAG, "Creating new folder: " + folder.toString());
folder.mkdirs();
}
return folder;
}
/* Return a GPX filepath for a given track */
private static File createFile(Track track, File folder) {
Date recordingStart = track.getRecordingStart();
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US);
return new File(folder, dateFormat.format(recordingStart) + FILE_TYPE_GPX_EXTENSION);
}
/* Writes given GPX string to given file */
private static boolean writeGpxToFile (String gpxString, File gpxFile) {
// write track
try (BufferedWriter bw = new BufferedWriter(new FileWriter(gpxFile))) {
LogHelper.v(LOG_TAG, "Saving track to external storage: " + gpxFile.toString());
bw.write(gpxString);
return true;
} catch (IOException e) {
LogHelper.e(LOG_TAG, "Unable to saving track to external storage (IOException): " + gpxFile.toString());
return false;
}
}
/* Creates GPX formatted string */
private static String createGpxString(Track track) {
String gpxString;
// add header
gpxString = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\" ?>\n" +
"<gpx version=\"1.1\" creator=\"Transistor App (Android)\"\n" +
" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n" +
" xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd\">\n";
// add track
gpxString = gpxString + addTrack(track);
// add closing tag
gpxString = gpxString + "</gpx>\n";
return gpxString;
}
/* Creates Track */
private static String addTrack(Track track) {
StringBuilder gpxTrack = new StringBuilder("");
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
// add opening track tag
gpxTrack.append("\t<trk>\n");
// add name to track
gpxTrack.append("\t\t<name>");
gpxTrack.append("Trackbook Recording");
gpxTrack.append("</name>\n");
// add opening track segment tag
gpxTrack.append("\t\t<trkseg>\n");
// add route point
for (WayPoint wayPoint:track.getWayPoints()) {
// get location from waypoint
Location location = wayPoint.getLocation();
// add longitude and latitude
gpxTrack.append("\t\t\t<trkpt lat=\"");
gpxTrack.append(location.getLatitude());
gpxTrack.append("\" lon=\"");
gpxTrack.append(location.getLongitude());
gpxTrack.append("\">\n");
// add time
gpxTrack.append("\t\t\t\t<time>");
gpxTrack.append(dateFormat.format(new Date(location.getTime())));
gpxTrack.append("</time>\n");
// add altitude
gpxTrack.append("\t\t\t\t<ele>");
gpxTrack.append(location.getAltitude());
gpxTrack.append("</ele>\n");
// add closing tag
gpxTrack.append("\t\t\t</trkpt>\n");
}
// add closing track segment tag
gpxTrack.append("\t\t</trkseg>\n");
// add closing track tag
gpxTrack.append("\t</trk>\n");
return gpxTrack.toString();
}
}

View File

@ -0,0 +1,431 @@
/*
* FileHelper.kt
* Implements the FileHelper object
* A FileHelper provides helper methods for reading and writing files from and to device storage
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.content.Context
import android.database.Cursor
import android.graphics.Bitmap
import android.net.Uri
import android.provider.OpenableColumns
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import org.y20k.trackbook.Keys
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.Tracklist
import org.y20k.trackbook.core.TracklistElement
import java.io.*
import java.text.NumberFormat
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/*
* FileHelper object
*/
object FileHelper {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(FileHelper::class.java)
/* Return an InputStream for given Uri */
fun getTextFileStream(context: Context, uri: Uri): InputStream? {
var stream : InputStream? = null
try {
stream = context.contentResolver.openInputStream(uri)
} catch (e : Exception) {
e.printStackTrace()
}
return stream
}
/* Get file size for given Uri */
fun getFileSize(context: Context, uri: Uri): Long {
val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null) {
val sizeIndex: Int = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
val size: Long = cursor.getLong(sizeIndex)
cursor.close()
return size
} else {
return 0L
}
}
/* Get file name for given Uri */
fun getFileName(context: Context, uri: Uri): String {
val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null) {
val nameIndex: Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
val name: String = cursor.getString(nameIndex)
cursor.close()
return name
} else {
return String()
}
}
/* Clears given folder - keeps given number of files */
fun clearFolder(folder: File?, keep: Int, deleteFolder: Boolean = false) {
if (folder != null && folder.exists()) {
val files = folder.listFiles()
val fileCount: Int = files.size
files.sortBy { it.lastModified() }
for (fileNumber in files.indices) {
if (fileNumber < fileCount - keep) {
files[fileNumber].delete()
}
}
if (deleteFolder && keep == 0) {
folder.delete()
}
}
}
/* Reads tracklist from storage using GSON */
fun readTracklist(context: Context): Tracklist {
LogHelper.v(TAG, "Reading Tracklist - Thread: ${Thread.currentThread().name}")
// get JSON from text file
val json: String = readTextFile(context, getTracklistFileUri(context))
var tracklist: Tracklist = Tracklist()
when (json.isNotBlank()) {
// convert JSON and return as tracklist
true -> try {
tracklist = getCustomGson().fromJson(json, Tracklist::class.java)
} catch (e: Exception) {
e.printStackTrace()
}
}
return tracklist
}
/* Reads track from storage using GSON */
fun readTrack(context: Context, fileUri: Uri): Track {
// get JSON from text file
val json: String = readTextFile(context, fileUri)
var track: Track = Track()
when (json.isNotEmpty()) {
// convert JSON and return as track
true -> try {
track = getCustomGson().fromJson(json, Track::class.java)
} catch (e: Exception) {
e.printStackTrace()
}
}
return track
}
/* Deletes temp track file */
fun deleteTempFile(context: Context) {
getTempFileUri(context).toFile().delete()
}
/* Checks if temp track file exists */
fun tempFileExists(context: Context): Boolean {
return getTempFileUri(context).toFile().exists()
}
/* Creates Uri for Gpx file of a track */
fun getGpxFileUri(context: Context, track: Track): Uri {
val fileName: String = DateTimeHelper.convertToSortableDateString(track.recordingStart) + Keys.GPX_FILE_EXTENSION
return File(context.getExternalFilesDir(Keys.FOLDER_GPX), fileName).toUri()
}
/* Creates Uri for json track file */
fun getTrackFileUri(context: Context, track: Track): Uri {
val fileName: String = DateTimeHelper.convertToSortableDateString(track.recordingStart) + Keys.TRACKBOOK_FILE_EXTENSION
return File(context.getExternalFilesDir(Keys.FOLDER_TRACKS), fileName).toUri()
}
/* Creates Uri for json temp track file */
fun getTempFileUri(context: Context): Uri {
return File(context.getExternalFilesDir(Keys.FOLDER_TEMP), Keys.TEMP_FILE).toUri()
}
/* Suspend function: Wrapper for saveTracklist */
suspend fun addTrackAndSaveTracklistSuspended(context: Context, track: Track, modificationDate: Date = track.recordingStop) {
return suspendCoroutine { cont ->
val tracklist: Tracklist = readTracklist(context)
tracklist.tracklistElements.add(track.toTracklistElement(context))
cont.resume(saveTracklist(context, tracklist, modificationDate))
}
}
/* Suspend function: Wrapper for renameTrack */
suspend fun renameTrackSuspended(context: Context, track: Track, newName: String) {
return suspendCoroutine { cont ->
cont.resume(renameTrack(context, track, newName))
}
}
/* Suspend function: Wrapper for saveTracklist */
suspend fun saveTracklistSuspended(context: Context, tracklist: Tracklist, modificationDate: Date) {
return suspendCoroutine { cont ->
cont.resume(saveTracklist(context, tracklist, modificationDate))
}
}
/* Suspend function: Wrapper for saveTrack */
suspend fun saveTrackSuspended(track: Track, saveGpxToo: Boolean) {
return suspendCoroutine { cont ->
cont.resume(saveTrack(track, saveGpxToo))
}
}
/* Suspend function: Wrapper for saveTempTrack */
suspend fun saveTempTrackSuspended(context: Context, track: Track) {
return suspendCoroutine { cont ->
cont.resume(saveTempTrack(context, track))
}
}
/* Suspend function: Wrapper for deleteTrack */
suspend fun deleteTrackSuspended(context: Context, position: Int, tracklist: Tracklist): Tracklist {
return suspendCoroutine { cont ->
cont.resume(deleteTrack(context, position, tracklist))
}
}
/* Suspend function: Wrapper for readTracklist */
suspend fun readTracklistSuspended(context: Context): Tracklist {
return suspendCoroutine {cont ->
cont.resume(readTracklist(context))
}
}
/* Suspend function: Wrapper for copyFile */
suspend fun saveCopyOfFileSuspended(context: Context, originalFileUri: Uri, targetFileUri: Uri) {
return suspendCoroutine { cont ->
cont.resume(copyFile(context, originalFileUri, targetFileUri, deleteOriginal = true))
}
}
/* Save Track as JSON to storage */
private fun saveTrack(track: Track, saveGpxToo: Boolean) {
val jsonString: String = getTrackJsonString(track)
if (jsonString.isNotBlank()) {
// write track file
writeTextFile(jsonString, Uri.parse(track.trackUriString))
}
if (saveGpxToo) {
val gpxString: String = TrackHelper.createGpxString(track)
if (gpxString.isNotBlank()) {
// write GPX file
writeTextFile(gpxString, Uri.parse(track.gpxUriString))
}
}
}
/* Save Temp Track as JSON to storage */
private fun saveTempTrack(context: Context, track: Track) {
val json: String = getTrackJsonString(track)
if (json.isNotBlank()) {
writeTextFile(json, getTempFileUri(context))
}
}
/* Saves track tracklist as JSON text file */
private fun saveTracklist(context: Context, tracklist: Tracklist, modificationDate: Date) {
tracklist.modificationDate = modificationDate
// convert to JSON
val gson: Gson = getCustomGson()
var json: String = String()
try {
json = gson.toJson(tracklist)
} catch (e: Exception) {
e.printStackTrace()
}
if (json.isNotBlank()) {
// write text file
writeTextFile(json, getTracklistFileUri(context))
}
}
/* Creates Uri for tracklist file */
private fun getTracklistFileUri(context: Context): Uri {
return File(context.getExternalFilesDir(""), Keys.TRACKLIST_FILE).toUri()
}
/* Renames track */
private fun renameTrack(context: Context, track: Track, newName: String) {
// search track in tracklist
val tracklist: Tracklist = readTracklist(context)
var trackUriString: String = String()
tracklist.tracklistElements.forEach { tracklistElement ->
if (tracklistElement.getTrackId() == track.getTrackId()) {
// rename tracklist element
tracklistElement.name = newName
trackUriString = tracklistElement.trackUriString
}
}
if (trackUriString.isNotEmpty()) {
// save tracklist
saveTracklist(context, tracklist, GregorianCalendar.getInstance().time)
// rename track
track.name = newName
// save track
saveTrack(track, saveGpxToo = true)
}
}
/* Deletes track */
private fun deleteTrack(context: Context, position: Int, tracklist: Tracklist): Tracklist {
val tracklistElement: TracklistElement = tracklist.tracklistElements[position]
// delete track files
tracklistElement.trackUriString.toUri().toFile().delete()
tracklistElement.gpxUriString.toUri().toFile().delete()
// remove track element from list
tracklist.tracklistElements.removeIf { TrackHelper.getTrackId(it) == TrackHelper.getTrackId(tracklistElement) }
saveTracklist(context, tracklist, GregorianCalendar.getInstance().time)
return tracklist
}
/* Copies file to specified target */
private fun copyFile(context: Context, originalFileUri: Uri, targetFileUri: Uri, deleteOriginal: Boolean = false) {
val inputStream = context.contentResolver.openInputStream(originalFileUri)
val outputStream = context.contentResolver.openOutputStream(targetFileUri)
if (outputStream != null) {
inputStream?.copyTo(outputStream)
}
if (deleteOriginal) {
context.contentResolver.delete(originalFileUri, null, null)
}
}
/* Converts track to JSON */
private fun getTrackJsonString(track: Track): String {
val gson: Gson = getCustomGson()
var json: String = String()
try {
json = gson.toJson(track)
} catch (e: Exception) {
e.printStackTrace()
}
return json
}
/* Creates a Gson object */
private fun getCustomGson(): Gson {
val gsonBuilder = GsonBuilder()
gsonBuilder.setDateFormat("M/d/yy hh:mm a")
gsonBuilder.excludeFieldsWithoutExposeAnnotation()
return gsonBuilder.create()
}
/* Converts byte value into a human readable format */
// Source: https://programming.guide/java/formatting-byte-size-to-human-readable-format.html
fun getReadableByteCount(bytes: Long, si: Boolean = true): String {
// check if Decimal prefix symbol (SI) or Binary prefix symbol (IEC) requested
val unit: Long = if (si) 1000L else 1024L
// just return bytes if file size is smaller than requested unit
if (bytes < unit) return "$bytes B"
// calculate exp
val exp: Int = (Math.log(bytes.toDouble()) / Math.log(unit.toDouble())).toInt()
// determine prefix symbol
val prefix: String = ((if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i")
// calculate result and set number format
val result: Double = bytes / Math.pow(unit.toDouble(), exp.toDouble())
val numberFormat = NumberFormat.getNumberInstance()
numberFormat.maximumFractionDigits = 1
return numberFormat.format(result) + " " + prefix + "B"
}
/* Reads InputStream from file uri and returns it as String */
private fun readTextFile(context: Context, fileUri: Uri): String {
// todo read https://commonsware.com/blog/2016/03/15/how-consume-content-uri.html
// https://developer.android.com/training/secure-file-sharing/retrieve-info
val file: File = fileUri.toFile()
// check if file exists
if (!file.exists()) {
return String()
}
// read until last line reached
val stream: InputStream = file.inputStream()
val reader: BufferedReader = BufferedReader(InputStreamReader(stream))
val builder: StringBuilder = StringBuilder()
reader.forEachLine {
builder.append(it)
builder.append("\n") }
stream.close()
return builder.toString()
}
/* Writes given text to file on storage */
private fun writeTextFile(text: String, fileUri: Uri) {
val file: File = fileUri.toFile()
file.writeText(text)
}
/* Writes given bitmap as image file to storage */
private fun writeImageFile(context: Context, bitmap: Bitmap, file: File, format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, quality: Int = 75) {
if (file.exists()) file.delete ()
try {
val out = FileOutputStream(file)
bitmap.compress(format, quality, out)
out.flush()
out.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@ -0,0 +1,165 @@
/*
* ImportHelper.kt
* Implements the ImportHelper object
* A ImportHelper manages the one-time import of old .trackbook files
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.content.Context
import android.location.Location
import com.google.gson.GsonBuilder
import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.y20k.trackbook.Keys
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.WayPoint
import java.io.BufferedReader
import java.io.File
import java.io.InputStream
import java.io.InputStreamReader
import java.util.*
/*
* ImportHelper data class
*/
object ImportHelper {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(ImportHelper::class.java)
/* Converts older tracks of type .trackbook into the new format */
fun convertOldTracks(context: Context) {
val oldTracks: ArrayList<Track> = arrayListOf()
val trackFolder: File? = context.getExternalFilesDir(Keys.FOLDER_TRACKS)
if (trackFolder != null && trackFolder.exists() && trackFolder.isDirectory) {
trackFolder.listFiles()?.forEach { file ->
if (file.name.endsWith(".trackbook")) {
// read until last line reached
val stream: InputStream = file.inputStream()
val reader: BufferedReader = BufferedReader(InputStreamReader(stream))
val builder: StringBuilder = StringBuilder()
reader.forEachLine {
builder.append(it)
builder.append("\n") }
stream.close()
// get content of file
val fileContent: String = builder.toString()
// get LegacyTrack from JSON
val gsonBuilder = GsonBuilder()
gsonBuilder.setDateFormat("M/d/yy hh:mm a")
val oldTrack: LegacyTrack = gsonBuilder.create().fromJson(fileContent, LegacyTrack::class.java)
oldTracks.add(oldTrack.toTrack())
}
}
}
// save track using "deferred await"
if (oldTracks.isNotEmpty()) {
GlobalScope.launch {
oldTracks.forEach { oldTrack ->
// step 1: create and store filenames for json and gpx files
oldTrack.trackUriString = FileHelper.getTrackFileUri(context, oldTrack).toString()
oldTrack.gpxUriString = FileHelper.getGpxFileUri(context, oldTrack).toString()
// step 2: save track
FileHelper.saveTrackSuspended(oldTrack, saveGpxToo = true)
// step 3: save tracklist
FileHelper.addTrackAndSaveTracklistSuspended(context, oldTrack)
}
}
}
}
/*
* Inner class: Legacy version of Track - used for one-time import only
* Warning: Works only as long as targetSdkVersion < 28
*/
private data class LegacyTrack (
@SerializedName("b") var mTrackFormatVersion: Int = 0,
@SerializedName("c") var mWayPoints: List<LegacyWayPoint>,
@SerializedName("d") var mTrackLength: Float = 0f,
@SerializedName("e") var mDuration: Long = 0,
@SerializedName("f") var mStepCount: Float = 0f,
@SerializedName("g") var mRecordingStart: Date = GregorianCalendar.getInstance().time,
@SerializedName("h") var mRecordingStop: Date = mRecordingStart,
@SerializedName("i") var mMaxAltitude: Double = 0.0,
@SerializedName("j") var mMinAltitude: Double = 0.0,
@SerializedName("k") var mPositiveElevation: Double = 0.0,
@SerializedName("l") var mNegativeElevation: Double = 0.0) {
/* Converts */
fun toTrack():Track {
val track: Track = Track()
track.trackFormatVersion = mTrackFormatVersion
mWayPoints.forEach { legacyWayPoint ->
val wayPoint: WayPoint= WayPoint(
provider = legacyWayPoint.mLocation.provider,
latitude = legacyWayPoint.mLocation.latitude,
longitude = legacyWayPoint.mLocation.longitude,
altitude = legacyWayPoint.mLocation.altitude,
accuracy = legacyWayPoint.mLocation.accuracy,
time = legacyWayPoint.mLocation.time,
distanceToStartingPoint = legacyWayPoint.mDistanceToStartingPoint,
numberSatellites = legacyWayPoint.mNumberSatellites,
isStopOver = legacyWayPoint.mIsStopOver
)
track.wayPoints.add(wayPoint)
}
track.length = mTrackLength
track.duration = mDuration
track.stepCount = mStepCount
track.recordingStart = mRecordingStart
track.recordingStop = mRecordingStop
track.maxAltitude = mMaxAltitude
track.minAltitude = mMinAltitude
track.positiveElevation = mPositiveElevation
track.negativeElevation = mNegativeElevation
track.latitude = track.wayPoints[0].latitude
track.longitude = track.wayPoints[0].longitude
track.name = DateTimeHelper.convertToReadableDate(mRecordingStart)
return track
}
}
/*
* End of inner class
*/
/*
* Inner class: Legacy version of WayPoint - used for one-time import only
* Warning: Works only as long as targetSdkVersion < 28
*/
private data class LegacyWayPoint (
var mLocation: Location,
var mIsStopOver: Boolean = false,
var mDistanceToStartingPoint: Float = 0f,
var mNumberSatellites: Int = 0) {
}
/*
* End of inner class
*/
}

View File

@ -1,96 +0,0 @@
/**
* LengthUnitHelper.java
* Implements the LengthUnitHelper class
* A LengthUnitHelper offers helper methods for dealing with unit systems and locales
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import java.text.NumberFormat;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
/**
* LengthUnitHelper class
*/
public final class LengthUnitHelper implements TrackbookKeys {
/* Converts for the default locale a distance value to a readable string */
public static String convertDistanceToString(double distance) {
return convertDistanceToString(distance, getUnitSystem());
}
/* Converts for the given uni System a distance value to a readable string */
public static String convertDistanceToString(double distance, int unitSystem) {
String unit;
NumberFormat numberFormat = NumberFormat.getNumberInstance();
// check for locale and set unit system accordingly
if (unitSystem == IMPERIAL) {
// miles and feet
if (distance > 1610) {
// convert distance to miles
distance = distance * 0.000621371192;
// set measurement unit
unit = "mi";
// set number precision
numberFormat.setMaximumFractionDigits(2);
} else {
// convert distance to feet
distance = distance * 3.28084f;
// set measurement unit
unit = "ft";
// set number precision
numberFormat.setMaximumFractionDigits(0);
}
} else {
// kilometer and meter
if (distance >= 1000) {
// convert distance to kilometer
distance = distance * 0.001f;
// set measurement unit
unit = "km";
// set number precision
numberFormat.setMaximumFractionDigits(2);
} else {
// set measurement unit
unit = "m";
// set number precision
numberFormat.setMaximumFractionDigits(0);
}
}
// format distance according to current locale
return numberFormat.format(distance) + " " + unit;
}
/* Determines which unit system the device is using (metric or imperial) */
public static int getUnitSystem() {
// America (US), Liberia (LR), Myanmar(MM) use the imperial system
List<String> imperialSystemCountries = Arrays.asList("US", "LR", "MM");
String countryCode = Locale.getDefault().getCountry();
if (imperialSystemCountries.contains(countryCode)){
return IMPERIAL;
} else {
return METRIC;
}
}
}

View File

@ -0,0 +1,95 @@
/*
* LengthUnitHelper.kt
* Implements the LengthUnitHelper object
* A LengthUnitHelper offers helper methods for dealing with unit systems and locales
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import java.text.NumberFormat
import java.util.*
/*
* LengthUnitHelper object
*/
object LengthUnitHelper {
/* Converts for the given uni System a distance value to a readable string */
fun convertDistanceToString(distance: Float, useImperial: Boolean = false): String {
return convertDistanceToString(distance.toDouble(), useImperial)
}
/* Converts for the given uni System a distance value to a readable string */
fun convertDistanceToString(distance: Double, useImperial: Boolean = false): String {
val readableDistance: Double
val unit: String
val numberFormat = NumberFormat.getNumberInstance()
// check for locale and set unit system accordingly
when (useImperial) {
// CASE: miles and feet
true -> {
if (distance > 1610) {
// convert distance to miles
readableDistance = distance * 0.000621371192f
// set measurement unit
unit = "mi"
// set number precision
numberFormat.maximumFractionDigits = 2
} else {
// convert distance to feet
readableDistance = distance * 3.28084f
// set measurement unit
unit = "ft"
// set number precision
numberFormat.maximumFractionDigits = 0
}
}
// CASE: kilometer and meter
false -> {
if (distance >= 1000) {
// convert distance to kilometer
readableDistance = distance * 0.001f
// set measurement unit
unit = "km"
// set number precision
numberFormat.maximumFractionDigits = 2
} else {
// no need to convert
readableDistance = distance
// set measurement unit
unit = "m"
// set number precision
numberFormat.maximumFractionDigits = 0
}
}
}
// format distance according to current locale
return "${numberFormat.format(readableDistance)} $unit"
}
/* Determines which unit system the device is using (metric or imperial) */
fun useImperialUnits(): Boolean {
// America (US), Liberia (LR), Myanmar(MM) use the imperial system
val imperialSystemCountries = Arrays.asList("US", "LR", "MM")
val countryCode = Locale.getDefault().country
return imperialSystemCountries.contains(countryCode)
}
}

View File

@ -1,302 +0,0 @@
/**
* LocationHelper.java
* Implements the LocationHelper class
* A LocationHelper offers helper methods for dealing with location issues
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.SystemClock;
import android.provider.Settings;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import androidx.annotation.Nullable;
/**
* LocationHelper class
*/
public final class LocationHelper implements TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = LocationHelper.class.getSimpleName();
/* Determines last known location */
public static Location determineLastKnownLocation(LocationManager locationManager) {
// define variables
List locationProviders = locationManager.getProviders(true);
Location gpsLocation = null;
Location networkLocation = null;
// set location providers
String gpsProvider = LocationManager.GPS_PROVIDER;
String networkProvider = LocationManager.NETWORK_PROVIDER;
if (locationProviders.contains(gpsProvider)) {
// get last know location from gps
try {
gpsLocation = locationManager.getLastKnownLocation(gpsProvider);
} catch (SecurityException e) {
// catches permission problems
e.printStackTrace();
}
}
if (locationProviders.contains(networkProvider)) {
// get last known location from wifi and cell
try {
networkLocation = locationManager.getLastKnownLocation(networkProvider);
} catch (SecurityException e) {
// catches permission problems
e.printStackTrace();
}
}
if (gpsLocation == null) {
return networkLocation;
} else if (networkLocation == null) {
return gpsLocation;
} else if (isBetterLocation(gpsLocation, networkLocation)) {
return gpsLocation;
} else {
return networkLocation;
}
}
/* Determines whether one location reading is better than the current location fix */
public static boolean isBetterLocation(Location location, Location currentBestLocation) {
// credit: the isBetterLocation method was sample code from: https://developer.android.com/guide/topics/location/strategies.html
if (currentBestLocation == null) {
// a new location is always better than no location
return true;
}
// check whether the new location fix is newer or older
long timeDelta = location.getElapsedRealtimeNanos() - currentBestLocation.getElapsedRealtimeNanos();
boolean isSignificantlyNewer = timeDelta > ONE_MINUTE_IN_NANOSECONDS;
boolean isSignificantlyOlder = timeDelta < -ONE_MINUTE_IN_NANOSECONDS;
boolean isNewer = timeDelta > 0;
// if it's been more than two minutes since the current location, use the new location because the user has likely moved
if (isSignificantlyNewer) {
return true;
} else if (isSignificantlyOlder) {
return false;
}
// check whether the new location fix is more or less accurate
int accuracyDelta = (int) (location.getAccuracy() - currentBestLocation.getAccuracy());
boolean isLessAccurate = accuracyDelta > 0;
boolean isMoreAccurate = accuracyDelta < 0;
boolean isSignificantlyLessAccurate = accuracyDelta > 200;
// check if the old and new location are from the same provider
boolean isFromSameProvider = isSameProvider(location.getProvider(), currentBestLocation.getProvider());
// determine location quality using a combination of timeliness and accuracy
if (isMoreAccurate) {
return true;
} else if (isNewer && !isLessAccurate) {
return true;
} else if (isNewer && !isSignificantlyLessAccurate && isFromSameProvider) {
return true;
}
return false;
}
/* Checks accuracy of given location */
public static boolean isAccurate(Location location) {
return location.getAccuracy() < FIFTY_METER_RADIUS;
}
/* Checks if given location is newer than two minutes */
public static boolean isCurrent(Location location) {
if (location == null) {
return false;
} else {
long locationAge = SystemClock.elapsedRealtimeNanos() - location.getElapsedRealtimeNanos();
return locationAge < ONE_MINUTE_IN_NANOSECONDS;
}
}
/* Checks if given location is a new WayPoint */
public static boolean isNewWayPoint(Location lastLocation, Location newLocation, float averageSpeed) {
float distance = newLocation.distanceTo(lastLocation);
long timeDifference = newLocation.getElapsedRealtimeNanos() - lastLocation.getElapsedRealtimeNanos();
if (newLocation.getProvider().equals(LocationManager.NETWORK_PROVIDER)) {
// calculate speed difference
float speedDifference;
float currentSpeed = distance / ((float)timeDifference / ONE_SECOND_IN_NANOSECOND);
if (currentSpeed > averageSpeed) {
speedDifference = currentSpeed / averageSpeed;
} else {
speedDifference = averageSpeed / currentSpeed;
}
// SPECIAL CASE network: plausibility check for network provider. looking for sudden location jump errors
if (averageSpeed != 0f && currentSpeed > 10f && speedDifference > 2f) {
// implausible location (speed is high (10 m/s == 36km/h) and has doubled)
return false;
}
// SPECIAL CASE network: if last location came from gps. only accept location fixes with decent accuracy
if (lastLocation.getProvider().equals(LocationManager.GPS_PROVIDER) && newLocation.getAccuracy() < 66) {
// network locations tend to be too in accurate
return false;
}
// DEFAULT network: distance is bigger than 30 meters and time difference bigger than 12 seconds
return distance > 30 && timeDifference >= 12 * ONE_SECOND_IN_NANOSECOND; // TODO add minimal accuracy
} else {
// DEFAULT GPS: distance is bigger than 10 meters and time difference bigger than 12 seconds
return distance > 10 && timeDifference >= 12 * ONE_SECOND_IN_NANOSECOND;
}
}
/* Checks if given location is a stop over */
public static boolean isStopOver(@Nullable Location previousLocation, Location newLocation) {
if (previousLocation != null) {
long timeDifference = newLocation.getElapsedRealtimeNanos() - previousLocation.getElapsedRealtimeNanos();
return timeDifference >= FIVE_MINUTES_IN_NANOSECONDS;
} else {
return false;
}
}
/* Registers gps and network location listeners */
public static void registerLocationListeners(LocationManager locationManager, LocationListener gpsListener, LocationListener networkListener) {
LogHelper.v(LOG_TAG, "Registering location listeners.");
// get location providers
List locationProviders = locationManager.getAllProviders();
// got GPS location provider?
if (gpsListener != null && locationProviders.contains(LocationManager.GPS_PROVIDER)) {
try {
// register GPS location listener and request updates
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, gpsListener);
LogHelper.v(LOG_TAG, "Registering gps listener.");
} catch (SecurityException e) {
// catches permission problems
e.printStackTrace();
}
}
// got network location provider?
if (networkListener != null && locationProviders.contains(LocationManager.NETWORK_PROVIDER)) {
try {
// register network location listener and request updates
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, networkListener);
LogHelper.v(LOG_TAG, "Registering network listener.");
} catch (SecurityException e) {
// catches permission problems
e.printStackTrace();
}
}
}
/* Removes gps and network location listeners */
public static void removeLocationListeners(LocationManager locationManager, LocationListener gpsListener, LocationListener networkListener) {
LogHelper.v(LOG_TAG, "Removing location listeners.");
// get location providers
List locationProviders = locationManager.getAllProviders();
// got GPS location provider?
if (locationProviders.contains(LocationManager.GPS_PROVIDER) && gpsListener != null) {
try {
// remove GPS listener
locationManager.removeUpdates(gpsListener);
LogHelper.v(LOG_TAG, "Removing gps listener.");
} catch (SecurityException e) {
// catches permission problems
e.printStackTrace();
}
}
// got network location provider?
if (locationProviders.contains(LocationManager.NETWORK_PROVIDER) && networkListener != null) {
try {
// remove network listener
locationManager.removeUpdates(networkListener);
LogHelper.v(LOG_TAG, "Removing network listener.");
} catch (SecurityException e) {
// catches permission problems
e.printStackTrace();
}
}
}
/* Converts milliseconds to mm:ss or hh:mm:ss */
public static String convertToReadableTime(long milliseconds, boolean includeHours) {
if (includeHours) {
// format hh:mm:ss
return String.format(Locale.ENGLISH, "%02d:%02d:%02d", TimeUnit.MILLISECONDS.toHours(milliseconds),
TimeUnit.MILLISECONDS.toMinutes(milliseconds) % TimeUnit.HOURS.toMinutes(1),
TimeUnit.MILLISECONDS.toSeconds(milliseconds) % TimeUnit.MINUTES.toSeconds(1));
} else if (TimeUnit.MILLISECONDS.toHours(milliseconds) < 1) {
// format mm:ss
return String.format(Locale.ENGLISH, "%02d:%02d", TimeUnit.MILLISECONDS.toMinutes(milliseconds) % TimeUnit.HOURS.toMinutes(1),
TimeUnit.MILLISECONDS.toSeconds(milliseconds) % TimeUnit.MINUTES.toSeconds(1));
} else {
return null;
}
}
/* Check if any location provider is enabled */
public static boolean checkLocationSystemSetting(Context context) {
int locationSettingState = 0;
try {
locationSettingState = Settings.Secure.getInt(context.getContentResolver(), Settings.Secure.LOCATION_MODE);
} catch (Settings.SettingNotFoundException e) {
e.printStackTrace();
}
return locationSettingState != Settings.Secure.LOCATION_MODE_OFF;
}
/* Checks whether two location providers are the same */
private static boolean isSameProvider(String provider1, String provider2) {
// credit: the isSameProvider method was sample code from: https://developer.android.com/guide/topics/location/strategies.html
if (provider1 == null) {
return provider2 == null;
}
return provider1.equals(provider2);
}
}

View File

@ -0,0 +1,218 @@
/*
* LocationHelper.kt
* Implements the LocationHelper object
* A LocationHelper offers helper methods for dealing with location issues
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.SystemClock
import androidx.core.content.ContextCompat
import org.y20k.trackbook.Keys
import org.y20k.trackbook.core.Track
import java.util.*
/*
* Keys object
*/
object LocationHelper {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(LocationHelper::class.java)
/* Get default location */
fun getDefaultLocation(): Location {
val defaultLocation: Location = Location(LocationManager.NETWORK_PROVIDER)
defaultLocation.latitude = Keys.DEFAULT_LATITUDE
defaultLocation.longitude = Keys.DEFAULT_LONGITUDE
defaultLocation.accuracy = Keys.DEFAULT_ACCURACY
defaultLocation.altitude = Keys.DEFAULT_ALTITUDE
defaultLocation.time = Keys.DEFAULT_DATE.time
return defaultLocation
}
/* Checks if a location is older than one minute */
fun isOldLocation(location: Location): Boolean {
// check how many milliseconds the given location is old
return GregorianCalendar.getInstance().time.time - location.time > Keys.SIGNIFICANT_TIME_DIFFERENCE
}
/* Tries to return the last location that the system has stored */
fun getLastKnownLocation(context: Context): Location {
// get last location that Trackbook has stored
var lastKnownLocation: Location = PreferencesHelper.loadCurrentBestLocation(context)
// try to get the last location the system has stored - it is probably more recent
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val lastKnownLocationGps: Location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) ?: lastKnownLocation
val lastKnownLocationNetwork: Location = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) ?: lastKnownLocation
when (isBetterLocation(lastKnownLocationGps, lastKnownLocationNetwork)) {
true -> lastKnownLocation = lastKnownLocationGps
false -> lastKnownLocation = lastKnownLocationNetwork
}
}
return lastKnownLocation
}
/* Determines whether one Location reading is better than the current Location fix */
fun isBetterLocation(location: Location, currentBestLocation: Location?): Boolean {
// Credit: https://developer.android.com/guide/topics/location/strategies.html#BestEstimate
if (currentBestLocation == null) {
// a new location is always better than no location
return true
}
// check whether the new location fix is newer or older
val timeDelta: Long = location.time - currentBestLocation.time
val isSignificantlyNewer: Boolean = timeDelta > Keys.SIGNIFICANT_TIME_DIFFERENCE
val isSignificantlyOlder:Boolean = timeDelta < -Keys.SIGNIFICANT_TIME_DIFFERENCE
when {
// if it's been more than two minutes since the current location, use the new location because the user has likely moved
isSignificantlyNewer -> return true
// if the new location is more than two minutes older, it must be worse
isSignificantlyOlder -> return false
}
// check whether the new location fix is more or less accurate
val isNewer: Boolean = timeDelta > 0L
val accuracyDelta: Float = location.accuracy - currentBestLocation.accuracy
val isLessAccurate: Boolean = accuracyDelta > 0f
val isMoreAccurate: Boolean = accuracyDelta < 0f
val isSignificantlyLessAccurate: Boolean = accuracyDelta > 200f
// check if the old and new location are from the same provider
val isFromSameProvider: Boolean = location.provider == currentBestLocation.provider
// determine location quality using a combination of timeliness and accuracy
return when {
isMoreAccurate -> true
isNewer && !isLessAccurate -> true
isNewer && !isSignificantlyLessAccurate && isFromSameProvider -> true
else -> false
}
}
/* Checks if GPS location provider is available and enabled */
fun isGpsEnabled(locationManager: LocationManager): Boolean {
if (locationManager.allProviders.contains(LocationManager.GPS_PROVIDER)) {
return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
} else {
return false
}
}
/* Checks if Network location provider is available and enabled */
fun isNetworkEnabled(locationManager: LocationManager): Boolean {
if (locationManager.allProviders.contains(LocationManager.NETWORK_PROVIDER)) {
return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} else {
return false
}
}
/* Checks if given location is new */
fun isRecentEnough(location: Location): Boolean {
val locationAge: Long = SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos
return locationAge < Keys.DEFAULT_THRESHOLD_LOCATION_AGE
}
/* Checks if given location is accurate */
fun isAccurateEnough(location: Location, locationAccuracyThreshold: Int): Boolean {
val isAccurate: Boolean
when (location.provider) {
LocationManager.GPS_PROVIDER -> isAccurate = location.accuracy < locationAccuracyThreshold
else -> isAccurate = location.accuracy < locationAccuracyThreshold + 10 // a bit more relaxed when location comes from network provider
}
return isAccurate
}
/* Checks if given location is different enough compared to previous location */
fun isDifferentEnough(previousLocation: Location?, location: Location): Boolean {
// check if previous location is (not) available
if (previousLocation == null) return true
// check if distance between is large enough
val distanceThreshold: Float
val averageAccuracy: Float = (previousLocation.accuracy + location.accuracy) / 2
// increase the distance threshold if one or both locations are
if (averageAccuracy > Keys.DEFAULT_THRESHOLD_DISTANCE) {
distanceThreshold = averageAccuracy
} else {
distanceThreshold = Keys.DEFAULT_THRESHOLD_DISTANCE
}
LogHelper.e(TAG, "distanceThreshold -> $distanceThreshold") // todo remove
// location is different when far enough away from previous location
return calculateDistance(previousLocation, location) > distanceThreshold
}
/* Calculates distance between two locations */
fun calculateDistance(previousLocation: Location?, location: Location): Float {
var distance: Float = 0f
// two data points needed to calculate distance
if (previousLocation != null) {
// add up distance
distance = previousLocation.distanceTo(location)
}
return distance
}
/* Calculate elevation differences */
fun calculateElevationDifferences(previousLocation: Location?, location: Location, track: Track): Pair<Double, Double> {
// store current values
var positiveElevation: Double = track.positiveElevation
var negativeElevation: Double = track.negativeElevation
if (previousLocation != null) {
// factor is bigger than 1 if the time stamp difference is larger than the movement recording interval
val timeDifferenceFactor: Long = (location.time - previousLocation.time) / Keys.ADD_WAYPOINT_TO_TRACK_INTERVAL
// get elevation difference and sum it up
val altitudeDifference: Double = location.altitude - previousLocation.altitude
if (altitudeDifference > 0 && altitudeDifference < Keys.ALTITUDE_MEASUREMENT_ERROR_THRESHOLD * timeDifferenceFactor && location.altitude != Keys.DEFAULT_ALTITUDE) {
positiveElevation = track.positiveElevation + altitudeDifference // upwards movement
}
if (altitudeDifference < 0 && altitudeDifference > -Keys.ALTITUDE_MEASUREMENT_ERROR_THRESHOLD * timeDifferenceFactor && location.altitude != Keys.DEFAULT_ALTITUDE) {
negativeElevation = track.negativeElevation + altitudeDifference // downwards movement
}
}
return Pair(positiveElevation, negativeElevation)
}
/* Checks if given location is a stop over */
fun isStopOver(previousLocation: Location?, location: Location): Boolean {
if (previousLocation == null) return false
// check how many milliseconds the given locations are apart
return location.time - previousLocation.time > Keys.STOP_OVER_THRESHOLD
}
}

View File

@ -1,62 +0,0 @@
/**
* LogHelper.java
* Implements the LogHelper class
* A LogHelper wraps the logging calls to be able to strip them out of release versions
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import android.util.Log;
import org.y20k.trackbook.BuildConfig;
/**
* LogHelper class
*/
public final class LogHelper {
private final static boolean mTesting = false;
public static void d(final String tag, String message) {
// include logging only in debug versions
if (BuildConfig.DEBUG || mTesting) {
Log.d(tag, message);
}
}
public static void v(final String tag, String message) {
// include logging only in debug versions
if (BuildConfig.DEBUG || mTesting) {
Log.v(tag, message);
}
}
public static void e(final String tag, String message) {
Log.e(tag, message);
}
public static void i(final String tag, String message) {
Log.i(tag, message);
}
public static void w(final String tag, String message) {
Log.w(tag, message);
}
}

View File

@ -0,0 +1,115 @@
/*
* LogHelper.kt
* Implements the LogHelper object
* A LogHelper wraps the logging calls to be able to strip them out of release versions
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.util.Log
import org.y20k.trackbook.BuildConfig
/*
* LogHelper object
*/
object LogHelper {
private const val TESTING: Boolean = true // set to "false"
private const val LOG_PREFIX: String = "trackbook_"
private const val MAX_LOG_TAG_LENGTH: Int = 64
private const val LOG_PREFIX_LENGTH: Int = LOG_PREFIX.length
fun makeLogTag(str: String): String {
return if (str.length > MAX_LOG_TAG_LENGTH - LOG_PREFIX_LENGTH) {
LOG_PREFIX + str.substring(0, MAX_LOG_TAG_LENGTH - LOG_PREFIX_LENGTH - 1)
} else LOG_PREFIX + str
}
fun makeLogTag(cls: Class<*>): String {
// don't use this when obfuscating class names
return makeLogTag(cls.simpleName)
}
fun v(tag: String, vararg messages: Any) {
// Only log VERBOSE if build type is DEBUG or if TESTING is true
if (BuildConfig.DEBUG || TESTING) {
log(tag, Log.VERBOSE, null, *messages)
}
}
fun d(tag: String, vararg messages: Any) {
// Only log DEBUG if build type is DEBUG or if TESTING is true
if (BuildConfig.DEBUG || TESTING) {
log(tag, Log.DEBUG, null, *messages)
}
}
fun i(tag: String, vararg messages: Any) {
log(tag, Log.INFO, null, *messages)
}
fun w(tag: String, vararg messages: Any) {
log(tag, Log.WARN, null, *messages)
}
fun w(tag: String, t: Throwable, vararg messages: Any) {
log(tag, Log.WARN, t, *messages)
}
fun e(tag: String, vararg messages: Any) {
log(tag, Log.ERROR, null, *messages)
}
fun e(tag: String, t: Throwable, vararg messages: Any) {
log(tag, Log.ERROR, t, *messages)
}
private fun log(tag: String, level: Int, t: Throwable?, vararg messages: Any) {
val message: String
if (t == null && messages.size == 1) {
// handle this common case without the extra cost of creating a stringbuffer:
message = messages[0].toString()
} else {
val sb = StringBuilder()
for (m in messages) {
sb.append(m)
}
if (t != null) {
sb.append("\n").append(Log.getStackTraceString(t))
}
message = sb.toString()
}
Log.println(level, tag, message)
// if (Log.isLoggable(tag, level)) {
// val message: String
// if (t == null && messages != null && messages.size == 1) {
// // handle this common case without the extra cost of creating a stringbuffer:
// message = messages[0].toString()
// } else {
// val sb = StringBuilder()
// if (messages != null)
// for (m in messages) {
// sb.append(m)
// }
// if (t != null) {
// sb.append("\n").append(Log.getStackTraceString(t))
// }
// message = sb.toString()
// }
// Log.println(level, tag, message)
// }
}
}

View File

@ -1,275 +0,0 @@
/**
* MapHelper.java
* Implements the MapHelper class
* A MapHelper offers helper methods for dealing with Trackbook's map
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.location.Location;
import android.widget.Toast;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.overlay.ItemizedIconOverlay;
import org.osmdroid.views.overlay.OverlayItem;
import org.y20k.trackbook.R;
import org.y20k.trackbook.core.Track;
import org.y20k.trackbook.core.WayPoint;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
/**
* MapHelper class
*/
public final class MapHelper implements TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = MapHelper.class.getSimpleName();
/* Creates icon overlay for current position (used in MainActivity Fragment) */
public static ItemizedIconOverlay createMyLocationOverlay(final Context context, Location currentBestLocation, boolean locationIsNew, boolean trackingActive) {
final ArrayList<OverlayItem> overlayItems = new ArrayList<>();
// create marker
Drawable newMarker;
if (locationIsNew && !trackingActive) {
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_dot_blue_24dp);
} else if (!locationIsNew && trackingActive) {
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_dot_red_grey_24dp);
} else {
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_dot_blue_grey_24dp);
}
OverlayItem overlayItem = createOverlayItem(context, currentBestLocation);
overlayItem.setMarker(newMarker);
// add marker to list of overlay items
overlayItems.add(overlayItem);
// create and return overlay for current position
return new ItemizedIconOverlay<>(overlayItems,
new ItemizedIconOverlay.OnItemGestureListener<OverlayItem>() {
@Override
public boolean onItemSingleTapUp(final int index, final OverlayItem item) {
// tap on My Location dot icon
Toast.makeText(context, item.getTitle() + " | " + item.getSnippet(), Toast.LENGTH_LONG).show();
return true;
}
@Override
public boolean onItemLongPress(final int index, final OverlayItem item) {
// long press on My Location dot icon
return true;
}
}, context);
}
/* Creates icon overlay for track */
public static ItemizedIconOverlay createTrackOverlay(final Context context, Track track, boolean trackingActive){
final ArrayList<OverlayItem> overlayItems = new ArrayList<>();
boolean currentPosition;
final int trackSize = track.getSize();
final List<WayPoint> wayPoints = track.getWayPoints();
WayPoint wayPoint;
for (int i = 0; i < track.getSize(); i++) {
// get WayPoint and check if it is current position
wayPoint = wayPoints.get(i);
currentPosition = i == trackSize - 1;
// create marker
Drawable newMarker;
// CASE 1: Tracking active and WayPoint is not current position
if (trackingActive && !currentPosition) {
if (wayPoint.getIsStopOver()) {
// stop over marker
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_crumb_grey_24dp);
} else {
// default marker for this case
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_crumb_red_24dp);
}
}
// CASE 2: Tracking active and WayPoint is current position
else if (trackingActive && currentPosition) {
if (wayPoint.getIsStopOver()) {
// stop over marker
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_dot_blue_grey_24dp);
} else {
// default marker for this case
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_dot_red_24dp);
}
}
// CASE 3: Tracking not active and WayPoint is not current position
else if (!trackingActive && !currentPosition) {
if (wayPoint.getIsStopOver()) {
// stop over marker
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_crumb_grey_24dp);
} else {
// default marker for this case
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_crumb_blue_24dp);
}
}
// CASE 4: Tracking not active and WayPoint is current position
else {
// default marker
newMarker = ContextCompat.getDrawable(context, R.drawable.ic_my_location_crumb_blue_24dp);
}
// create overlay item
OverlayItem overlayItem = createOverlayItem(context, wayPoint.getLocation());
overlayItem.setMarker(newMarker);
// add marker to list of overlay items
overlayItems.add(overlayItem);
}
// return overlay for current position
return new ItemizedIconOverlay<>(overlayItems,
new ItemizedIconOverlay.OnItemGestureListener<OverlayItem>() {
@Override
public boolean onItemSingleTapUp(final int index, final OverlayItem item) {
// tap on waypoint
Toast.makeText(context, item.getTitle(), Toast.LENGTH_LONG).show();
return true;
}
@Override
public boolean onItemLongPress(final int index, final OverlayItem item) {
// long press on waypoint
Toast.makeText(context, item.getSnippet(), Toast.LENGTH_LONG).show();
return true;
}
}, context);
}
/* Creates a marker overlay item */
private static OverlayItem createOverlayItem(Context context, Location location) {
// create content of overlay item
String time = SimpleDateFormat.getTimeInstance(SimpleDateFormat.MEDIUM, Locale.getDefault()).format(location.getTime());
final String title = context.getString(R.string.marker_description_source) + ": " + location.getProvider() + " | " + context.getString(R.string.marker_description_time) + ": " + time;
final String description = context.getString(R.string.marker_description_accuracy) + ": " + location.getAccuracy();
final GeoPoint position = new GeoPoint(location.getLatitude(),location.getLongitude());
return new OverlayItem(title, description, position);
}
/**
* Create a {@code BoundingBox} for the collection of
* {@code WayPoint}s, so that it would be possible to fit the map in
* such box and see the whole {@code Track} in the map without
* manual zooming.
*
* @return {@code BoundingBox} containing all {@code Waypoint}s
*/
public static BoundingBox calculateBoundingBox(List<WayPoint> wayPoints) {
final ArrayList<GeoPoint> geoPoints = new ArrayList<>(wayPoints.size());
for (final WayPoint aWayPoint : wayPoints) {
final GeoPoint aGeoPoint = new GeoPoint(aWayPoint.getLocation());
geoPoints.add(aGeoPoint);
}
return BoundingBox.fromGeoPoints(geoPoints).increaseByScale(1.15f);
}
/* Calculates positive and negative elevation of track */
public static Track calculateElevation(@Nullable Track track) {
double maxAltitude = 0;
double minAltitude = 0;
double positiveElevation = 0;
double negativeElevation = 0;
if (track != null && track.getWayPoints().size() > 0) {
double previousLocationAltitude;
double currentLocationAltitude;
long previousTimeStamp;
long currentTimeStamp;
// initial values for max height and min height - first waypoint
maxAltitude = track.getWayPointLocation(0).getAltitude();
minAltitude = maxAltitude;
// apply filter & smooth data
// track = smoothTrack(track, 15f, 35f);
// iterate over track
for (int i = 1; i < track.getWayPoints().size(); i++ ) {
// get time difference
previousTimeStamp = track.getWayPointLocation(i -1).getTime();
currentTimeStamp = track.getWayPointLocation(i).getTime();
double timeDiff = (currentTimeStamp - previousTimeStamp);
// factor is bigger than 1 if the time stamp difference is larger than the movement recording interval (usually 15 seconds)
double timeDiffFactor = timeDiff / FIFTEEN_SECONDS_IN_MILLISECONDS;
// height of previous and current waypoints
previousLocationAltitude = track.getWayPointLocation(i -1).getAltitude();
currentLocationAltitude = track.getWayPointLocation(i).getAltitude();
// check for new min and max heights
if (currentLocationAltitude > maxAltitude) {
maxAltitude = currentLocationAltitude;
}
if (minAltitude == 0 || currentLocationAltitude < minAltitude) {
minAltitude = currentLocationAltitude;
}
// get elevation difference and sum it up
double altitudeDiff = currentLocationAltitude - previousLocationAltitude;
if (altitudeDiff > 0 && altitudeDiff < MEASUREMENT_ERROR_THRESHOLD * timeDiffFactor && currentLocationAltitude != 0) {
positiveElevation = positiveElevation + altitudeDiff;
}
if (altitudeDiff < 0 && altitudeDiff > -MEASUREMENT_ERROR_THRESHOLD * timeDiffFactor && currentLocationAltitude != 0) {
negativeElevation = negativeElevation + altitudeDiff;
}
}
// store elevation data in track
track.setMaxAltitude(maxAltitude);
track.setMinAltitude(minAltitude);
track.setPositiveElevation(positiveElevation);
track.setNegativeElevation(negativeElevation);
}
return track;
}
}

View File

@ -0,0 +1,148 @@
/*
* MapHelper.kt
* Implements the MapHelper object
* A MapHelper offers helper methods for manipulating osmdroid maps
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.content.Context
import android.graphics.drawable.Drawable
import android.location.Location
import android.os.Vibrator
import android.widget.Toast
import androidx.core.content.ContextCompat
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.overlay.ItemizedIconOverlay
import org.osmdroid.views.overlay.OverlayItem
import org.y20k.trackbook.Keys
import org.y20k.trackbook.R
import org.y20k.trackbook.core.Track
import java.text.DecimalFormat
import java.text.SimpleDateFormat
import java.util.*
/*
* MapHelper object
*/
object MapHelper {
/* Define log tag */
private val LOG_TAG = MapHelper::class.java.simpleName
/* Creates icon overlay for current position (used in MapFragment) */
fun createMyLocationOverlay(context: Context, location: Location, trackingState: Int): ItemizedIconOverlay<OverlayItem> {
val overlayItems = ArrayList<OverlayItem>()
val locationIsOld = LocationHelper.isOldLocation(location)
// create marker
val newMarker: Drawable
when (trackingState) {
// CASE: Tracking active
Keys.STATE_TRACKING_ACTIVE -> {
when (locationIsOld) {
true -> newMarker = ContextCompat.getDrawable(context, R.drawable.ic_marker_location_red_grey_24dp)!!
false -> newMarker = ContextCompat.getDrawable(context, R.drawable.ic_marker_location_red_24dp)!!
}
}
// CASE. Tracking is NOT active
else -> {
when (locationIsOld) {
true -> newMarker = ContextCompat.getDrawable(context, R.drawable.ic_marker_location_blue_grey_24dp)!!
false -> newMarker = ContextCompat.getDrawable(context, R.drawable.ic_marker_location_blue_24dp)!!
}
}
}
// add marker to list of overlay items
val overlayItem = createOverlayItem(context, location.latitude, location.longitude, location.accuracy, location.provider, location.time)
overlayItem.setMarker(newMarker)
overlayItems.add(overlayItem)
// create and return overlay for current position
return createOverlay(context, overlayItems)
}
/* Creates icon overlay for track */
fun createTrackOverlay(context: Context, track: Track, trackingState: Int): ItemizedIconOverlay<OverlayItem> {
val overlayItems = ArrayList<OverlayItem>()
val wayPoints = track.wayPoints
wayPoints.forEach { wayPoint ->
// create marker
val newMarker: Drawable
// get drawable
when (trackingState) {
// CASE: Recording is active
Keys.STATE_TRACKING_ACTIVE -> {
when (wayPoint.isStopOver) {
true -> newMarker = ContextCompat.getDrawable(context, R.drawable.ic_marker_track_location_grey_24dp)!!
false -> newMarker = ContextCompat.getDrawable(context, R.drawable.ic_marker_track_location_red_24dp)!!
}
}
// CASE: Recording is paused/stopped
else -> {
when (wayPoint.isStopOver) {
true -> newMarker = ContextCompat.getDrawable(context, R.drawable.ic_marker_track_location_grey_24dp)!!
false -> newMarker = ContextCompat.getDrawable(context, R.drawable.ic_marker_track_location_blue_24dp)!!
}
}
}
// create overlay item and add to list of overlay items
val overlayItem = createOverlayItem(context, wayPoint.latitude, wayPoint.longitude, wayPoint.accuracy, wayPoint.provider, wayPoint.time)
overlayItem.setMarker(newMarker)
overlayItems.add(overlayItem)
}
// create and return overlay for current position
return createOverlay(context, overlayItems)
}
/* Creates a marker overlay item */
private fun createOverlayItem(context: Context, latitude: Double, longitude: Double, accuracy: Float, provider: String, time: Long): OverlayItem {
val title: String = "${context.getString(R.string.marker_description_time)}: ${SimpleDateFormat.getTimeInstance(SimpleDateFormat.MEDIUM, Locale.getDefault()).format(time)}"
val description: String = "${context.getString(R.string.marker_description_accuracy)}: ${DecimalFormat("#0.00").format(accuracy)} (${provider})"
val position: GeoPoint = GeoPoint(latitude, longitude)
return OverlayItem(title, description, position)
}
/* Creates an overlay */
private fun createOverlay(context: Context, overlayItems: ArrayList<OverlayItem>): ItemizedIconOverlay<OverlayItem> {
return ItemizedIconOverlay(overlayItems,
object : ItemizedIconOverlay.OnItemGestureListener<OverlayItem> {
override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean {
Toast.makeText(context, item.title, Toast.LENGTH_LONG).show()
return true
}
override fun onItemLongPress(index: Int, item: OverlayItem): Boolean {
val v = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
v.vibrate(50)
Toast.makeText(context, item.snippet, Toast.LENGTH_LONG).show()
return true
}
}, context)
}
}

View File

@ -1,177 +0,0 @@
/**
* NightModeHelper.java
* Implements the NightModeHelper class
* A NightModeHelper can toggle and restore the state of the theme's Night Mode
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.os.Build;
import android.preference.PreferenceManager;
import android.view.View;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatDelegate;
import org.y20k.trackbook.R;
/**
* NightModeHelper class
*/
public final class NightModeHelper implements TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = NightModeHelper.class.getSimpleName();
/* Switches between modes: day, night, undefined */
public static void switchMode(Activity activity) {
// SWITCH: undefined -> night / night -> day / day - undefined
switch (AppCompatDelegate.getDefaultNightMode()) {
case AppCompatDelegate.MODE_NIGHT_NO:
// currently: day mode -> switch to: follow system
displayDefaultStatusBar(activity); // necessary hack :-/
activateFollowSystemMode(activity, true);
break;
case AppCompatDelegate.MODE_NIGHT_YES:
// currently: night mode -> switch to: day mode
displayLightStatusBar(activity); // necessary hack :-/
activateDayMode(activity, true);
break;
default:
// currently: follow system / undefined -> switch to: day mode
displayLightStatusBar(activity); // necessary hack :-/
activateNightMode(activity, true);
break;
}
}
/* Sets night mode / dark theme */
public static void restoreSavedState(Context context) {
int savedNightModeState = loadNightModeState(context);
int currentNightModeState = AppCompatDelegate.getDefaultNightMode();
if (savedNightModeState != currentNightModeState) {
switch (savedNightModeState) {
case AppCompatDelegate.MODE_NIGHT_NO:
// turn on day mode
activateDayMode(context, false);
break;
case AppCompatDelegate.MODE_NIGHT_YES:
// turn on night mode
activateNightMode(context, false);
break;
default:
// turn on mode "follow system"
activateFollowSystemMode(context, false);
break;
}
}
}
/* Return weather Night Mode is on, or not */
public static Boolean getNightMode(Context context) {
int nightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
return nightMode == Configuration.UI_MODE_NIGHT_YES;
}
/* Returns state of night mode */
private static int getCurrentNightModeState(Context context) {
return context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
}
/* Activates Night Mode */
private static void activateNightMode(Context context, Boolean notifyUser) {
saveNightModeState(context, AppCompatDelegate.MODE_NIGHT_YES);
// switch to Night Mode
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
// notify user
if (notifyUser) {
Toast.makeText(context, context.getText(R.string.toast_message_theme_night), Toast.LENGTH_SHORT).show();
}
}
/* Activates Day Mode */
private static void activateDayMode(Context context, Boolean notifyUser) {
// save the new state
saveNightModeState(context, AppCompatDelegate.MODE_NIGHT_NO);
// switch to Day Mode
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
// notify user
if (notifyUser) {
Toast.makeText(context, context.getText(R.string.toast_message_theme_day), Toast.LENGTH_LONG).show();
}
}
/* Activate Mode "Follow System" */
private static void activateFollowSystemMode(Context context, Boolean notifyUser) {
// save the new state
saveNightModeState(context, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
// switch to Undefined Mode / Follow System
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
// notify user
if (notifyUser) {
Toast.makeText(context, context.getText(R.string.toast_message_theme_follow_system), Toast.LENGTH_LONG).show();
}
}
/* Displays the default status bar */
private static void displayDefaultStatusBar(Activity activity) {
View decorView = activity.getWindow().getDecorView();
decorView.setSystemUiVisibility(0);
}
/* Displays the light (inverted) status bar - if possible */
private static void displayLightStatusBar(Activity activity) {
View decorView = activity.getWindow().getDecorView();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
} else {
decorView.setSystemUiVisibility(0);
}
}
/* Save state of night mode */
private static void saveNightModeState(Context context, int currentState) {
SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context);
SharedPreferences.Editor editor = settings.edit();
editor.putInt(PREF_NIGHT_MODE_STATE, currentState);
editor.apply();
}
/* Load state of Night Mode */
private static int loadNightModeState(Context context) {
return PreferenceManager.getDefaultSharedPreferences(context).getInt(PREF_NIGHT_MODE_STATE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
}
}

View File

@ -0,0 +1,150 @@
/*
* NightModeHelper.kt
* Implements the NightModeHelper object
* A NightModeHelper can toggle and restore the state of the theme's Night Mode
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
import org.y20k.trackbook.R
/*
* NightModeHelper object
*/
object NightModeHelper {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(NightModeHelper::class.java)
/* Switches between modes: day, night, undefined */
@SuppressLint("SwitchIntDef")
fun switchMode(activity: Activity) {
// SWITCH: undefined -> night / night -> day / day - undefined
when (AppCompatDelegate.getDefaultNightMode()) {
AppCompatDelegate.MODE_NIGHT_NO -> {
// currently: day mode -> switch to: follow system
// displayDefaultStatusBar(activity) // necessary hack :-/
activateFollowSystemMode(activity, true)
}
AppCompatDelegate.MODE_NIGHT_YES -> {
// currently: night mode -> switch to: day mode
// displayLightStatusBar(activity) // necessary hack :-/
activateDayMode(activity, true)
}
else -> {
// currently: follow system / undefined -> switch to: day mode
// displayLightStatusBar(activity) // necessary hack :-/
activateNightMode(activity, true)
}
}
}
/* Sets night mode / dark theme */
fun restoreSavedState(context: Context) {
val savedNightModeState = PreferencesHelper.loadNightModeState(context)
val currentNightModeState = AppCompatDelegate.getDefaultNightMode()
if (savedNightModeState != currentNightModeState) {
when (savedNightModeState) {
AppCompatDelegate.MODE_NIGHT_NO ->
// turn on day mode
activateDayMode(context, false)
AppCompatDelegate.MODE_NIGHT_YES ->
// turn on night mode
activateNightMode(context, false)
else ->
// turn on mode "follow system"
activateFollowSystemMode(context, false)
}
}
}
/* Return weather Night Mode is on, or not */
fun isNightModeOn(context: Context): Boolean {
val nightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return nightMode == Configuration.UI_MODE_NIGHT_YES
}
/* Activates Night Mode */
private fun activateNightMode(context: Context, notifyUser: Boolean) {
PreferencesHelper.saveNightModeState(context, AppCompatDelegate.MODE_NIGHT_YES)
// switch to Night Mode
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
// notify user
if (notifyUser) {
Toast.makeText(context, context.getText(R.string.toast_message_theme_night), Toast.LENGTH_LONG).show()
}
}
/* Activates Day Mode */
private fun activateDayMode(context: Context, notifyUser: Boolean) {
// save the new state
PreferencesHelper.saveNightModeState(context, AppCompatDelegate.MODE_NIGHT_NO)
// switch to Day Mode
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
// notify user
if (notifyUser) {
Toast.makeText(context, context.getText(R.string.toast_message_theme_day), Toast.LENGTH_LONG).show()
}
}
/* Activate Mode "Follow System" */
private fun activateFollowSystemMode(context: Context, notifyUser: Boolean) {
// save the new state
PreferencesHelper.saveNightModeState(context, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
// switch to Undefined Mode / Follow System
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
// notify user
if (notifyUser) {
Toast.makeText(context, context.getText(R.string.toast_message_theme_follow_system), Toast.LENGTH_LONG).show()
}
}
/* Displays the default status bar */
private fun displayDefaultStatusBar(activity: Activity) {
val decorView = activity.window.decorView
decorView.systemUiVisibility = 0
}
/* Displays the light (inverted) status bar */
private fun displayLightStatusBar(activity: Activity) {
val decorView = activity.window.decorView
decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
}
}

View File

@ -1,171 +0,0 @@
/**
* NotificationHelper.java
* Implements the NotificationHelper class
* A NotificationHelper creates and configures a notification
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.Build;
import org.y20k.trackbook.MainActivity;
import org.y20k.trackbook.R;
import org.y20k.trackbook.TrackerService;
import org.y20k.trackbook.core.Track;
import androidx.core.app.NotificationCompat;
import androidx.core.app.TaskStackBuilder;
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
/**
* NotificationHelper class
*/
public final class NotificationHelper implements TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = NotificationHelper.class.getSimpleName();
/* Creates a notification builder */
public static Notification getNotification(Context context, NotificationCompat.Builder builder, Track track, boolean tracking) {
// create notification channel
createNotificationChannel(context);
// ACTION: NOTIFICATION TAP & BUTTON SHOW
Intent tapActionIntent = new Intent(context, MainActivity.class);
tapActionIntent.setAction(ACTION_SHOW_MAP);
tapActionIntent.putExtra(EXTRA_TRACK, track);
tapActionIntent.putExtra(EXTRA_TRACKING_STATE, tracking);
// artificial back stack for started Activity (https://developer.android.com/training/notify-user/navigation.html#DirectEntry)
TaskStackBuilder tapActionIntentBuilder = TaskStackBuilder.create(context);
tapActionIntentBuilder.addParentStack(MainActivity.class);
tapActionIntentBuilder.addNextIntent(tapActionIntent);
// pending intent wrapper for notification tap
PendingIntent tapActionPendingIntent = tapActionIntentBuilder.getPendingIntent(10, PendingIntent.FLAG_UPDATE_CURRENT);
// ACTION: NOTIFICATION BUTTON STOP
Intent stopActionIntent = new Intent(context, TrackerService.class);
stopActionIntent.setAction(ACTION_STOP);
// pending intent wrapper for notification stop action
PendingIntent stopActionPendingIntent = PendingIntent.getService(context, 14, stopActionIntent, 0);
// ACTION: NOTIFICATION BUTTON RESUME
Intent resumeActionIntent = new Intent(context, TrackerService.class);
resumeActionIntent.setAction(ACTION_RESUME);
// pending intent wrapper for notification resume action
PendingIntent resuneActionPendingIntent = PendingIntent.getService(context, 16, resumeActionIntent, 0);
// construct notification in builder
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
builder.setShowWhen(false);
builder.setContentIntent(tapActionPendingIntent);
builder.setSmallIcon(R.drawable.ic_notification_small_24dp);
builder.setLargeIcon(getNotificationIconLarge(context, tracking));
if (tracking) {
builder.addAction(R.drawable.ic_stop_white_24dp, context.getString(R.string.notification_stop), stopActionPendingIntent);
builder.setContentTitle(context.getString(R.string.notification_title_trackbook_running));
builder.setContentText(getContextString(context, track));
} else {
builder.addAction(R.drawable.ic_fiber_manual_record_white_24dp, context.getString(R.string.notification_resume), resuneActionPendingIntent);
builder.addAction(R.drawable.ic_compass_needle_white_24dp, context.getString(R.string.notification_show), tapActionPendingIntent);
builder.setContentTitle(context.getString(R.string.notification_title_trackbook_not_running));
builder.setContentText(getContextString(context, track));
}
return builder.build();
}
/* Constructs an updated notification */
public static Notification getUpdatedNotification(Context context, NotificationCompat.Builder builder, Track track) {
builder.setContentText(getContextString(context, track));
return builder.build();
}
/* Create a notification channel */
public static boolean createNotificationChannel(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// API level 26 ("Android O") supports notification channels.
String id = NOTIFICATION_CHANEL_ID_RECORDING_CHANNEL;
CharSequence name = context.getString(R.string.notification_channel_recording_name);
String description = context.getString(R.string.notification_channel_recording_description);
int importance = NotificationManager.IMPORTANCE_LOW;
// create channel
NotificationChannel channel = new NotificationChannel(id, name, importance);
channel.setDescription(description);
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.createNotificationChannel(channel);
return true;
} else {
return false;
}
}
/* Get station image for notification's large icon */
private static Bitmap getNotificationIconLarge(Context context, boolean tracking) {
// get dimensions
Resources resources = context.getResources();
int height = (int) resources.getDimension(android.R.dimen.notification_large_icon_height);
int width = (int) resources.getDimension(android.R.dimen.notification_large_icon_width);
Bitmap bitmap;
if (tracking) {
bitmap = getBitmap(context, R.drawable.ic_notification_large_tracking_48dp);
} else {
bitmap = getBitmap(context, R.drawable.ic_notification_large_not_tracking_48dp);
}
return Bitmap.createScaledBitmap(bitmap, width, height, false);
}
/* Return a bitmap for a given resource id of a vector drawable */
private static Bitmap getBitmap(Context context, int resource) {
VectorDrawableCompat drawable = VectorDrawableCompat.create(context.getResources(), resource, null);
if (drawable != null) {
Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
return bitmap;
} else {
return null;
}
}
/* Build context text for notification builder */
private static String getContextString(Context context, Track track) {
return context.getString(R.string.notification_content_distance) + ": " + LengthUnitHelper.convertDistanceToString(track.getTrackDistance()) + " | " +
context.getString(R.string.notification_content_duration) + ": " + LocationHelper.convertToReadableTime(track.getTrackDuration(), true);
}
}

View File

@ -0,0 +1,135 @@
/*
* NotificationHelper.kt
* Implements the NotificationHelper class
* A NotificationHelper creates and configures a notification
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.app.*
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.toBitmap
import org.y20k.trackbook.Keys
import org.y20k.trackbook.MainActivity
import org.y20k.trackbook.R
import org.y20k.trackbook.TrackerService
/*
* NotificationHelper class
*/
class NotificationHelper(private val trackerService: TrackerService) {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(NotificationHelper::class.java)
/* Main class variables */
private val notificationManager: NotificationManager = trackerService.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
/* Creates notification */
fun createNotification(trackingState: Int, trackLength: Float, duration: Long, useImperial: Boolean): Notification {
// create notification channel if necessary
if (shouldCreateNotificationChannel()) {
createNotificationChannel()
}
// Build notification
val builder = NotificationCompat.Builder(trackerService, Keys.NOTIFICATION_CHANNEL_RECORDING)
builder.setContentIntent(showActionPendingIntent)
builder.setSmallIcon(R.drawable.ic_notification_icon_small_24dp)
builder.setContentText(getContentString(trackerService, duration, trackLength, useImperial))
// add icon and actions for stop, resume and show
when (trackingState) {
Keys.STATE_TRACKING_ACTIVE -> {
builder.setContentTitle(trackerService.getString(R.string.notification_title_trackbook_running))
builder.addAction(stopAction)
builder.setLargeIcon(AppCompatResources.getDrawable(trackerService, R.drawable.ic_notification_icon_large_tracking_active_48dp)!!.toBitmap())
}
else -> {
builder.setContentTitle(trackerService.getString(R.string.notification_title_trackbook_not_running))
builder.addAction(resumeAction)
builder.addAction(showAction)
builder.setLargeIcon(AppCompatResources.getDrawable(trackerService, R.drawable.ic_notification_icon_large_tracking_stopped_48dp)!!.toBitmap())
}
}
return builder.build()
}
/* Build context text for notification builder */
private fun getContentString(context: Context, duration: Long, trackLength: Float, useImperial: Boolean): String {
return "${LengthUnitHelper.convertDistanceToString(trackLength, useImperial)}${DateTimeHelper.convertToReadableTime(context, duration)}"
}
/* Checks if notification channel should be created */
private fun shouldCreateNotificationChannel() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !nowPlayingChannelExists()
/* Checks if notification channel exists */
@RequiresApi(Build.VERSION_CODES.O)
private fun nowPlayingChannelExists() = notificationManager.getNotificationChannel(Keys.NOTIFICATION_CHANNEL_RECORDING) != null
/* Create a notification channel */
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() {
val notificationChannel = NotificationChannel(Keys.NOTIFICATION_CHANNEL_RECORDING,
trackerService.getString(R.string.notification_channel_recording_name),
NotificationManager.IMPORTANCE_LOW)
.apply { description = trackerService.getString(R.string.notification_channel_recording_description) }
notificationManager.createNotificationChannel(notificationChannel)
}
/* Notification pending intents */
private val stopActionPendingIntent = PendingIntent.getService(
trackerService,14,
Intent(trackerService, TrackerService::class.java).setAction(Keys.ACTION_STOP),0)
private val resumeActionPendingIntent = PendingIntent.getService(
trackerService, 16,
Intent(trackerService, TrackerService::class.java).setAction(Keys.ACTION_RESUME),0)
private val showActionPendingIntent: PendingIntent? = TaskStackBuilder.create(trackerService).run {
addNextIntentWithParentStack(Intent(trackerService, MainActivity::class.java))
getPendingIntent(10, PendingIntent.FLAG_UPDATE_CURRENT)
}
/* Notification actions */
private val stopAction = NotificationCompat.Action(
R.drawable.ic_notification_action_stop_24dp,
trackerService.getString(R.string.notification_stop),
stopActionPendingIntent)
private val resumeAction = NotificationCompat.Action(
R.drawable.ic_notification_action_resume_36dp,
trackerService.getString(R.string.notification_resume),
resumeActionPendingIntent)
private val showAction = NotificationCompat.Action(
R.drawable.ic_notification_action_show_36dp,
trackerService.getString(R.string.notification_show),
showActionPendingIntent)
}

View File

@ -0,0 +1,167 @@
/*
* PreferencesHelper.kt
* Implements the PreferencesHelper object
* A PreferencesHelper provides helper methods for the saving and loading values from shared preferences
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.content.Context
import android.location.Location
import android.location.LocationManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.PreferenceManager
import org.y20k.trackbook.Keys
import org.y20k.trackbook.extensions.getDouble
import org.y20k.trackbook.extensions.putDouble
/*
* PreferencesHelper object
*/
object PreferencesHelper {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(PreferencesHelper::class.java)
/* Loads zoom level of map */
fun loadZoomLevel(context: Context): Double {
// get preferences
val settings = PreferenceManager.getDefaultSharedPreferences(context)
// load zoom level
return settings.getDouble(Keys.PREF_MAP_ZOOM_LEVEL, Keys.DEFAULT_ZOOM_LEVEL)
}
/* Saves zoom level of map */
fun saveZoomLevel(context: Context, zoomLevel: Double) {
// get preferences
val settings = PreferenceManager.getDefaultSharedPreferences(context)
val editor = settings.edit()
// save zoom level
editor.putDouble(Keys.PREF_MAP_ZOOM_LEVEL, zoomLevel)
editor.apply()
}
/* Loads tracking state */
fun loadTrackingState(context: Context): Int {
// get preferences
val settings = PreferenceManager.getDefaultSharedPreferences(context)
// load tracking state
return settings.getInt(Keys.PREF_TRACKING_STATE, Keys.STATE_NOT_TRACKING)
}
/* Saves tracking state */
fun saveTrackingState(context: Context, trackingState: Int) {
// get preferences
val settings = PreferenceManager.getDefaultSharedPreferences(context)
val editor = settings.edit()
// save tracking state
editor.putInt(Keys.PREF_TRACKING_STATE, trackingState)
editor.apply()
}
/* Loads length unit system - metric or imperial */
fun loadUseImperialUnits(context: Context): Boolean {
// get preferences
val settings = PreferenceManager.getDefaultSharedPreferences(context)
// load length unit system
return settings.getBoolean(Keys.PREF_USE_IMPERIAL_UNITS, LengthUnitHelper.useImperialUnits())
}
/* Loads length unit system - metric or imperial */
fun loadGpsOnly(context: Context): Boolean {
// get preferences
val settings = PreferenceManager.getDefaultSharedPreferences(context)
// load length unit system
return settings.getBoolean(Keys.PREF_GPS_ONLY, false)
}
/* Loads accuracy threshold used to determine if location is good enough */
fun loadAccuracyThreshold(context: Context): Int {
// get preferences
val settings = PreferenceManager.getDefaultSharedPreferences(context)
// load tracking state
return settings.getInt(Keys.PREF_LOCATION_ACCURACY_THRESHOLD, Keys.DEFAULT_THRESHOLD_LOCATION_ACCURACY)
}
/* Loads the state of a map */
fun loadCurrentBestLocation(context: Context): Location {
// get preferences
val settings = PreferenceManager.getDefaultSharedPreferences(context)
val provider: String = settings.getString(Keys.PREF_CURRENT_BEST_LOCATION_PROVIDER, LocationManager.NETWORK_PROVIDER) ?: LocationManager.NETWORK_PROVIDER
// create location
val currentBestLocation: Location = Location(provider)
// load location attributes
currentBestLocation.latitude = settings.getDouble(Keys.PREF_CURRENT_BEST_LOCATION_LATITUDE, Keys.DEFAULT_LATITUDE)
currentBestLocation.longitude = settings.getDouble(Keys.PREF_CURRENT_BEST_LOCATION_LONGITUDE, Keys.DEFAULT_LONGITUDE)
currentBestLocation.accuracy = settings.getFloat(Keys.PREF_CURRENT_BEST_LOCATION_ACCURACY, Keys.DEFAULT_ACCURACY)
currentBestLocation.altitude = settings.getDouble(Keys.PREF_CURRENT_BEST_LOCATION_ALTITUDE, Keys.DEFAULT_ALTITUDE)
currentBestLocation.time = settings.getLong(Keys.PREF_CURRENT_BEST_LOCATION_TIME, Keys.DEFAULT_TIME)
return currentBestLocation
}
/* Saves the state of a map */
fun saveCurrentBestLocation(context: Context, currentBestLocation: Location) {
// get preferences
val settings = PreferenceManager.getDefaultSharedPreferences(context)
val editor = settings.edit()
// save location
editor.putDouble(Keys.PREF_CURRENT_BEST_LOCATION_LATITUDE, currentBestLocation.latitude)
editor.putDouble(Keys.PREF_CURRENT_BEST_LOCATION_LONGITUDE, currentBestLocation.longitude)
editor.putFloat(Keys.PREF_CURRENT_BEST_LOCATION_ACCURACY, currentBestLocation.accuracy)
editor.putDouble(Keys.PREF_CURRENT_BEST_LOCATION_ALTITUDE, currentBestLocation.altitude)
editor.putLong(Keys.PREF_CURRENT_BEST_LOCATION_TIME, currentBestLocation.time)
editor.apply()
}
/* Load state of Night Mode */
fun loadNightModeState(context: Context): Int {
return PreferenceManager.getDefaultSharedPreferences(context).getInt(Keys.PREF_NIGHT_MODE_STATE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}
/* Save state of night mode */
fun saveNightModeState(context: Context, currentState: Int) {
val settings = PreferenceManager.getDefaultSharedPreferences(context)
val editor = settings.edit()
editor.putInt(Keys.PREF_NIGHT_MODE_STATE, currentState)
editor.apply()
}
/* Checks if housekeeping work needs to be done - used usually in DownloadWorker "REQUEST_UPDATE_COLLECTION" */
fun isHouseKeepingNecessary(context: Context): Boolean {
val settings = PreferenceManager.getDefaultSharedPreferences(context)
return settings.getBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, true)
}
/* Saves state of housekeeping */
fun saveHouseKeepingNecessaryState(context: Context, state: Boolean = false) {
val settings = PreferenceManager.getDefaultSharedPreferences(context)
val editor = settings.edit()
editor.putBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, state)
editor.apply()
}
}

View File

@ -1,405 +0,0 @@
/**
* StorageHelper.java
* Implements the StorageHelper class
* A StorageHelper deals with saving and loading recorded tracks
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
import android.content.Context;
import android.os.Environment;
import android.widget.Toast;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.y20k.trackbook.R;
import org.y20k.trackbook.core.Track;
import org.y20k.trackbook.core.TrackBuilder;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.Locale;
import androidx.annotation.Nullable;
import androidx.core.os.EnvironmentCompat;
/**
* StorageHelper class
*/
public class StorageHelper implements TrackbookKeys {
/* Define log tag */
private static final String LOG_TAG = StorageHelper.class.getSimpleName();
/* Main class variables */
private final Context mContext;
private final File mFolder;
private final File mTempFile;
/* Constructor */
public StorageHelper(Context context) {
// store activity
mContext = context;
// get "tracks" folder
mFolder = mContext.getExternalFilesDir(TRACKS_DIRECTORY_NAME);
// mFolder = getTracksDirectory();
// create "tracks" folder if necessary
if (mFolder != null && !mFolder.exists()) {
LogHelper.v(LOG_TAG, "Creating new folder: " + mFolder.toString());
mFolder.mkdirs();
}
// create temp file object // todo check -> may produce NullPointerException
String tempFilePathName = mFolder.toString() + "/" + FILE_NAME_TEMP + FILE_TYPE_TRACKBOOK_EXTENSION;
mTempFile = new File(tempFilePathName);
// delete old track - exclude temp file
deleteOldTracks(false);
}
/* Checks if a temp file exits */
public boolean tempFileExists() {
return mTempFile.exists();
}
/* Deletes temp file - if it exits */
public boolean deleteTempFile() {
return mTempFile.exists() && mTempFile.delete();
}
/* Saves track object to file */
public boolean saveTrack(@Nullable Track track, int fileType) {
Date recordingStart = null;
if (track != null) {
recordingStart = track.getRecordingStart();
}
if (mFolder != null && mFolder.exists() && mFolder.isDirectory() && mFolder.canWrite() && recordingStart != null && track != null) {
// create file object and calculate bounding box and elevation, if necessary
String fileName;
if (fileType == FILE_TEMP_TRACK) {
// get the temp file name
fileName = FILE_NAME_TEMP + FILE_TYPE_TRACKBOOK_EXTENSION;
} else {
// build a regular file name
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US);
fileName = dateFormat.format(recordingStart) + FILE_TYPE_TRACKBOOK_EXTENSION;
// calculate elevation and store it in track
track = MapHelper.calculateElevation(track);
// calculate bounding box and store it in track
track.setBoundingBox(MapHelper.calculateBoundingBox(track.getWayPoints()));
}
File file = new File(mFolder.toString() + "/" + fileName);
// convert track to JSON
Gson gson = getCustomGson();
String json = gson.toJson(track);
// write track
try (BufferedWriter bw = new BufferedWriter(new FileWriter(file))) {
LogHelper.v(LOG_TAG, "Saving track to external storage: " + file.toString());
bw.write(json);
} catch (IOException e) {
LogHelper.e(LOG_TAG, "Unable to saving track to external storage (IOException): " + file.toString());
return false;
}
// if write was successful delete old track files - only if not a temp file
if (fileType != FILE_TEMP_TRACK) {
// include temp file if it exists
deleteOldTracks(true);
}
return true;
} else {
LogHelper.e(LOG_TAG, "Unable to save track to external storage.");
return false;
}
}
/* Loads given file into memory */
public Track loadTrack(int fileType) {
// get file reference
File trackFile;
switch (fileType) {
case FILE_TEMP_TRACK:
trackFile = getTempFile();
break;
case FILE_MOST_CURRENT_TRACK:
trackFile = getMostCurrentTrack();
break;
default:
trackFile = null;
break;
}
// read & parse file and return track
return readTrackFromFile(trackFile);
}
/* Loads given file into memory */
public Track loadTrack(File file) {
// get file reference
File trackFile;
if (file != null) {
trackFile = file;
} else {
// fallback
trackFile = getMostCurrentTrack();
}
// read & parse file and return track
return readTrackFromFile(trackFile);
}
/* Gets a list of .trackbook files - excluding the temp file */
public File[] getListOfTrackbookFiles() {
// TODO HANDLE CASE: EMPTY FILE LIST
// get files and sort them
return sortFiles(mFolder.listFiles());
}
// /* Gets a list of tracks based on their file names */
// public List<String> getListOfTracks() {
// List<String> listOfTracks = new ArrayList<String>();
//
// // get files and sort them
// File[] files = mFolder.listFiles();
// files = sortFiles(files);
//
// for (File file : files) {
// listOfTracks.add(file.getName());
// }
//
// // TODO HANDLE CASE: EMPTY FILE LIST
// return listOfTracks;
// }
// loads file and parses it into a track
private Track readTrackFromFile(File file) {
// check if given file was null
if (file == null) {
LogHelper.e(LOG_TAG, "Did not receive a file object.");
return null;
}
try (BufferedReader br = new BufferedReader(new FileReader(file))) {
LogHelper.v(LOG_TAG, "Loading track from external storage: " + file.toString());
// read until last line reached
String fileContent;
String singleLine;
StringBuilder sb = new StringBuilder("");
while ((singleLine = br.readLine()) != null) {
sb.append(singleLine);
sb.append("\n");
}
fileContent = sb.toString();
// prepare custom Gson and return Track object
Gson gson = getCustomGson();
return gson.fromJson(fileContent, TrackBuilder.class).toTrack();
} catch (IOException e) {
LogHelper.e(LOG_TAG, "Unable to read file from external storage: " + file.toString());
return null;
}
}
/* Creates a Gson object */
private Gson getCustomGson() {
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.setDateFormat("M/d/yy hh:mm a");
return gsonBuilder.create();
}
/* Gets most current track from directory */
private File getMostCurrentTrack() {
if (mFolder != null && mFolder.isDirectory()) {
// get files and sort them
File[] files = mFolder.listFiles();
files = sortFiles(files);
if (files.length > 0 && files[0].getName().endsWith(FILE_TYPE_TRACKBOOK_EXTENSION) && !files[0].equals(mTempFile)){
// return latest track
return files[0];
}
}
LogHelper.e(LOG_TAG, "Unable to get files from given folder. Folder is probably empty.");
return null;
}
/* Gets temp file - if it exists */
private File getTempFile() {
if (mTempFile.exists()) {
return mTempFile;
} else {
return null;
}
}
/* Gets the last track from directory */
private void deleteOldTracks(boolean includeTempFile) {
if (mFolder != null && mFolder.isDirectory()) {
LogHelper.v(LOG_TAG, "Deleting older recordings.");
// get files and sort them
File[] files = mFolder.listFiles();
files = sortFiles(files);
// store length of array
int numberOfFiles = files.length;
// keep the latest ten (mMaxTrackFiles) track files
int index = MAXIMUM_TRACK_FILES;
// iterate through array
while (index < numberOfFiles && files[index].getName().endsWith(FILE_TYPE_TRACKBOOK_EXTENSION) && !files[index].equals(mTempFile)) {
files[index].delete();
index++;
}
}
// delete temp file if it exists
if (includeTempFile && mTempFile.exists()) {
mTempFile.delete();
}
}
/* Sorts array of files in a way that the newest files are at the top and non-.trackbook files are at the bottom */
private File[] sortFiles(File[] files) {
// sort array
LogHelper.v(LOG_TAG, "Sorting files.");
Arrays.sort(files, new Comparator<File>() {
@Override
public int compare(File file1, File file2) {
// discard temp file and files not ending with ".trackbook"
boolean file1IsTrack = file1.getName().endsWith(FILE_TYPE_TRACKBOOK_EXTENSION) && !file1.equals(mTempFile);
boolean file2IsTrack = file2.getName().endsWith(FILE_TYPE_TRACKBOOK_EXTENSION) && !file2.equals(mTempFile);
// note: "greater" means higher index in array
if (!file1IsTrack && file2IsTrack) {
// file1 is not a track, file1 is greater
return 1;
} else if (!file2IsTrack && file1IsTrack) {
// file2 is not a track, file2 is greater
return -1;
} else {
// "compareTo" compares abstract path names lexicographically | 0 == equal | -1 == file2 less than file1 | 1 == file2 greater than file1
return file2.compareTo(file1);
}
}
});
// hand back sorted array of files
return files;
}
/* Return a write-able sub-directory from external storage */
private File getTracksDirectory() {
File[] storage = mContext.getExternalFilesDirs(TRACKS_DIRECTORY_NAME);
for (File file : storage) {
if (file != null) {
String state = EnvironmentCompat.getStorageState(file);
if (Environment.MEDIA_MOUNTED.equals(state)) {
LogHelper.i(LOG_TAG, "External storage: " + file.toString());
return file;
}
}
}
Toast.makeText(mContext, R.string.toast_message_no_external_storage, Toast.LENGTH_LONG).show();
LogHelper.e(LOG_TAG, "Unable to access external storage.");
return null;
}
/* Tries to smooth the elevation data using a low pass filter */
private Track smoothTrack(Track input, float dt, float rc) {
// The following code is adapted from https://en.wikipedia.org/wiki/Low-pass_filter
//
// // Return RC low-pass filter output samples, given input samples,
// // time interval dt, and time constant RC
// function lowpass(real[0..n] x, real dt, real RC)
// var real[0..n] y
// var real α := dt / (RC + dt)
// y[0] := α * x[0]
// for i from 1 to n
// y[i] := α * x[i] + (1-α) * y[i-1]
// return y
// copy input track
Track output = new Track(input);
// calculate alpha
float alpha = dt / (rc + dt);
// set initial value for first waypoint
double outputInitialAltitudeValue = alpha * input.getWayPoints().get(0).getLocation().getAltitude();
output.getWayPoints().get(0).getLocation().setAltitude(outputInitialAltitudeValue);
double inputCurrentAltitudeValue;
double outputPreviousAltitudeValue;
double outputCurrentAltitudeValue;
for (int i = 1; i < input.getSize(); i++) {
inputCurrentAltitudeValue = input.getWayPoints().get(i).getLocation().getAltitude();
outputPreviousAltitudeValue = output.getWayPoints().get(i-1).getLocation().getAltitude();
outputCurrentAltitudeValue = alpha * inputCurrentAltitudeValue + (1 - alpha) * outputPreviousAltitudeValue;
output.getWayPoints().get(i).getLocation().setAltitude(outputCurrentAltitudeValue);
}
return output;
}
}

View File

@ -0,0 +1,194 @@
/*
* TrackHelper.kt
* Implements the TrackHelper object
* A TrackHelper offers helper methods for dealing with track objects
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers
import android.location.Location
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.TracklistElement
import org.y20k.trackbook.core.WayPoint
import java.text.SimpleDateFormat
import java.util.*
/*
* TrackHelper object
*/
object TrackHelper {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(TrackHelper::class.java)
/* Returns unique ID for Track - currently the start date */
fun getTrackId(track: Track): Long {
return track.recordingStart.time
}
/* Returns unique ID for TracklistElement - currently the start date */
fun getTrackId(tracklistElement: TracklistElement): Long {
return tracklistElement.date.time
}
/* Adds given locatiom as waypoint to track */
fun addWayPointToTrack(track: Track, location: Location, locationAccuracyThreshold: Int): Track {
// get previous location
val previousLocation: Location?
val numberOfWayPoints: Int = track.wayPoints.size
if (numberOfWayPoints == 0) {
previousLocation = null
} else {
previousLocation = track.wayPoints.get(numberOfWayPoints - 1).toLocation()
}
// update duration
val now: Date = GregorianCalendar.getInstance().time
val difference: Long = now.time - track.recordingStop.time
track.duration = track.duration + difference
track.recordingStop = now
// add only if recent and accurate
val shouldBeAdded: Boolean
shouldBeAdded = (LocationHelper.isRecentEnough(location)
&& LocationHelper.isAccurateEnough(location, locationAccuracyThreshold)
&& LocationHelper.isDifferentEnough(previousLocation, location))
if (shouldBeAdded) {
// update distance
track.length = track.length + LocationHelper.calculateDistance(previousLocation, location)
if (location.altitude != 0.0) {
// update altitude values
if (numberOfWayPoints == 0) {
track.maxAltitude = location.altitude
track.minAltitude = location.altitude
} else {
// calculate elevation values
val elevationDifferences: Pair<Double, Double> = LocationHelper.calculateElevationDifferences(previousLocation, location, track)
// check if significant differences were calculated
if (elevationDifferences != Pair(track.positiveElevation, track.negativeElevation)) {
// update altitude values
if (location.altitude > track.maxAltitude) track.maxAltitude = location.altitude
if (location.altitude < track.minAltitude) track.minAltitude = location.altitude
// update elevation values
track.positiveElevation = elevationDifferences.first
track.negativeElevation = elevationDifferences.second
}
}
}
// toggle stop over status, if necessary
if (track.wayPoints.size < 0) {
track.wayPoints[track.wayPoints.size - 1].isStopOver = LocationHelper.isStopOver(previousLocation, location)
}
// save number of satellites
val numberOfSatellites: Int
val extras = location.extras
if (extras != null && extras.containsKey("satellites")) {
numberOfSatellites = extras.getInt("satellites", 0)
} else {
numberOfSatellites = 0
}
// add current location as point to center on for later display
track.latitude = location.latitude
track.longitude = location.longitude
// add location as new waypoint
track.wayPoints.add(WayPoint(location.provider, location.latitude, location.longitude, location.altitude, location.accuracy, location.time, track.length, numberOfSatellites))
}
return track
}
/* Creates GPX string for given track */
fun createGpxString(track: Track): String {
var gpxString: String
// add header
gpxString = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\" ?>\n" +
"<gpx version=\"1.1\" creator=\"Transistor App (Android)\"\n" +
" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n" +
" xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd\">\n"
// add track
gpxString += createGpxTrk(track)
// add closing tag
gpxString += "</gpx>\n"
return gpxString
}
/* Creates GPX formatted track */
private fun createGpxTrk(track: Track): String {
val gpxTrack = StringBuilder("")
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
// add opening track tag
gpxTrack.append("\t<trk>\n")
// add name to track
gpxTrack.append("\t\t<name>")
gpxTrack.append("Trackbook Recording: ${track.name}")
gpxTrack.append("</name>\n")
// add opening track segment tag
gpxTrack.append("\t\t<trkseg>\n")
// add route point
track.wayPoints.forEach { wayPoint ->
// add longitude and latitude
gpxTrack.append("\t\t\t<trkpt lat=\"")
gpxTrack.append(wayPoint.latitude)
gpxTrack.append("\" lon=\"")
gpxTrack.append(wayPoint.longitude)
gpxTrack.append("\">\n")
// add time
gpxTrack.append("\t\t\t\t<time>")
gpxTrack.append(dateFormat.format(Date(wayPoint.time)))
gpxTrack.append("</time>\n")
// add altitude
gpxTrack.append("\t\t\t\t<ele>")
gpxTrack.append(wayPoint.altitude)
gpxTrack.append("</ele>\n")
// add closing tag
gpxTrack.append("\t\t\t</trkpt>\n")
}
// add closing track segment tag
gpxTrack.append("\t\t</trkseg>\n")
// add closing track tag
gpxTrack.append("\t</trk>\n")
return gpxTrack.toString()
}
}

View File

@ -1,122 +0,0 @@
/**
* TrackbookKeys.java
* Implements the keys used throughout the app
* This interface hosts all keys used to control Trackbook's state
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.helpers;
/**
* TrackbookKeys.class
*/
public interface TrackbookKeys {
/* ACTIONS */
String ACTION_START = "org.y20k.trackbook.action.START";
String ACTION_STOP = "org.y20k.trackbook.action.STOP";
String ACTION_DISMISS = "org.y20k.transistor.action.DISMISS";
String ACTION_RESUME = "org.y20k.transistor.action.RESUME";
String ACTION_CLEAR = "org.y20k.transistor.action.CLEAR";
String ACTION_SAVE = "org.y20k.transistor.action.SAVE";
String ACTION_DEFAULT = "DEFAULT";
String ACTION_SHOW_MAP = "SHOW_MAP";
String ACTION_TRACK_UPDATED = "TRACK_UPDATED";
String ACTION_TRACK_REQUEST = "TRACK_REQUEST";
String ACTION_TRACKING_STATE_CHANGED = "TRACKING_STATE_CHANGED";
String ACTION_TRACK_SAVE = "TRACK_SAVE";
/* EXTRAS */
String EXTRA_TRACK = "TRACK";
String EXTRA_LAST_LOCATION = "LAST_LOCATION";
String EXTRA_TRACKING_STATE = "TRACKING_STATE";
String EXTRA_INFOSHEET_TITLE = "EXTRA_INFOSHEET_TITLE";
String EXTRA_INFOSHEET_CONTENT = "INFOSHEET_CONTENT";
String EXTRA_SAVE_FINISHED = "SAVE_FINISHED";
/* ARGS */
String ARG_DIALOG_TITLE = "ArgDialogTitle";
String ARG_DIALOG_MESSAGE = "ArgDialogMessage";
String ARG_DIALOG_BUTTON_POSITIVE = "ArgDialogButtonPositive";
String ARG_DIALOG_BUTTON_NEGATIVE = "ArgDialogButtonNegative";
/* PREFS */
String PREFS_FAB_STATE = "fabStatePrefs";
String PREFS_TRACKER_SERVICE_RUNNING = "trackerServiceRunning";
String PREFS_CURRENT_TRACK_DURATION = "currentTrackDuration";
String PREF_NIGHT_MODE_STATE = "prefNightModeState";
/* INSTANCE STATE */
String INSTANCE_FIRST_START = "firstStart";
String INSTANCE_TRACKING_STATE = "trackingState";
String INSTANCE_SELECTED_TAB = "selectedTab";
String INSTANCE_FAB_SUB_MENU_VISIBLE = "fabSubMenuVisible";
String INSTANCE_LATITUDE_MAIN_MAP = "latitudeMainMap";
String INSTANCE_LONGITUDE_MAIN_MAP = "longitudeMainMap";
String INSTANCE_ZOOM_LEVEL_MAIN_MAP = "zoomLevelMainMap";
String INSTANCE_TRACK_TRACK_MAP = "trackTrackMap";
String INSTANCE_LATITUDE_TRACK_MAP = "latitudeTrackMap";
String INSTANCE_LONGITUDE_TRACK_MAP = "longitudeTrackMap";
String INSTANCE_ZOOM_LEVEL_TRACK_MAP = "zoomLevelTrackMap";
String INSTANCE_CURRENT_LOCATION = "currentLocation";
String INSTANCE_CURRENT_TRACK = "currentTrack";
/* FRAGMENT IDS */
int FRAGMENT_ID_MAP = 0;
int FRAGMENT_ID_TRACKS = 1;
/* RESULTS */
int RESULT_SAVE_DIALOG = 1;
int RESULT_CLEAR_DIALOG = 2;
int RESULT_DELETE_DIALOG = 3;
int RESULT_EXPORT_DIALOG = 4;
int RESULT_EMPTY_RECORDING_DIALOG = 5;
/* CONSTANTS */
long ONE_SECOND_IN_NANOSECOND = 1000000000L;
long EIGHT_HOURS_IN_MILLISECONDS = 43200000; // maximum tracking duration
long FIFTEEN_SECONDS_IN_MILLISECONDS = 15000; // timer interval for tracking
long FIVE_MINUTES_IN_NANOSECONDS = 5L * 60000000000L; // determines a stop over
long ONE_MINUTE_IN_NANOSECONDS = 1L * 60000000000L; // defines an old location
int MAXIMUM_TRACK_FILES = 25;
int FIFTY_METER_RADIUS = 50;
/* FILE */
String FILE_TYPE_GPX_EXTENSION = ".gpx";
String FILE_TYPE_TRACKBOOK_EXTENSION = ".trackbook";
String FILE_NAME_TEMP = "temp";
String TRACKS_DIRECTORY_NAME = "tracks";
int FILE_TEMP_TRACK = 0;
int FILE_MOST_CURRENT_TRACK = 1;
/* UNITS */
int METRIC = 1;
int IMPERIAL = -1;
/* FLOATING ACTION BUTTON */
int FAB_STATE_DEFAULT = 0;
int FAB_STATE_RECORDING = 1;
int FAB_STATE_SAVE = 2;
/* NOTIFICATION */
int TRACKER_SERVICE_NOTIFICATION_ID = 1;
String NOTIFICATION_CHANEL_ID_RECORDING_CHANNEL ="notificationChannelIdRecordingChannel";
/* MISC */
int CURRENT_TRACK_FORMAT_VERSION = 3; // incremental version number to prevent issues in case the Track format evolves
double DEFAULT_LATITUDE = 71.172500; // latitude Nordkapp, Norway
double DEFAULT_LONGITUDE = 25.784444; // longitude Nordkapp, Norway
int MEASUREMENT_ERROR_THRESHOLD = 10; // altitude changes of 10 meter or more (per 15 seconds) are being discarded
int REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS = 124;
}

View File

@ -0,0 +1,130 @@
/*
* UiHelper.kt
* Implements the UiHelper object
* A UiHelper provides helper methods for User Interface related tasks
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.escapepod.helpers
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.drawable.ColorDrawable
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.y20k.trackbook.R
import org.y20k.trackbook.helpers.LogHelper
/*
* UiHelper object
*/
object UiHelper {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(UiHelper::class.java)
/* Sets layout margins for given view in DP */
fun setViewMargins(context: Context, view: View, left: Int = 0, right: Int = 0, top: Int= 0, bottom: Int = 0) {
val scalingFactor: Float = context.resources.displayMetrics.density
val l: Int = (left * scalingFactor).toInt()
val r: Int = (right * scalingFactor).toInt()
val t: Int = (top * scalingFactor).toInt()
val b: Int = (bottom * scalingFactor).toInt()
if (view.layoutParams is ViewGroup.MarginLayoutParams) {
val p = view.layoutParams as ViewGroup.MarginLayoutParams
p.setMargins(l, t, r, b)
view.requestLayout()
}
}
/* Sets layout margins for given view in percent */
fun setViewMarginsPercentage(context: Context, view: View, height: Int, width: Int, left: Int = 0, right: Int = 0, top: Int= 0, bottom: Int = 0) {
val l: Int = ((width / 100.0f) * left).toInt()
val r: Int = ((width / 100.0f) * right).toInt()
val t: Int = ((height / 100.0f) * top).toInt()
val b: Int = ((height / 100.0f) * bottom).toInt()
setViewMargins(context, view, l, r, t, b)
}
/*
* Inner class: Callback that detects a left swipe
* Credit: https://github.com/kitek/android-rv-swipe-delete/blob/master/app/src/main/java/pl/kitek/rvswipetodelete/SwipeToDeleteCallback.kt
*/
abstract class SwipeToDeleteCallback(context: Context): ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
private val deleteIcon = ContextCompat.getDrawable(context, R.drawable.ic_remove_circle_24dp)
private val intrinsicWidth: Int = deleteIcon?.intrinsicWidth ?: 0
private val intrinsicHeight: Int = deleteIcon?.intrinsicHeight ?: 0
private val background: ColorDrawable = ColorDrawable()
private val backgroundColor = context.resources.getColor(R.color.list_card_delete_background, null)
private val clearPaint: Paint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) }
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
// do nothing
return false
}
override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) {
val itemView = viewHolder.itemView
val itemHeight = itemView.bottom - itemView.top
val isCanceled = dX == 0f && !isCurrentlyActive
if (isCanceled) {
clearCanvas(c, itemView.right + dX, itemView.top.toFloat(), itemView.right.toFloat(), itemView.bottom.toFloat())
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
return
}
// draw red delete background
background.color = backgroundColor
background.setBounds(
itemView.right + dX.toInt(),
itemView.top,
itemView.right,
itemView.bottom
)
background.draw(c)
// calculate position of delete icon
val deleteIconTop = itemView.top + (itemHeight - intrinsicHeight) / 2
val deleteIconMargin = (itemHeight - intrinsicHeight) / 2
val deleteIconLeft = itemView.right - deleteIconMargin - intrinsicWidth
val deleteIconRight = itemView.right - deleteIconMargin
val deleteIconBottom = deleteIconTop + intrinsicHeight
// draw delete icon
deleteIcon?.setBounds(deleteIconLeft, deleteIconTop, deleteIconRight, deleteIconBottom)
deleteIcon?.draw(c)
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
private fun clearCanvas(c: Canvas?, left: Float, top: Float, right: Float, bottom: Float) {
c?.drawRect(left, top, right, bottom, clearPaint)
}
}
/*
* End of inner class
*/
}

View File

@ -1,60 +0,0 @@
/**
* DodgeAbleLayoutBehavior.java
* Implements the DodgeAbleLayoutBehavior class
* A DodgeAbleLayoutBehavior enables any element to be dodged up by a snackbar
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.layout;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import com.google.android.material.snackbar.Snackbar;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
/**
* DodgeAbleLayoutBehavior class
* adapted from: http://stackoverflow.com/a/35904421
*/
public class DodgeAbleLayoutBehavior extends CoordinatorLayout.Behavior<View> {
/* Constructor (default) */
public DodgeAbleLayoutBehavior() {
super();
}
/* Constructor for context and attributes */
public DodgeAbleLayoutBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
return dependency instanceof Snackbar.SnackbarLayout;
}
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
float translationY = Math.min(0, dependency.getTranslationY() - dependency.getHeight());
child.setTranslationY(translationY);
return true;
}
}

View File

@ -1,102 +0,0 @@
/**
* NonSwipeableViewPager.java
* Implements the NonSwipeableViewPager class
* A NonSwipeableViewPager is a ViewPager with swiping gestures disabled
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-19 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.layout;
/**
* NonSwipeableViewPager class
* adapted from: http://stackoverflow.com/a/9650884
*/
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.animation.DecelerateInterpolator;
import android.widget.Scroller;
import org.y20k.trackbook.helpers.LogHelper;
import java.lang.reflect.Field;
import androidx.viewpager.widget.ViewPager;
public class NonSwipeableViewPager extends ViewPager {
/* Define log tag */
private static final String LOG_TAG = NonSwipeableViewPager.class.getSimpleName();
/* Constructor */
public NonSwipeableViewPager(Context context) {
super(context);
setMyScroller();
}
/* Constructor */
public NonSwipeableViewPager(Context context, AttributeSet attrs) {
super(context, attrs);
setMyScroller();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
// Never allow swiping to switch between pages
return false;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// Never allow swiping to switch between pages
return false;
}
/* Attaches a custom smooth scrolling scroller to a ViewPager */
private void setMyScroller() {
try {
Class<?> viewpager = ViewPager.class;
Field scroller = viewpager.getDeclaredField("mScroller");
scroller.setAccessible(true);
scroller.set(this, new MyScroller(getContext()));
} catch (Exception e) {
LogHelper.e(LOG_TAG, "Problem accessing or modifying the mScroller field. Exception: " + e);
e.printStackTrace();
}
}
/**
* Inner class: MyScroller is a custom Scroller
*/
public class MyScroller extends Scroller {
public MyScroller(Context context) {
super(context, new DecelerateInterpolator());
}
@Override
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
super.startScroll(startX, startY, dx, dy, 350 /*1 secs*/);
}
}
/**
* End of inner class
*/
}

View File

@ -0,0 +1,208 @@
/*
* TracklistAdapter.kt
* Implements the TracklistAdapter class
* A TracklistAdapter is a custom adapter for a RecyclerView
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.tracklist
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.*
import org.y20k.trackbook.R
import org.y20k.trackbook.core.Tracklist
import org.y20k.trackbook.core.TracklistElement
import org.y20k.trackbook.helpers.*
import java.util.*
/*
* TracklistAdapter class
*/
class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(TracklistAdapter::class.java)
/* Main class variables */
private val context: Context = fragment.activity as Context
private lateinit var tracklistListener: TracklistAdapterListener
private var useImperial: Boolean = PreferencesHelper.loadUseImperialUnits(context)
private var tracklist: Tracklist = Tracklist()
/* Listener Interface */
interface TracklistAdapterListener {
fun onTrackElementTapped(tracklistElement: TracklistElement) { }
// fun onTrackElementStarred(trackId: Long, starred: Boolean)
}
/* Overrides onAttachedToRecyclerView from RecyclerView.Adapter */
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
// get reference to listener
tracklistListener = fragment as TracklistAdapterListener
// load tracklist
tracklist = FileHelper.readTracklist(context)
tracklist.tracklistElements.sortByDescending { tracklistElement -> tracklistElement.date }
}
/* Overrides onCreateViewHolder from RecyclerView.Adapter */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val v = LayoutInflater.from(parent.context).inflate(R.layout.track_element, parent, false)
return TrackElementViewHolder(v)
}
/* Overrides getItemCount from RecyclerView.Adapter */
override fun getItemCount(): Int {
return tracklist.tracklistElements.size
}
/* Overrides onBindViewHolder from RecyclerView.Adapter */
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val trackElementViewHolder: TrackElementViewHolder = holder as TrackElementViewHolder
trackElementViewHolder.trackNameView.text = tracklist.tracklistElements[position].name
trackElementViewHolder.trackDataView.text = createTrackDataString(position)
when (tracklist.tracklistElements[position].starred) {
true -> trackElementViewHolder.starButton.setImageDrawable(context.getDrawable(R.drawable.ic_star_24dp))
false -> trackElementViewHolder.starButton.setImageDrawable(context.getDrawable(R.drawable.ic_star_border_24dp))
}
trackElementViewHolder.trackElement.setOnClickListener {
tracklistListener.onTrackElementTapped(tracklist.tracklistElements[position])
}
trackElementViewHolder.starButton.setOnClickListener {
toggleStarred(it, position)
}
}
/* Get track name for given position */
fun getTrackName(position: Int): String {
return tracklist.tracklistElements[position].name
}
/* Removes track and track files for given position - used by TracklistFragment */
fun removeTrack(context: Context, position: Int) {
val backgroundJob = Job()
val uiScope = CoroutineScope(Dispatchers.Main + backgroundJob)
uiScope.launch {
notifyItemRemoved(position)
val deferred: Deferred<Tracklist> = async { FileHelper.deleteTrackSuspended(context, position, tracklist) }
// wait for result and store in tracklist
tracklist = deferred.await()
backgroundJob.cancel()
}
}
/* Finds current position of track element in adapter list */
fun findPosition(trackId: Long): Int {
tracklist.tracklistElements.forEachIndexed {index, tracklistElement ->
if (tracklistElement.getTrackId() == trackId) return index
}
return -1
}
/* Toggles the starred state of tracklist element - and saves tracklist */
private fun toggleStarred(view: View, position: Int) {
val starButton: ImageButton = view as ImageButton
when (tracklist.tracklistElements[position].starred) {
true -> {
starButton.setImageDrawable(context.getDrawable(R.drawable.ic_star_border_24dp))
tracklist.tracklistElements[position].starred = false
}
false -> {
starButton.setImageDrawable(context.getDrawable(R.drawable.ic_star_24dp))
tracklist.tracklistElements[position].starred = true
}
}
GlobalScope.launch {
FileHelper.saveTracklistSuspended(context, tracklist, GregorianCalendar.getInstance().time)
}
}
/* Creates the track data string */
private fun createTrackDataString(position: Int): String {
val tracklistElement: TracklistElement = tracklist.tracklistElements[position]
val trackDataString: String
when (tracklistElement.name == tracklistElement.dateString) {
// CASE: no individual name set - exclude date
true -> trackDataString = "${LengthUnitHelper.convertDistanceToString(tracklistElement.length, useImperial)}${tracklistElement.durationString}"
// CASE: no individual name set - include date
false -> trackDataString = "${tracklistElement.dateString}${LengthUnitHelper.convertDistanceToString(tracklistElement.length, useImperial)}${tracklistElement.durationString}"
}
return trackDataString
}
/*
* Inner class: DiffUtil.Callback that determines changes in data - improves list performance
*/
private inner class DiffCallback(val oldList: Tracklist, val newList: Tracklist): DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList.tracklistElements[oldItemPosition]
val newItem = newList.tracklistElements[newItemPosition]
return TrackHelper.getTrackId(oldItem) == TrackHelper.getTrackId(newItem)
}
override fun getOldListSize(): Int {
return oldList.tracklistElements.size
}
override fun getNewListSize(): Int {
return newList.tracklistElements.size
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList.tracklistElements[oldItemPosition]
val newItem = newList.tracklistElements[newItemPosition]
return TrackHelper.getTrackId(oldItem) == TrackHelper.getTrackId(newItem) && oldItem.length == newItem.length
}
}
/*
* End of inner class
*/
/*
* Inner class: ViewHolder for a track element
*/
private inner class TrackElementViewHolder (trackElementLayout: View): RecyclerView.ViewHolder(trackElementLayout) {
val trackElement: ConstraintLayout = trackElementLayout.findViewById(R.id.track_element)
val trackNameView: TextView = trackElementLayout.findViewById(R.id.track_name)
val trackDataView: TextView = trackElementLayout.findViewById(R.id.track_data)
val starButton: ImageButton = trackElementLayout.findViewById(R.id.star_button)
}
/*
* End of inner class
*/
}

View File

@ -0,0 +1,233 @@
/*
* MapFragmentLayoutHolder.kt
* Implements the MapFragmentLayoutHolder class
* A MapFragmentLayoutHolder hold references to the main views of a map fragment
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.ui
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.Group
import androidx.core.content.ContextCompat
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import org.osmdroid.api.IMapController
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.ItemizedIconOverlay
import org.osmdroid.views.overlay.OverlayItem
import org.osmdroid.views.overlay.TilesOverlay
import org.osmdroid.views.overlay.compass.CompassOverlay
import org.osmdroid.views.overlay.compass.InternalCompassOrientationProvider
import org.y20k.trackbook.Keys
import org.y20k.trackbook.R
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.helpers.LogHelper
import org.y20k.trackbook.helpers.MapHelper
import org.y20k.trackbook.helpers.NightModeHelper
import org.y20k.trackbook.helpers.PreferencesHelper
/*
* MapFragmentLayoutHolder class
*/
data class MapFragmentLayoutHolder(var context: Context, var inflater: LayoutInflater, var container: ViewGroup?, val startLocation: Location, val trackingState: Int) {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(MapFragmentLayoutHolder::class.java)
/* Main class variables */
val rootView: View
val mapView: MapView
val currentLocationButton: FloatingActionButton
val recordingButton: FloatingActionButton
val recordingButtonSubMenu: Group
val saveButton: FloatingActionButton
val clearButton: FloatingActionButton
val resumeButton: FloatingActionButton
var userInteraction: Boolean = false
private var currentPositionOverlay: ItemizedIconOverlay<OverlayItem>
private var currentTrackOverlay: ItemizedIconOverlay<OverlayItem>?
private var locationErrorBar: Snackbar
private var controller: IMapController
private var zoomLevel: Double
/* Init block */
init {
// find views
rootView = inflater.inflate(R.layout.fragment_map, container, false)
mapView = rootView.findViewById(R.id.map)
currentLocationButton = rootView.findViewById(R.id.fab_location_button)
recordingButton = rootView.findViewById(R.id.fab_main_button)
recordingButtonSubMenu = rootView.findViewById(R.id.fab_sub_menu)
saveButton = rootView.findViewById(R.id.fab_sub_menu_button_save)
clearButton = rootView.findViewById(R.id.fab_sub_menu_button_clear)
resumeButton = rootView.findViewById(R.id.fab_sub_menu_button_resume)
locationErrorBar = Snackbar.make(mapView, String(), Snackbar.LENGTH_INDEFINITE)
// basic map setup
controller = mapView.controller
mapView.isTilesScaledToDpi = true
mapView.setTileSource(TileSourceFactory.MAPNIK)
mapView.setMultiTouchControls(true)
mapView.zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
zoomLevel = PreferencesHelper.loadZoomLevel(context)
controller.setZoom(zoomLevel)
// set dark map tiles, if necessary
if (NightModeHelper.isNightModeOn(context as Activity)) {
mapView.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS)
}
// add compass to map
val compassOverlay = CompassOverlay(context, InternalCompassOrientationProvider(context), mapView)
compassOverlay.enableCompass()
compassOverlay.setCompassCenter(36f, 60f)
mapView.overlays.add(compassOverlay)
// add my location overlay
currentPositionOverlay = MapHelper.createMyLocationOverlay(context, startLocation, trackingState)
mapView.overlays.add(currentPositionOverlay)
centerMap(startLocation)
// initialize track overlay
currentTrackOverlay = null
// initialize recording button state
updateRecordingButton(trackingState)
// add touch listeners
addTouchListeners()
// listen for user interaction
addInteractionListener()
}
/* Listen for user interaction */
@SuppressLint("ClickableViewAccessibility")
private fun addInteractionListener() {
mapView.setOnTouchListener { v, event ->
userInteraction = true
false
}
}
/* Set map center */
fun centerMap(location: Location, animated: Boolean = false) {
val position = GeoPoint(location.latitude, location.longitude)
when (animated) {
true -> controller.animateTo(position)
false -> controller.setCenter(position)
}
userInteraction = false
}
/* Save current best location and state of map to shared preferences */
fun saveState(currentBestLocation: Location) {
PreferencesHelper.saveCurrentBestLocation(context, currentBestLocation)
PreferencesHelper.saveZoomLevel(context, mapView.getZoomLevelDouble())
// reset user interaction state
userInteraction = false
}
/* Mark current position on map */
fun markCurrentPosition(location: Location, trackingState: Int = Keys.STATE_NOT_TRACKING) {
mapView.overlays.remove(currentPositionOverlay)
currentPositionOverlay = MapHelper.createMyLocationOverlay(context, location, trackingState)
mapView.overlays.add(currentPositionOverlay)
}
/* Overlay current track on map */
fun overlayCurrentTrack(track: Track, trackingState: Int) {
if (currentTrackOverlay != null) {
mapView.overlays.remove(currentTrackOverlay)
}
if (track.wayPoints.isNotEmpty()) {
currentTrackOverlay = MapHelper.createTrackOverlay(context, track, trackingState)
mapView.overlays.add(currentTrackOverlay)
}
}
/* Toggles state of recording button and sub menu_bottom_navigation */
fun updateRecordingButton(trackingState: Int) {
when (trackingState) {
Keys.STATE_NOT_TRACKING -> {
recordingButton.setImageResource(R.drawable.ic_fiber_manual_record_white_24dp)
recordingButtonSubMenu.visibility = View.GONE
}
Keys.STATE_TRACKING_ACTIVE -> {
recordingButton.setImageResource(R.drawable.ic_fiber_manual_record_red_24dp)
recordingButtonSubMenu.visibility = View.GONE
}
Keys.STATE_TRACKING_STOPPED -> {
recordingButton.setImageResource(R.drawable.ic_save_white_24dp)
}
}
}
/* Toggles visibility of recording button sub menu_bottom_navigation */
fun toggleRecordingButtonSubMenu() {
when (recordingButtonSubMenu.visibility) {
View.VISIBLE -> recordingButtonSubMenu.visibility = View.GONE
else -> recordingButtonSubMenu.visibility = View.VISIBLE
}
}
/* Toggles content and visibility of the location error snackbar */
fun toggleLocationErrorBar(gpsProviderActive: Boolean, networkProviderActive: Boolean) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_DENIED) {
// CASE: Location permission not granted
locationErrorBar.setText(R.string.snackbar_message_location_permission_denied)
locationErrorBar.show()
} else if (!gpsProviderActive && !networkProviderActive) {
// CASE: Location setting is off
locationErrorBar.setText(R.string.snackbar_message_location_offline)
locationErrorBar.show()
} else if (locationErrorBar.isShown) {
// CASE: Snackbar is visible but unnecessary
locationErrorBar.dismiss()
}
}
/* Sets up views - adds touch listeners */
private fun addTouchListeners() {
currentLocationButton.setOnLongClickListener {
NightModeHelper.switchMode(context as Activity)
return@setOnLongClickListener true
}
}
}

View File

@ -0,0 +1,254 @@
/*
* TrackFragmentLayoutHolder.kt
* Implements the TrackFragmentLayoutHolder class
* A TrackFragmentLayoutHolder hold references to the main views of a track fragment
*
* This file is part of
* TRACKBOOK - Movement Recorder for Android
*
* Copyright (c) 2016-20 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
* https://github.com/osmdroid/osmdroid
*/
package org.y20k.trackbook.ui
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.Toast
import androidx.constraintlayout.widget.Group
import androidx.core.widget.NestedScrollView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.textview.MaterialTextView
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.osmdroid.api.IGeoPoint
import org.osmdroid.api.IMapController
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.ItemizedIconOverlay
import org.osmdroid.views.overlay.OverlayItem
import org.osmdroid.views.overlay.TilesOverlay
import org.osmdroid.views.overlay.compass.CompassOverlay
import org.osmdroid.views.overlay.compass.InternalCompassOrientationProvider
import org.y20k.trackbook.Keys
import org.y20k.trackbook.R
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.helpers.*
import kotlin.math.roundToInt
/*
* TrackFragmentLayoutHolder class
*/
data class TrackFragmentLayoutHolder(var context: Context, var inflater: LayoutInflater, var container: ViewGroup?, var arguments: Bundle?) {
/* Define log tag */
private val TAG: String = LogHelper.makeLogTag(TrackFragmentLayoutHolder::class.java)
/* Main class variables */
val rootView: View
val track: Track
val shareButton: ImageButton
val deleteButton: ImageButton
val editButton: ImageButton
val trackNameView: MaterialTextView
private val mapView: MapView
private var trackOverlay: ItemizedIconOverlay<OverlayItem>?
private var controller: IMapController
private var zoomLevel: Double
private val statisticsSheetBehavior: BottomSheetBehavior<View>
private val statisticsSheet: NestedScrollView
private val statisticsView: View
private val distanceView: MaterialTextView
private val stepsView: MaterialTextView
private val waypointsView: MaterialTextView
private val durationView: MaterialTextView
private val recordingStartView: MaterialTextView
private val recordingStopView: MaterialTextView
private val maxAltitudeView: MaterialTextView
private val minAltitudeView: MaterialTextView
private val positiveElevationView: MaterialTextView
private val negativeElevationView: MaterialTextView
private val elevationDataViews: Group
private val trackManagementViews: Group
private val useImperialUnits: Boolean
/* Init block */
init {
// find views
rootView = inflater.inflate(R.layout.fragment_track, container, false)
mapView = rootView.findViewById(R.id.map)
shareButton = rootView.findViewById(R.id.share_button)
deleteButton = rootView.findViewById(R.id.delete_button)
editButton = rootView.findViewById(R.id.edit_button)
trackNameView = rootView.findViewById(R.id.statistics_track_name_headline)
// basic map setup
controller = mapView.controller
mapView.isTilesScaledToDpi = true
mapView.setTileSource(TileSourceFactory.MAPNIK)
mapView.setMultiTouchControls(true)
mapView.zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
zoomLevel = Keys.DEFAULT_ZOOM_LEVEL
controller.setZoom(zoomLevel)
// get views for statistics sheet
statisticsSheet = rootView.findViewById(R.id.statistics_sheet)
statisticsView = rootView.findViewById(R.id.statistics_view)
distanceView = rootView.findViewById(R.id.statistics_data_distance)
stepsView = rootView.findViewById(R.id.statistics_data_steps)
waypointsView = rootView.findViewById(R.id.statistics_data_waypoints)
durationView = rootView.findViewById(R.id.statistics_data_duration)
recordingStartView = rootView.findViewById(R.id.statistics_data_recording_start)
recordingStopView = rootView.findViewById(R.id.statistics_data_recording_stop)
maxAltitudeView = rootView.findViewById(R.id.statistics_data_max_altitude)
minAltitudeView = rootView.findViewById(R.id.statistics_data_min_altitude)
positiveElevationView = rootView.findViewById(R.id.statistics_data_positive_elevation)
negativeElevationView = rootView.findViewById(R.id.statistics_data_negative_elevation)
elevationDataViews = rootView.findViewById(R.id.elevation_data)
trackManagementViews = rootView.findViewById(R.id.management_icons)
// get measurement unit system
useImperialUnits = PreferencesHelper.loadUseImperialUnits(context)
// set dark map tiles, if necessary
if (NightModeHelper.isNightModeOn(context as Activity)) {
mapView.getOverlayManager().getTilesOverlay().setColorFilter(TilesOverlay.INVERT_COLORS)
}
// add compass to map
val compassOverlay = CompassOverlay(context, InternalCompassOrientationProvider(context), mapView)
compassOverlay.enableCompass()
compassOverlay.setCompassCenter(36f, 60f)
mapView.overlays.add(compassOverlay)
// get track and create map overlay
val fileUriString: String = arguments?.getString(Keys.ARG_TRACK_FILE_URI, String()) ?: String()
if (fileUriString.isNotBlank()) {
track = FileHelper.readTrack(context, Uri.parse(fileUriString))
} else {
track = Track()
}
trackOverlay = MapHelper.createTrackOverlay(context, track, Keys.STATE_NOT_TRACKING)
if (track.wayPoints.isNotEmpty()) {
mapView.overlays.add(trackOverlay)
}
// set up and show statistics sheet
statisticsSheetBehavior = BottomSheetBehavior.from<View>(statisticsSheet)
statisticsSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
statisticsSheetBehavior.addBottomSheetCallback(getStatisticsSheetCallback())
setupStatisticsViews()
}
/* Updates zoom level and center of this map */
fun updateMapView() {
val position = GeoPoint(track.latitude, track.longitude)
controller.setCenter(position)
controller.setZoom(track.zoomLevel)
}
/* Saves zoom level and center of this map */
fun saveViewStateToTrack() {
val center: IGeoPoint = mapView.mapCenter
track.latitude = center.latitude
track.longitude = center.longitude
track.zoomLevel = mapView.zoomLevelDouble
GlobalScope.launch { FileHelper.saveTrackSuspended(track, false) }
}
/* Sets up the statistics sheet */
private fun setupStatisticsViews() {
// get step count string
val steps: String
if (track.stepCount == -1f) steps = context.getString(R.string.statistics_sheet_p_steps_no_pedometer)
else steps = track.stepCount.roundToInt().toString()
// populate views
trackNameView.text = track.name
distanceView.text = LengthUnitHelper.convertDistanceToString(track.length, useImperialUnits)
stepsView.text = steps
waypointsView.text = track.wayPoints.size.toString()
durationView.text = DateTimeHelper.convertToReadableTime(context, track.duration)
recordingStartView.text = DateTimeHelper.convertToReadableDate(track.recordingStart)
recordingStopView.text = DateTimeHelper.convertToReadableDate(track.recordingStart)
maxAltitudeView.text = LengthUnitHelper.convertDistanceToString(track.maxAltitude, useImperialUnits)
minAltitudeView.text = LengthUnitHelper.convertDistanceToString(track.minAltitude, useImperialUnits)
positiveElevationView.text = LengthUnitHelper.convertDistanceToString(track.positiveElevation, useImperialUnits)
negativeElevationView.text = LengthUnitHelper.convertDistanceToString(track.negativeElevation, useImperialUnits)
// inform user about possible accuracy issues with altitude measurements
elevationDataViews.referencedIds.forEach { id ->
(rootView.findViewById(id) as View).setOnClickListener{
Toast.makeText(context, R.string.toast_message_elevation_info, Toast.LENGTH_LONG).show()
}
}
// make track name on statistics sheet clickable
trackNameView.setOnClickListener {
toggleStatisticsSheetVisibility()
}
}
/* Shows/hides the statistics sheet */
private fun toggleStatisticsSheetVisibility() {
when (statisticsSheetBehavior.state) {
BottomSheetBehavior.STATE_EXPANDED -> statisticsSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
else -> statisticsSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
/* Defines the behavior of the statistics sheet */
private fun getStatisticsSheetCallback(): BottomSheetBehavior.BottomSheetCallback {
return object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> {
statisticsSheet.background = context.getDrawable(R.drawable.shape_statistics_background_expanded)
trackManagementViews.visibility = View.VISIBLE
shareButton.visibility = View.GONE
// bottomSheet.setPadding(0,24,0,0)
}
else -> {
statisticsSheet.background = context.getDrawable(R.drawable.shape_statistics_background_collapsed)
trackManagementViews.visibility = View.GONE
shareButton.visibility = View.VISIBLE
// bottomSheet.setPadding(0,0,0,0)
}
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (slideOffset < 0.125f) {
statisticsSheet.background = context.getDrawable(R.drawable.shape_statistics_background_collapsed)
trackManagementViews.visibility = View.GONE
shareButton.visibility = View.VISIBLE
} else {
statisticsSheet.background = context.getDrawable(R.drawable.shape_statistics_background_expanded)
trackManagementViews.visibility = View.VISIBLE
shareButton.visibility = View.GONE
}
}
}
}
}

View File

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0"/>
<item
android:color="#00000000"
android:offset="1.0"/>
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="48dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="48dp">
<path android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,5c1.66,0 3,1.34 3,3s-1.34,3 -3,3 -3,-1.34 -3,-3 1.34,-3 3,-3zM12,19.2c-2.5,0 -4.71,-1.28 -6,-3.22 0.03,-1.99 4,-3.08 6,-3.08 1.99,0 5.97,1.09 6,3.08 -1.29,1.94 -3.5,3.22 -6,3.22z"/>
</vector>

View File

@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:fillColor="@color/trackbook_black"
android:pathData="M21,3L3,10.53v0.98l6.84,2.65L12.48,21h0.98L21,3z"/>
</vector>

View File

@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/location_button_icon"
android:fillColor="@color/location_button_background"
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94L13,1h-2v2.06C6.83,3.52 3.52,6.83 3.06,11L1,11v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94L11,23h2v-2.06c4.17,-0.46 7.48,-3.77 7.94,-7.94L23,13v-2h-2.06zM12,19c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"/>
</vector>

View File

@ -5,5 +5,5 @@
android:viewportHeight="24.0">
<path
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L18,7L6,7v12zM8.46,11.88l1.41,-1.41L12,12.59l2.12,-2.12 1.41,1.41L13.41,14l2.12,2.12 -1.41,1.41L12,15.41l-2.12,2.12 -1.41,-1.41L10.59,14l-2.13,-2.12zM15.5,4l-1,-1h-5l-1,1L5,4v2h14L19,4z"
android:fillColor="@color/track_management_icons" />
android:fillColor="@color/icon_default" />
</vector>

View File

@ -0,0 +1,9 @@
<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="@color/icon_default"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View File

@ -0,0 +1,9 @@
<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="#FF000000"
android:pathData="M20,4L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM20,8l-8,5 -8,-5L4,6l8,5 8,-5v2z"/>
</vector>

View File

@ -5,5 +5,5 @@
android:viewportHeight="24.0">
<path
android:pathData="M11,17h2v-6h-2v6zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM11,9h2L13,7h-2v2z"
android:fillColor="@color/statistic_sheet_icons" />
android:fillColor="@color/icon_default" />
</vector>

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path android:fillColor="#008577"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="36dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="36dp">
<path
android:fillColor="@color/trackbook_black"
android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="36dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="36dp">
<path
android:fillColor="@color/trackbook_black"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="36dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="36dp">
<path
android:fillColor="@color/trackbook_black"
android:pathData="M6,6h12v12H6z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
</vector>

View File

@ -0,0 +1,9 @@
<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="#FF000000"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>

View File

@ -0,0 +1,9 @@
<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="#FF000000"
android:pathData="M21,6h-2v9L6,15v2c0,0.55 0.45,1 1,1h11l4,4L22,7c0,-0.55 -0.45,-1 -1,-1zM17,12L17,3c0,-0.55 -0.45,-1 -1,-1L3,2c-0.55,0 -1,0.45 -1,1v14l4,-4h10c0.55,0 1,-0.45 1,-1z"/>
</vector>

View File

@ -0,0 +1,9 @@
<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="@color/trackbook_white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM17,13L7,13v-2h10v2z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98s-0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.39,-0.3 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98s0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.39,0.3 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5 3.5,1.57 3.5,3.5 -1.57,3.5 -3.5,3.5z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/trackbook_black"
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z"/>
</vector>

View File

@ -5,5 +5,5 @@
android:viewportHeight="24.0">
<path
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"
android:fillColor="@color/track_management_icons" />
android:fillColor="@color/icon_default" />
</vector>

View File

@ -0,0 +1,9 @@
<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="@color/icon_star_selected"
android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
</vector>

View File

@ -0,0 +1,9 @@
<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="@color/icon_lightweight"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/>
</vector>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="false" android:color="@color/bottom_navigation_element" />
<item android:state_checked="true" android:color="@color/bottom_navigation_element" />
<item android:color="@color/trackbook_grey_lighter" />
<item android:state_checked="true" android:color="@color/bottom_navigation_element_selected" />
</selector>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:top="0dp"
android:left="-2dp"
android:right="-2dp"
android:bottom="-2dp">
<shape
android:shape="rectangle">
<solid android:color="@color/statistic_sheet_background_collapsed" />
<corners android:topLeftRadius="20dp" android:topRightRadius="20dp" />
<stroke android:width="1dp" android:color="@color/statistic_sheet_background_border"/>
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:top="0dp"
android:left="-2dp"
android:right="-2dp"
android:bottom="-2dp">
<shape
android:shape="rectangle">
<solid android:color="@color/statistic_sheet_background_expanded" />
<corners android:topLeftRadius="20dp" android:topRightRadius="20dp" />
<stroke android:width="1dp" android:color="@color/statistic_sheet_background_border"/>
</shape>
</item>
</layer-list>

236
app/src/main/res/layout/activity_main.xml Executable file → Normal file
View File

@ -1,220 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<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:id="@+id/root_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context="org.y20k.trackbook.MainActivity">
tools:context=".MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/container"
<fragment
android:id="@+id/main_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- BUTTON MY LOCATION -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabLocationButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:clickable="true"
android:contentDescription="@string/descr_fab_my_location"
android:focusable="true"
app:backgroundTint="@color/location_button_background"
app:fabSize="mini"
app:layout_constraintBottom_toBottomOf="@+id/fabMainButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/fabMainButton"
app:srcCompat="@drawable/ic_my_location_24dp" />
<!-- SAVE RESUME -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabSubMenuButtonSave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="28dp"
android:clickable="true"
android:contentDescription="@string/descr_fab_sub_menu_button_save"
android:focusable="true"
app:backgroundTint="@color/trackbook_green"
app:fabSize="mini"
app:layout_constraintBottom_toTopOf="@+id/fabSubMenuButtonClear"
app:layout_constraintEnd_toEndOf="@+id/fabSubMenuButtonClear"
app:layout_constraintStart_toStartOf="@+id/fabSubMenuButtonClear"
app:srcCompat="@drawable/ic_save_white_24dp" />
<androidx.cardview.widget.CardView
android:id="@+id/fabSubMenuLabelSave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="8dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/fab_button_card_background"
app:cardCornerRadius="4dp"
app:cardElevation="4dp"
app:cardUseCompatPadding="true"
app:layout_constraintBottom_toBottomOf="@+id/fabSubMenuButtonSave"
app:layout_constraintEnd_toStartOf="@+id/fabSubMenuButtonSave"
app:layout_constraintTop_toTopOf="@+id/fabSubMenuButtonSave">
<TextView
android:layout_width="wrap_content"
android:layout_height="24dp"
android:paddingBottom="2dp"
android:paddingLeft="6dp"
android:paddingRight="6dp"
android:paddingTop="2dp"
android:text="@string/fab_sub_menu_save"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textColor="@color/fab_button_card_text"
android:textStyle="bold" />
</androidx.cardview.widget.CardView>
<!-- BUTTON CLEAR -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabSubMenuButtonClear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="28dp"
android:clickable="true"
android:contentDescription="@string/descr_fab_sub_menu_button_clear"
android:focusable="true"
app:backgroundTint="@color/trackbook_blue"
app:fabSize="mini"
app:layout_constraintBottom_toTopOf="@+id/fabSubMenuButtonResume"
app:layout_constraintEnd_toEndOf="@+id/fabSubMenuButtonResume"
app:layout_constraintStart_toStartOf="@+id/fabSubMenuButtonResume"
app:srcCompat="@drawable/ic_clear_white_24dp" />
<androidx.cardview.widget.CardView
android:id="@+id/fabSubMenuLabelClear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="8dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/fab_button_card_background"
app:cardCornerRadius="4dp"
app:cardElevation="4dp"
app:cardUseCompatPadding="true"
app:layout_constraintBottom_toBottomOf="@+id/fabSubMenuButtonClear"
app:layout_constraintEnd_toStartOf="@+id/fabSubMenuButtonClear"
app:layout_constraintTop_toTopOf="@+id/fabSubMenuButtonClear">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="2dp"
android:paddingLeft="6dp"
android:paddingRight="6dp"
android:paddingTop="2dp"
android:text="@string/fab_sub_menu_clear"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textColor="@color/fab_button_card_text"
android:textStyle="bold" />
</androidx.cardview.widget.CardView>
<!-- BUTTON RESUME -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabSubMenuButtonResume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:clickable="true"
android:contentDescription="@string/descr_fab_sub_menu_button_resume"
android:focusable="true"
app:backgroundTint="@color/trackbook_blue"
app:fabSize="mini"
app:layout_constraintBottom_toTopOf="@+id/fabMainButton"
app:layout_constraintEnd_toEndOf="@+id/fabMainButton"
app:layout_constraintStart_toStartOf="@+id/fabMainButton"
app:srcCompat="@drawable/ic_fiber_manual_record_white_24dp" />
<androidx.cardview.widget.CardView
android:id="@+id/fabSubMenuLabelResume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/fab_button_card_background"
app:cardCornerRadius="4dp"
app:cardElevation="4dp"
app:cardUseCompatPadding="true"
app:layout_constraintBottom_toBottomOf="@+id/fabSubMenuButtonResume"
app:layout_constraintEnd_toStartOf="@+id/fabSubMenuButtonResume"
app:layout_constraintTop_toTopOf="@+id/fabSubMenuButtonResume">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="2dp"
android:paddingLeft="6dp"
android:paddingRight="6dp"
android:paddingTop="2dp"
android:text="@string/fab_sub_menu_resume"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textColor="@color/fab_button_card_text"
android:textStyle="bold" />
</androidx.cardview.widget.CardView>
<!-- MAIN BUTTON -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabMainButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginEnd="16dp"
android:clickable="true"
android:contentDescription="@string/descr_fab_main_start"
android:focusable="true"
app:backgroundTint="@color/trackbook_blue"
app:fabSize="normal"
app:layout_constraintBottom_toTopOf="@+id/navigation"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="@drawable/ic_fiber_manual_record_white_24dp" />
<!-- BOTTOM NAVIGATION -->
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/navigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/bottom_navigation_background"
android:clickable="true"
android:focusable="true"
app:elevation="4dp"
app:itemIconTint="@drawable/selector_bottom_navigation"
app:itemTextColor="@drawable/selector_bottom_navigation"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/menu_main" />
<org.y20k.trackbook.layout.NonSwipeableViewPager
android:id="@+id/fragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/bottom_navigation_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph_main" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation_view"
android:layout_width="match_parent"
android:layout_height="56dp"
app:itemBackground="@color/bottom_navigation_background"
app:itemIconTint="@drawable/selector_bottom_navigation"
app:itemTextColor="@drawable/selector_bottom_navigation"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/menu_bottom_navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView android:id="@android:id/text1"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingStart="@dimen/activity_horizontal_margin"
android:paddingEnd="@dimen/activity_horizontal_margin"
android:textColor="@color/track_management_text"
android:backgroundTint="@color/track_management_background"
android:ellipsize="marquee"
android:singleLine="true" />

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView android:id="@android:id/text1"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingStart="@dimen/activity_horizontal_margin"
android:paddingEnd="@dimen/activity_horizontal_margin"
android:textColor="@color/track_management_text"
android:backgroundTint="@color/track_management_background"
android:ellipsize="marquee"
android:singleLine="true" />

View File

@ -0,0 +1,57 @@
<?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="wrap_content"
android:orientation="vertical">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/dialog_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="24dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="@color/text_default"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/dialog_details_link"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginStart="24dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="24dp"
android:scrollbars="vertical"
android:text="@string/dialog_generic_details_button"
android:textAppearance="@style/TextAppearance.MaterialComponents.Button"
android:textColor="@color/text_default"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dialog_message"
tools:text="@string/dialog_generic_details_button" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/dialog_details"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="24dp"
android:scrollbars="vertical"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="@color/text_default"
android:textIsSelectable="true"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/dialog_details_link" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,31 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/dialog_rename_track_input_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="24dp"
android:hint="@string/dialog_rename_track_input_hint"
android:maxLines="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/dialog_rename_track_input_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

Some files were not shown because too many files have changed in this diff Show More