Refactor out TracklistElement, make Track more object-oriented.

I'm sure the reason for TracklistElement was to save memory by not
having the waypoints loaded, but the separation of Track /
TracklistElement creates lots of back-and-forth conversions and
mental overhead. I want to give this a try and see if it actually
causes any problems.

Moved a lot of helper functions into the classes they operate on.
master
voussoir 2022-04-06 21:50:09 -07:00
parent 45de00b9c5
commit e3bc911de4
No known key found for this signature in database
GPG Key ID: 5F7554F8C26DACCB
12 changed files with 555 additions and 791 deletions

View File

@ -28,6 +28,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.CoroutineScope
@ -36,11 +37,9 @@ import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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
*/
@ -49,7 +48,6 @@ class MapFragment : Fragment(), YesNoDialog.YesNoDialogListener, MapOverlayHelpe
/* 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(Looper.getMainLooper())
@ -217,7 +215,7 @@ class MapFragment : Fragment(), YesNoDialog.YesNoDialogListener, MapOverlayHelpe
override fun onMarkerTapped(latitude: Double, longitude: Double) {
super.onMarkerTapped(latitude, longitude)
if (bound) {
track = TrackHelper.toggleStarred(activity as Context, track, latitude, longitude)
TrackHelper.toggle_waypoint_starred(activity as Context, track, latitude, longitude)
layout.overlayCurrentTrack(track, trackingState)
trackerService.track = track
}
@ -298,16 +296,12 @@ class MapFragment : Fragment(), YesNoDialog.YesNoDialogListener, MapOverlayHelpe
else
{
CoroutineScope(IO).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: clear track
track.save_json(activity as Context)
track.save_gpx(activity as Context)
trackerService.clearTrack()
// step 4: open track in TrackFragement
withContext(Main) {
openTrack(track.toTracklistElement(activity as Context))
// step 4: open track in TrackFragement
openTrack(track)
}
}
}
@ -315,12 +309,12 @@ class MapFragment : Fragment(), YesNoDialog.YesNoDialogListener, MapOverlayHelpe
/* Opens a track in TrackFragment */
private fun openTrack(tracklistElement: TracklistElement) {
private fun openTrack(track: Track) {
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, tracklistElement.id)
bundle.putString(Keys.ARG_TRACK_TITLE, track.name)
bundle.putString(Keys.ARG_TRACK_FILE_URI, track.get_json_file(activity as Context).toUri().toString())
bundle.putString(Keys.ARG_GPX_FILE_URI, track.get_gpx_file(activity as Context).toUri().toString())
bundle.putLong(Keys.ARG_TRACK_ID, track.id)
findNavController().navigate(R.id.fragment_track, bundle)
}

View File

@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import org.y20k.trackbook.core.Tracklist
import org.y20k.trackbook.core.load_tracklist
import org.y20k.trackbook.helpers.AppThemeHelper
import org.y20k.trackbook.helpers.FileHelper
import org.y20k.trackbook.helpers.LengthUnitHelper
@ -216,12 +217,8 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
/* Removes track and track files for given position - used by TracklistFragment */
private fun deleteNonStarred(context: Context) {
CoroutineScope(IO).launch {
var tracklist: Tracklist = FileHelper.readTracklist(context)
val deferred: Deferred<Tracklist> = async { FileHelper.deleteNonStarredSuspended(context, tracklist) }
// wait for result and store in tracklist
tracklist = deferred.await()
}
var tracklist: Tracklist = load_tracklist(context)
tracklist.delete_non_starred(context)
}

View File

@ -33,6 +33,7 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
@ -40,7 +41,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.track_from_file
import org.y20k.trackbook.dialogs.RenameTrackDialog
import org.y20k.trackbook.helpers.DateTimeHelper
import org.y20k.trackbook.helpers.FileHelper
import org.y20k.trackbook.helpers.LogHelper
import org.y20k.trackbook.helpers.MapOverlayHelper
@ -60,7 +63,8 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onCreate from Fragment */
override fun onCreate(savedInstanceState: Bundle?) {
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
trackFileUriString = arguments?.getString(Keys.ARG_TRACK_FILE_URI, String()) ?: String()
}
@ -70,8 +74,9 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
// initialize layout
val track: Track
if (this::trackFileUriString.isInitialized && trackFileUriString.isNotBlank()) {
track = FileHelper.readTrack(activity as Context, Uri.parse(trackFileUriString))
if (this::trackFileUriString.isInitialized && trackFileUriString.isNotBlank())
{
track = track_from_file(activity as Context, Uri.parse(trackFileUriString).toFile())
} else {
track = Track()
}
@ -107,13 +112,15 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onResume from Fragment */
override fun onResume() {
override fun onResume()
{
super.onResume()
}
/* Overrides onPause from Fragment */
override fun onPause() {
override fun onPause()
{
super.onPause()
// save zoom level and map center
layout.saveViewStateToTrack()
@ -125,15 +132,18 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Pass the activity result */
private fun requestSaveGpxResult(result: ActivityResult) {
private fun requestSaveGpxResult(result: ActivityResult)
{
// save GPX file to result file location
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val sourceUri: Uri = Uri.parse(layout.track.gpxUriString)
if (result.resultCode == Activity.RESULT_OK && result.data != null)
{
val sourceUri: Uri = layout.track.get_gpx_file(activity as Context).toUri()
val targetUri: Uri? = result.data?.data
if (targetUri != null) {
if (targetUri != null)
{
// copy file async (= fire & forget - no return value needed)
CoroutineScope(Dispatchers.IO).launch {
FileHelper.saveCopyOfFileSuspended(activity as Context, originalFileUri = sourceUri, targetFileUri = targetUri)
FileHelper.saveCopyOfFileSuspended(activity as Context, originalFileUri = sourceUri, targetFileUri = targetUri)
}
Toast.makeText(activity as Context, R.string.toast_message_save_gpx, Toast.LENGTH_LONG).show()
}
@ -142,7 +152,8 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onRenameTrackDialog from RenameTrackDialog */
override fun onRenameTrackDialog(textInput: String) {
override fun onRenameTrackDialog(textInput: String)
{
// rename track async (= fire & forget - no return value needed)
CoroutineScope(Dispatchers.IO).launch { FileHelper.renameTrackSuspended(activity as Context, layout.track, textInput) }
// update name in layout
@ -152,10 +163,13 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onYesNoDialog from YesNoDialogListener */
override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) {
when (type) {
override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String)
{
when (type)
{
Keys.DIALOG_DELETE_TRACK -> {
when (dialogResult) {
when (dialogResult)
{
// user tapped remove track
true -> {
// switch to TracklistFragment and remove track there
@ -169,25 +183,31 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onMarkerTapped from MarkerListener */
override fun onMarkerTapped(latitude: Double, longitude: Double) {
override fun onMarkerTapped(latitude: Double, longitude: Double)
{
super.onMarkerTapped(latitude, longitude)
// update track display
layout.track = TrackHelper.toggleStarred(activity as Context, layout.track, latitude, longitude)
TrackHelper.toggle_waypoint_starred(activity as Context, layout.track, latitude, longitude)
layout.updateTrackOverlay()
}
/* Opens up a file picker to select the save location */
private fun openSaveGpxDialog() {
private fun openSaveGpxDialog()
{
val context = this.activity as Context
val export_name: String = DateTimeHelper.convertToSortableDateString(layout.track.recordingStart) + Keys.GPX_FILE_EXTENSION
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = Keys.MIME_TYPE_GPX
putExtra(Intent.EXTRA_TITLE, FileHelper.getGpxFileName(layout.track))
putExtra(Intent.EXTRA_TITLE, export_name)
}
// file gets saved in the ActivityResult
try {
try
{
requestSaveGpxLauncher.launch(intent)
} catch (e: Exception) {
}
catch (e: Exception)
{
LogHelper.e(TAG, "Unable to save GPX.")
Toast.makeText(activity as Context, R.string.toast_message_install_file_helper, Toast.LENGTH_LONG).show()
}
@ -195,8 +215,9 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Share track as GPX via share sheet */
private fun shareGpxTrack() {
val gpxFile = Uri.parse(layout.track.gpxUriString).toFile()
private fun shareGpxTrack()
{
val gpxFile = layout.track.get_gpx_file(this.activity as Context)
val gpxShareUri = FileProvider.getUriForFile(this.activity as Context, "${requireActivity().applicationContext.packageName}.provider", gpxFile)
val shareIntent: Intent = Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
@ -208,9 +229,12 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
// show share sheet - if file helper is available
val packageManager: PackageManager? = activity?.packageManager
if (packageManager != null && shareIntent.resolveActivity(packageManager) != null) {
if (packageManager != null && shareIntent.resolveActivity(packageManager) != null)
{
startActivity(shareIntent)
} else {
}
else
{
Toast.makeText(activity, R.string.toast_message_install_file_helper, Toast.LENGTH_LONG).show()
}
}

View File

@ -41,6 +41,7 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.Runnable
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.load_temp_track
import org.y20k.trackbook.helpers.*
/*
@ -61,6 +62,8 @@ class TrackerService: Service(), SensorEventListener {
var currentBestLocation: Location = LocationHelper.getDefaultLocation()
var lastSave: Date = Keys.DEFAULT_DATE
var stepCountOffset: Float = 0f
// The resumed flag will be true for the first point that is received after unpausing a
// recording, so that the distance travelled while paused is not added to the track.distance.
var resumed: Boolean = false
var track: Track = Track()
var gpsLocationListenerRegistered: Boolean = false
@ -93,7 +96,7 @@ class TrackerService: Service(), SensorEventListener {
networkLocationListener = createLocationListener()
trackingState = PreferencesHelper.loadTrackingState()
currentBestLocation = LocationHelper.getLastKnownLocation(this)
track = FileHelper.readTrack(this, FileHelper.getTempFileUri(this))
track = load_temp_track(this)
// altitudeValues.capacity = PreferencesHelper.loadAltitudeSmoothingValue()
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
}
@ -195,72 +198,68 @@ class TrackerService: Service(), SensorEventListener {
/* Resume tracking after stop/pause */
fun resumeTracking() {
// load temp track - returns an empty track if not available
track = FileHelper.readTrack(this, FileHelper.getTempFileUri(this))
// load temp track - returns an empty track if there is no temp file.
track = load_temp_track(this)
// try to mark last waypoint as stopover
if (track.wayPoints.size > 0) {
val lastWayPointIndex = track.wayPoints.size - 1
track.wayPoints[lastWayPointIndex].isStopOver = true
}
// set resumed flag
resumed = true
// calculate length of recording break
track.recordingPaused = track.recordingPaused + TrackHelper.calculateDurationOfPause(track.recordingStop)
// start tracking
track.recordingPaused += TrackHelper.calculateDurationOfPause(track.recordingStop)
startTracking(newTrack = false)
}
/* Start tracking location */
fun startTracking(newTrack: Boolean = true) {
// start receiving location updates
addGpsLocationListener()
addNetworkLocationListener()
// set up new track
if (newTrack) {
track = Track()
track.name = DateTimeHelper.convertToReadableDate(track.recordingStart)
resumed = false
stepCountOffset = 0f
}
// set state
trackingState = Keys.STATE_TRACKING_ACTIVE
PreferencesHelper.saveTrackingState(trackingState)
// start recording steps and location fixes
startStepCounter()
handler.postDelayed(periodicTrackUpdate, 0)
// show notification
startForeground(Keys.TRACKER_SERVICE_NOTIFICATION_ID, displayNotification())
}
/* Stop tracking location */
fun stopTracking() {
// save temp track
track.recordingStop = GregorianCalendar.getInstance().time
CoroutineScope(IO).launch { FileHelper.saveTempTrackSuspended(this@TrackerService, track) }
// save state
val context: Context = this as Context
CoroutineScope(IO).launch { track.save_temp_suspended(context) }
trackingState = Keys.STATE_TRACKING_STOPPED
trackingState = Keys.STATE_TRACKING_PAUSED
PreferencesHelper.saveTrackingState(trackingState)
// reset altitude values queue
altitudeValues.reset()
// stop recording steps and location fixes
sensorManager.unregisterListener(this)
handler.removeCallbacks(periodicTrackUpdate)
// update notification
displayNotification()
stopForeground(false)
}
/* Clear track recording */
fun clearTrack() {
fun clearTrack()
{
track = Track()
FileHelper.deleteTempFile(this)
resumed = false
FileHelper.delete_temp_file(this as Context)
trackingState = Keys.STATE_TRACKING_NOT_STARTED
PreferencesHelper.saveTrackingState(trackingState)
stopForeground(true)
notificationManager.cancel(Keys.TRACKER_SERVICE_NOTIFICATION_ID) // this call was not necessary prior to Android 12
}
/* Creates location listener */
private fun createLocationListener(): LocationListener {
private fun createLocationListener(): LocationListener
{
return object : LocationListener {
override fun onLocationChanged(location: Location) {
// update currentBestLocation if a better location is available
@ -268,7 +267,8 @@ class TrackerService: Service(), SensorEventListener {
currentBestLocation = location
}
}
override fun onProviderEnabled(provider: String) {
override fun onProviderEnabled(provider: String)
{
LogHelper.v(TAG, "onProviderEnabled $provider")
when (provider) {
LocationManager.GPS_PROVIDER -> gpsProviderActive = LocationHelper.isGpsEnabled(
@ -280,7 +280,8 @@ class TrackerService: Service(), SensorEventListener {
)
}
}
override fun onProviderDisabled(provider: String) {
override fun onProviderDisabled(provider: String)
{
LogHelper.v(TAG, "onProviderDisabled $provider")
when (provider) {
LocationManager.GPS_PROVIDER -> gpsProviderActive = LocationHelper.isGpsEnabled(
@ -292,59 +293,58 @@ class TrackerService: Service(), SensorEventListener {
)
}
}
override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?) {
override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?)
{
// deprecated method
}
}
}
/* Adds a GPS location listener to location manager */
private fun addGpsLocationListener() {
// check if already registered
if (!gpsLocationListenerRegistered) {
// check if Network provider is available
gpsProviderActive = LocationHelper.isGpsEnabled(locationManager)
if (gpsProviderActive) {
// check for location permission
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED) {
// adds GPS location listener
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
0,
0f,
gpsLocationListener
)
gpsLocationListenerRegistered = true
LogHelper.v(TAG, "Added GPS location listener.")
} else {
LogHelper.w(
TAG,
"Unable to add GPS location listener. Location permission is not granted."
)
}
} else {
LogHelper.w(TAG, "Unable to add GPS location listener.")
}
} else {
LogHelper.v(TAG, "Skipping registration. GPS location listener has already been added.")
private fun addGpsLocationListener()
{
if (gpsLocationListenerRegistered)
{
LogHelper.v(TAG, "GPS location listener has already been added.")
return
}
gpsProviderActive = LocationHelper.isGpsEnabled(locationManager)
if (! gpsProviderActive)
{
LogHelper.w(TAG, "Device GPS is not enabled.")
return
}
val has_permission: Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (! has_permission)
{
LogHelper.w(TAG, "Location permission is not granted.")
return
}
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
0,
0f,
gpsLocationListener
)
gpsLocationListenerRegistered = true
LogHelper.v(TAG, "Added GPS location listener.")
}
/* Adds a Network location listener to location manager */
private fun addNetworkLocationListener() {
if (gpsOnly)
{
LogHelper.v(TAG, "User prefers GPS-only.")
return;
LogHelper.v(TAG, "Skipping Network listener. User prefers GPS-only.")
return
}
if (networkLocationListenerRegistered)
{
LogHelper.v(TAG, "Network location listener has already been added.")
return;
return
}
networkProviderActive = LocationHelper.isNetworkEnabled(locationManager)
@ -378,10 +378,7 @@ class TrackerService: Service(), SensorEventListener {
gpsLocationListenerRegistered = false
LogHelper.v(TAG, "Removed GPS location listener.")
} else {
LogHelper.w(
TAG,
"Unable to remove GPS location listener. Location permission is needed."
)
LogHelper.w(TAG, "Unable to remove GPS location listener. Location permission is needed.")
}
}
@ -392,10 +389,7 @@ class TrackerService: Service(), SensorEventListener {
networkLocationListenerRegistered = false
LogHelper.v(TAG, "Removed Network location listener.")
} else {
LogHelper.w(
TAG,
"Unable to remove Network location listener. Location permission is needed."
)
LogHelper.w(TAG, "Unable to remove Network location listener. Location permission is needed.")
}
}
@ -424,25 +418,25 @@ class TrackerService: Service(), SensorEventListener {
* Defines the listener for changes in shared preferences
*/
private val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
when (key) {
// preference "Restrict to GPS"
Keys.PREF_GPS_ONLY -> {
gpsOnly = PreferencesHelper.loadGpsOnly()
when (gpsOnly) {
true -> removeNetworkLocationListener()
false -> addNetworkLocationListener()
}
}
// preference "Use Imperial Measurements"
Keys.PREF_USE_IMPERIAL_UNITS -> {
useImperial = PreferencesHelper.loadUseImperialUnits()
}
// preference "Recording Accuracy"
Keys.PREF_OMIT_RESTS -> {
omitRests = PreferencesHelper.loadOmitRests()
when (key) {
// preference "Restrict to GPS"
Keys.PREF_GPS_ONLY -> {
gpsOnly = PreferencesHelper.loadGpsOnly()
when (gpsOnly) {
true -> removeNetworkLocationListener()
false -> addNetworkLocationListener()
}
}
// preference "Use Imperial Measurements"
Keys.PREF_USE_IMPERIAL_UNITS -> {
useImperial = PreferencesHelper.loadUseImperialUnits()
}
// preference "Recording Accuracy"
Keys.PREF_OMIT_RESTS -> {
omitRests = PreferencesHelper.loadOmitRests()
}
}
}
/*
* End of declaration
*/
@ -460,19 +454,13 @@ class TrackerService: Service(), SensorEventListener {
/*
* Runnable: Periodically track updates (if recording active)
*/
private val periodicTrackUpdate: Runnable = object : Runnable {
private val periodicTrackUpdate: Runnable = object : Runnable
{
override fun run() {
// add waypoint to track - step count is continuously updated in onSensorChanged
val result: Pair<Boolean, Track> = TrackHelper.addWayPointToTrack(track, currentBestLocation, omitRests, resumed)
// get results
val successfullyAdded: Boolean = result.first
track = result.second
// check, if waypoint was added
if (successfullyAdded) {
// reset resumed flag, if necessary
if (resumed) {
resumed = false
}
val success = track.add_waypoint(currentBestLocation, omitRests, resumed)
if (success) {
resumed = false
// store previous smoothed altitude
val previousAltitude: Double = altitudeValues.getAverage()
@ -480,7 +468,7 @@ class TrackerService: Service(), SensorEventListener {
val currentBestLocationAltitude: Double = currentBestLocation.altitude
if (currentBestLocationAltitude != Keys.DEFAULT_ALTITUDE) altitudeValues.add(currentBestLocationAltitude)
// TODO remove
// uncomment to use test altitude values - useful if testing wirth an emulator
// uncomment to use test altitude values - useful if testing with an emulator
//altitudeValues.add(getTestAltitude()) // TODO remove
// TODO remove
@ -499,7 +487,7 @@ class TrackerService: Service(), SensorEventListener {
val now: Date = GregorianCalendar.getInstance().time
if (now.time - lastSave.time > Keys.SAVE_TEMP_TRACK_INTERVAL) {
lastSave = now
CoroutineScope(IO).launch { FileHelper.saveTempTrackSuspended(this@TrackerService, track) }
CoroutineScope(IO).launch { track.save_temp_suspended(this@TrackerService) }
}
}
// update notification
@ -514,10 +502,12 @@ class TrackerService: Service(), SensorEventListener {
/* Simple queue that evicts older elements and holds an average */
/* Credit: CircularQueue https://stackoverflow.com/a/51923797 */
class SimpleMovingAverageQueue(var capacity: Int) : LinkedList<Double>() {
class SimpleMovingAverageQueue(var capacity: Int) : LinkedList<Double>()
{
var prepared: Boolean = false
private var sum: Double = 0.0
override fun add(element: Double): Boolean {
override fun add(element: Double): Boolean
{
prepared = this.size + 1 >= Keys.MIN_NUMBER_OF_WAYPOINTS_FOR_ELEVATION_CALCULATION
if (this.size >= capacity) {
sum -= this.first
@ -526,8 +516,12 @@ class TrackerService: Service(), SensorEventListener {
sum += element
return super.add(element)
}
fun getAverage(): Double = sum / this.size
fun reset() {
fun getAverage(): Double
{
return sum / this.size
}
fun reset()
{
this.clear()
prepared = false
sum = 0.0

View File

@ -24,6 +24,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
@ -33,11 +34,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.Main
import org.y20k.trackbook.core.Tracklist
import org.y20k.trackbook.core.TracklistElement
import org.y20k.trackbook.helpers.FileHelper
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.helpers.LogHelper
import org.y20k.trackbook.helpers.TrackHelper
import org.y20k.trackbook.helpers.UiHelper
import org.y20k.trackbook.tracklist.TracklistAdapter
@ -95,19 +93,17 @@ class TracklistFragment : Fragment(), TracklistAdapter.TracklistAdapterListener,
return rootView
}
/* Overrides onTrackElementTapped from TracklistElementAdapterListener */
override fun onTrackElementTapped(tracklistElement: TracklistElement) {
override fun onTrackElementTapped(track: Track) {
val bundle: Bundle = bundleOf(
Keys.ARG_TRACK_TITLE to tracklistElement.name,
Keys.ARG_TRACK_FILE_URI to tracklistElement.trackUriString,
Keys.ARG_GPX_FILE_URI to tracklistElement.gpxUriString,
Keys.ARG_TRACK_ID to tracklistElement.id
Keys.ARG_TRACK_TITLE to track.name,
Keys.ARG_TRACK_FILE_URI to track.get_json_file(activity as Context).toUri().toString(),
Keys.ARG_GPX_FILE_URI to track.get_gpx_file(activity as Context).toUri().toString(),
Keys.ARG_TRACK_ID to track.id
)
findNavController().navigate(R.id.fragment_track, bundle)
}
/* Overrides onYesNoDialog from YesNoDialogListener */
override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) {
CoroutineScope(Dispatchers.IO).launch {
@ -117,7 +113,7 @@ class TracklistFragment : Fragment(), TracklistAdapter.TracklistAdapterListener,
// user tapped remove track
true -> {
toggleOnboardingLayout()
val deferred: Deferred<Unit> = async { tracklistAdapter.removeTrackAtPositionSuspended(activity as Context, payload) }
val deferred: Deferred<Unit> = async { tracklistAdapter.delete_track_at_position_suspended(activity as Context, payload) }
// wait for result and store in tracklist
withContext(Main) {
deferred.await()
@ -173,7 +169,7 @@ class TracklistFragment : Fragment(), TracklistAdapter.TracklistAdapterListener,
return;
}
CoroutineScope(Main). launch {
tracklistAdapter.removeTrackById(this@TracklistFragment.activity as Context, deleteTrackId)
tracklistAdapter.delete_track_by_id(this@TracklistFragment.activity as Context, deleteTrackId)
toggleOnboardingLayout()
}
}

View File

@ -17,56 +17,280 @@
package org.y20k.trackbook.core
import android.content.Context
import android.location.Location
import android.os.Parcelable
import android.util.Log
import androidx.annotation.Keep
import com.google.gson.annotations.Expose
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.random.Random
import kotlinx.parcelize.Parcelize
import org.y20k.trackbook.Keys
import org.y20k.trackbook.helpers.DateTimeHelper
import org.y20k.trackbook.helpers.FileHelper
import org.y20k.trackbook.helpers.LocationHelper
/*
* Track data class
*/
@Keep
@Parcelize
data class Track (@Expose val id: Long = make_random_id(),
@Expose var trackFormatVersion: Int = Keys.CURRENT_TRACK_FORMAT_VERSION,
@Expose val wayPoints: MutableList<WayPoint> = mutableListOf<WayPoint>(),
@Expose var distance: Float = 0f,
@Expose var duration: Long = 0L,
@Expose var recordingPaused: 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
data class Track (
@Expose val id: Long = make_random_id(),
@Expose var trackFormatVersion: Int = Keys.CURRENT_TRACK_FORMAT_VERSION,
@Expose val wayPoints: MutableList<WayPoint> = mutableListOf<WayPoint>(),
@Expose var distance: Float = 0f,
@Expose var duration: Long = 0L,
@Expose var recordingPaused: Long = 0L,
@Expose var stepCount: Float = 0f,
@Expose var recordingStart: Date = GregorianCalendar.getInstance().time,
@Expose var dateString: String = DateTimeHelper.convertToReadableDate(recordingStart),
@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 latitude: Double = Keys.DEFAULT_LATITUDE,
@Expose var longitude: Double = Keys.DEFAULT_LONGITUDE,
@Expose var zoomLevel: Double = Keys.DEFAULT_ZOOM_LEVEL,
@Expose var name: String = DateTimeHelper.convertToReadableDate(recordingStart),
@Expose var starred: Boolean = false,
): Parcelable
{
/* Creates a TracklistElement */
fun toTracklistElement(context: Context): TracklistElement {
val readableDateString: String = DateTimeHelper.convertToReadableDate(recordingStart)
val readableDurationString: String = DateTimeHelper.convertToReadableTime(context, duration)
return TracklistElement(
id = id,
name = name,
date = recordingStart,
dateString = readableDateString,
distance = distance,
duration = duration,
trackUriString = trackUriString,
gpxUriString = gpxUriString,
starred = false
fun add_waypoint(location: Location, omitRests: Boolean, resumed: Boolean): Boolean
{
// Step 1: Get previous location
val previousLocation: Location?
var numberOfWayPoints: Int = this.wayPoints.size
// CASE: First location
if (numberOfWayPoints == 0)
{
previousLocation = null
}
// CASE: Second location - check if first location was plausible & remove implausible location
else if (numberOfWayPoints == 1 && !LocationHelper.isFirstLocationPlausible(location, this))
{
previousLocation = null
numberOfWayPoints = 0
this.wayPoints.removeAt(0)
}
// CASE: Third location or second location (if first was plausible)
else
{
previousLocation = this.wayPoints[numberOfWayPoints - 1].toLocation()
}
// Step 2: Update duration
val now: Date = GregorianCalendar.getInstance().time
val difference: Long = now.time - this.recordingStop.time
this.duration += difference
this.recordingStop = now
// Step 3: Add waypoint, if recent and accurate and different enough
val shouldBeAdded: Boolean = (
LocationHelper.isRecentEnough(location) &&
LocationHelper.isAccurateEnough(location, Keys.DEFAULT_THRESHOLD_LOCATION_ACCURACY) &&
LocationHelper.isDifferentEnough(previousLocation, location, omitRests)
)
if (! shouldBeAdded)
{
return false
}
// Step 3.1: Update distance (do not update if resumed -> we do not want to add values calculated during a recording pause)
if (!resumed)
{
this.distance = this.distance + LocationHelper.calculateDistance(previousLocation, location)
}
// Step 3.2: Update altitude values
val altitude: Double = location.altitude
if (altitude != 0.0)
{
if (numberOfWayPoints == 0)
{
this.maxAltitude = altitude
this.minAltitude = altitude
}
else
{
if (altitude > this.maxAltitude) this.maxAltitude = altitude
if (altitude < this.minAltitude) this.minAltitude = altitude
}
}
// Step 3.3: Toggle stop over status, if necessary
if (this.wayPoints.size < 0)
{
this.wayPoints[this.wayPoints.size - 1].isStopOver = LocationHelper.isStopOver(previousLocation, location)
}
// Step 3.4: Add current location as point to center on for later display
this.latitude = location.latitude
this.longitude = location.longitude
// Step 3.5: Add location as new waypoint
this.wayPoints.add(WayPoint(location = location, distanceToStartingPoint = this.distance))
return true
}
fun delete(context: Context)
{
Log.i("VOUSSOIR", "Deleting track ${this.id}.")
val json_file: File = this.get_json_file(context)
if (json_file.isFile)
{
json_file.delete()
}
val gpx_file: File = this.get_gpx_file(context)
if (gpx_file.isFile)
{
gpx_file.delete()
}
}
suspend fun delete_suspended(context: Context)
{
return suspendCoroutine { cont ->
cont.resume(this.delete(context))
}
}
fun get_gpx_file(context: Context): File
{
val basename: String = this.id.toString() + Keys.GPX_FILE_EXTENSION
return File(context.getExternalFilesDir(Keys.FOLDER_GPX), basename)
}
fun get_json_file(context: Context): File
{
val basename: String = this.id.toString() + Keys.TRACKBOOK_FILE_EXTENSION
return File(context.getExternalFilesDir(Keys.FOLDER_TRACKS), basename)
}
fun save_both(context: Context)
{
this.save_json(context)
this.save_gpx(context)
}
suspend fun save_both_suspended(context: Context)
{
return suspendCoroutine { cont ->
cont.resume(this.save_both(context))
}
}
fun save_gpx(context: Context)
{
val gpx: String = this.to_gpx()
FileHelper.write_text_file_noblank(gpx, this.get_gpx_file(context))
Log.i("VOUSSOIR", "Saved ${this.id}.gpx")
}
suspend fun save_gpx_suspended(context: Context)
{
return suspendCoroutine { cont ->
cont.resume(this.save_gpx(context))
}
}
fun save_json(context: Context)
{
val json: String = this.to_json()
FileHelper.write_text_file_noblank(json, this.get_json_file(context))
Log.i("VOUSSOIR", "Saved ${this.id}.json")
}
suspend fun save_json_suspended(context: Context)
{
return suspendCoroutine { cont ->
cont.resume(this.save_json(context))
}
}
fun save_temp(context: Context)
{
val json: String = this.to_json()
FileHelper.write_text_file_noblank(json, FileHelper.get_temp_file(context))
}
suspend fun save_temp_suspended(context: Context)
{
return suspendCoroutine { cont ->
cont.resume(this.save_temp(context))
}
}
fun to_gpx(): String {
val gpxString = StringBuilder("")
// Header
gpxString.appendLine("""
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<gpx
version="1.1" creator="Trackbook App (Android)"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"
>
""".trimIndent())
gpxString.appendLine("\t<metadata>")
gpxString.appendLine("\t\t<name>Trackbook Recording: ${this.name}</name>")
gpxString.appendLine("\t</metadata>")
// POIs
val poiList: List<WayPoint> = this.wayPoints.filter { it.starred }
poiList.forEach { poi ->
gpxString.appendLine("\t<wpt lat=\"${poi.latitude}\" lon=\"${poi.longitude}\">")
gpxString.appendLine("\t\t<name>Point of interest</name>")
gpxString.appendLine("\t\t<ele>${poi.altitude}</ele>")
gpxString.appendLine("\t</wpt>")
}
// TRK
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
gpxString.appendLine("\t<trk>")
gpxString.appendLine("\t\t<name>${this.name}</name>")
gpxString.appendLine("\t\t<trkseg>")
this.wayPoints.forEach { wayPoint ->
gpxString.appendLine("\t\t\t<trkpt lat=\"${wayPoint.latitude}\" lon=\"${wayPoint.longitude}\">")
gpxString.appendLine("\t\t\t\t<ele>${wayPoint.altitude}</ele>")
gpxString.appendLine("\t\t\t\t<time>${dateFormat.format(Date(wayPoint.time))}</time>")
gpxString.appendLine("\t\t\t\t<sat>${wayPoint.numberSatellites}</sat>")
gpxString.appendLine("\t\t\t</trkpt>")
}
gpxString.appendLine("\t\t</trkseg>")
gpxString.appendLine("\t</trk>")
gpxString.appendLine("</gpx>")
return gpxString.toString()
}
fun to_json(): String
{
return FileHelper.getCustomGson().toJson(this)
}
}
fun load_temp_track(context: Context): Track
{
return track_from_file(context, FileHelper.get_temp_file(context))
}
fun track_from_file(context: Context, file: File): Track
{
// get JSON from text file
val json: String = FileHelper.readTextFile(context, file)
if (json.isEmpty())
{
return Track()
}
return FileHelper.getCustomGson().fromJson(json, Track::class.java)
}
fun make_random_id(): Long

View File

@ -17,54 +17,79 @@
package org.y20k.trackbook.core
import android.content.Context
import android.os.Parcelable
import android.util.Log
import androidx.annotation.Keep
import com.google.gson.annotations.Expose
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.parcelize.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>()): Parcelable {
/* Return trackelement for given track id */
fun getTrackElement(trackId: Long): TracklistElement? {
tracklistElements.forEach { tracklistElement ->
if (tracklistElement.id == trackId) {
return tracklistElement
data class Tracklist (
@Expose val tracklistFormatVersion: Int = Keys.CURRENT_TRACKLIST_FORMAT_VERSION,
@Expose val tracks: MutableList<Track> = mutableListOf<Track>()
): Parcelable
{
fun delete_non_starred(context: Context)
{
val to_delete: List<Track> = this.tracks.filter{! it.starred}
to_delete.forEach { track ->
if (!track.starred)
{
track.delete(context)
}
}
return null
this.tracks.removeIf{! it.starred}
}
suspend fun delete_non_starred_suspended(context: Context)
{
return suspendCoroutine { cont ->
cont.resume(this.delete_non_starred(context))
}
}
fun get_total_distance(): Float
fun get_total_distance(): Double
{
var total: Float = 0F
tracklistElements.forEach { tracklist_element ->
total += tracklist_element.distance
}
return total
return this.tracks.sumOf {it.distance.toDouble()}
}
fun get_total_duration(): Long
{
var total: Long = 0L
tracklistElements.forEach { tracklist_element ->
total += tracklist_element.duration
}
return total
return this.tracks.sumOf {it.duration}
}
/* Create a deep copy */
fun deepCopy(): Tracklist {
return Tracklist(tracklistFormatVersion, mutableListOf<TracklistElement>().apply { addAll(tracklistElements) })
fun deepCopy(): Tracklist
{
return Tracklist(tracklistFormatVersion, mutableListOf<Track>().apply { addAll(tracks) })
}
}
fun load_tracklist(context: Context): Tracklist {
Log.i("VOUSSOIR", "Loading tracklist.")
val folder = context.getExternalFilesDir("tracks")
var tracklist: Tracklist = Tracklist()
if (folder == null)
{
return tracklist
}
folder.walk().filter{ f: File -> f.isFile }.forEach{ json_file ->
val track = track_from_file(context, json_file)
tracklist.tracks.add(track)
}
tracklist.tracks.sortByDescending {it.recordingStart}
return tracklist
}
suspend fun load_tracklist_suspended(context: Context): Tracklist
{
return suspendCoroutine {cont -> cont.resume(load_tracklist(context))}
}

View File

@ -1,43 +0,0 @@
/*
* 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-22 - 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.parcelize.Parcelize
import java.util.*
/*
* TracklistElement data class
*/
@Keep
@Parcelize
data class TracklistElement(
@Expose val id: Long,
@Expose var name: String,
@Expose val date: Date,
@Expose val dateString: String,
@Expose val duration: Long,
@Expose val distance: Float,
@Expose val trackUriString: String,
@Expose val gpxUriString: String,
@Expose var starred: Boolean = false
) : Parcelable {
}

View File

@ -18,26 +18,16 @@
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 android.util.Log
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
import kotlin.math.ln
import kotlin.math.pow
import org.y20k.trackbook.Keys
import org.y20k.trackbook.core.Track
/*
@ -48,185 +38,29 @@ 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}")
var folder = context.getExternalFilesDir("tracks")
var tracklist: Tracklist = Tracklist()
Log.i(TAG, folder.toString())
if (folder != null)
fun delete_temp_file(context: Context)
{
val temp: File = get_temp_file(context)
if (temp.isFile())
{
folder.walk().filter{ f: File -> f.isFile }.forEach{ track_file ->
val track_json = readTextFile(context, track_file.toUri())
Log.i("VOUSSOIR", track_json)
val track = getCustomGson().fromJson(track_json, Track::class.java)
val tracklist_element = track.toTracklistElement(context)
tracklist.tracklistElements.add(tracklist_element)
}
temp.delete()
}
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 = File(context.getExternalFilesDir(Keys.FOLDER_GPX), getGpxFileName(track)).toUri()
/* Creates file name for Gpx file of a track */
fun getGpxFileName(track: Track): String = DateTimeHelper.convertToSortableDateString(track.recordingStart) + Keys.GPX_FILE_EXTENSION
/* 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()
fun get_temp_file(context: Context): File
{
return File(context.getExternalFilesDir(Keys.FOLDER_TEMP), Keys.TEMP_FILE)
}
/* Suspend function: Wrapper for renameTrack */
suspend fun renameTrackSuspended(context: Context, track: Track, newName: String) {
return suspendCoroutine { cont ->
cont.resume(renameTrack(context, track, newName))
track.name = newName
track.save_both(context)
cont.resume(Unit)
}
}
/* 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, tracklist_element: TracklistElement, tracklist: Tracklist): Tracklist {
return suspendCoroutine { cont ->
cont.resume(deleteTrack(context, tracklist_element, tracklist))
}
}
/* Suspend function: Deletes tracks that are not starred using deleteTracks */
suspend fun deleteNonStarredSuspended(context: Context, tracklist: Tracklist): Tracklist {
return suspendCoroutine { cont ->
val tracklistElements = mutableListOf<TracklistElement>()
tracklist.tracklistElements.forEach { tracklistElement ->
if (!tracklistElement.starred) {
tracklistElements.add(tracklistElement)
}
}
cont.resume(deleteTracks(context, tracklistElements, 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, deleteOriginal: Boolean = false) {
@ -235,83 +69,6 @@ object FileHelper {
}
}
/* 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, track.trackUriString.toUri())
}
if (saveGpxToo) {
val gpxString: String = TrackHelper.createGpxString(track)
if (gpxString.isNotBlank()) {
// write GPX file
writeTextFile(gpxString, track.gpxUriString.toUri())
}
}
}
/* 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))
}
}
/* 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.id == track.id) {
// rename tracklist element
tracklistElement.name = newName
trackUriString = tracklistElement.trackUriString
}
}
if (trackUriString.isNotEmpty()) {
// rename track
track.name = newName
// save track
saveTrack(track, saveGpxToo = true)
}
}
/* Deletes multiple tracks */
private fun deleteTracks(context: Context, tracklistElements: MutableList<TracklistElement>, tracklist: Tracklist): Tracklist {
tracklistElements.forEach { tracklistElement ->
deleteTrack(context, tracklistElement, tracklist)
}
return tracklist
}
/* Deletes one track */
private fun deleteTrack(context: Context, tracklist_element: TracklistElement, tracklist: Tracklist): Tracklist {
// delete track files
val json_file: File = tracklist_element.trackUriString.toUri().toFile()
if (json_file.isFile)
{
json_file.delete()
}
val gpx_file: File = tracklist_element.gpxUriString.toUri().toFile()
if (gpx_file.isFile)
{
gpx_file.delete()
}
// remove track element from list
tracklist.tracklistElements.removeIf {it.id == tracklist_element.id}
return tracklist
}
/* Copies file to specified target */
private fun copyFile(context: Context, originalFileUri: Uri, targetFileUri: Uri, deleteOriginal: Boolean = false) {
@ -325,58 +82,19 @@ object FileHelper {
}
}
/* 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 {
fun getCustomGson(): Gson
{
val gsonBuilder = GsonBuilder()
gsonBuilder.setDateFormat("yyyy-MM-dd-HH-mm-ss")
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 = (ln(bytes.toDouble()) / ln(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 / unit.toDouble().pow(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 {
fun readTextFile(context: Context, file: File): 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()
}
@ -393,27 +111,12 @@ object FileHelper {
/* Writes given text to file on storage */
private fun writeTextFile(text: String, fileUri: Uri) {
fun write_text_file_noblank(text: String, file: File)
{
if (text.isNotEmpty()) {
val file: File = fileUri.toFile()
file.writeText(text)
} else {
LogHelper.w(TAG, "Writing text file $fileUri failed. Empty text string text was provided.")
LogHelper.w(TAG, "Writing text file ${file.toUri()} failed. Empty text string was provided.")
}
}
/* 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

@ -17,20 +17,10 @@
package org.y20k.trackbook.helpers
import android.content.Context
import android.location.Location
import android.widget.Toast
import androidx.core.net.toUri
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import org.y20k.trackbook.Keys
import org.y20k.trackbook.R
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.Tracklist
import org.y20k.trackbook.core.TracklistElement
import org.y20k.trackbook.core.WayPoint
/*
* TrackHelper object
@ -41,123 +31,14 @@ object TrackHelper {
private val TAG: String = LogHelper.makeLogTag(TrackHelper::class.java)
/* Adds given locatiom as waypoint to track */
fun addWayPointToTrack(track: Track, location: Location, omitRests: Boolean, resumed: Boolean): Pair<Boolean, Track> {
// Step 1: Get previous location
val previousLocation: Location?
var numberOfWayPoints: Int = track.wayPoints.size
// CASE: First location
if (numberOfWayPoints == 0) {
previousLocation = null
}
// CASE: Second location - check if first location was plausible & remove implausible location
else if (numberOfWayPoints == 1 && !LocationHelper.isFirstLocationPlausible(location, track)) {
previousLocation = null
numberOfWayPoints = 0
track.wayPoints.removeAt(0)
}
// CASE: Third location or second location (if first was plausible)
else {
previousLocation = track.wayPoints[numberOfWayPoints - 1].toLocation()
}
// Step 2: Update duration
val now: Date = GregorianCalendar.getInstance().time
val difference: Long = now.time - track.recordingStop.time
track.duration = track.duration + difference
track.recordingStop = now
// Step 3: Add waypoint, ifrecent and accurate and different enough
val shouldBeAdded: Boolean = (
LocationHelper.isRecentEnough(location) &&
LocationHelper.isAccurateEnough(location, Keys.DEFAULT_THRESHOLD_LOCATION_ACCURACY) &&
LocationHelper.isDifferentEnough(previousLocation, location, omitRests)
)
if (shouldBeAdded) {
// Step 3.1: Update distance (do not update if resumed -> we do not want to add values calculated during a recording pause)
if (!resumed) {
track.distance = track.distance + LocationHelper.calculateDistance(previousLocation, location)
}
// Step 3.2: Update altitude values
val altitude: Double = location.altitude
if (altitude != 0.0) {
if (numberOfWayPoints == 0) {
track.maxAltitude = altitude
track.minAltitude = altitude
}
else {
if (altitude > track.maxAltitude) track.maxAltitude = altitude
if (altitude < track.minAltitude) track.minAltitude = altitude
}
}
// Step 3.3: Toggle stop over status, if necessary
if (track.wayPoints.size < 0) {
track.wayPoints[track.wayPoints.size - 1].isStopOver = LocationHelper.isStopOver(previousLocation, location)
}
// Step 3.4: Add current location as point to center on for later display
track.latitude = location.latitude
track.longitude = location.longitude
// Step 3.5: Add location as new waypoint
track.wayPoints.add(WayPoint(location = location, distanceToStartingPoint = track.distance))
}
return Pair(shouldBeAdded, track)
}
/* Calculates time passed since last stop of recording */
fun calculateDurationOfPause(recordingStop: Date): Long = GregorianCalendar.getInstance().time.time - recordingStop.time
/* Creates GPX string for given track */
fun createGpxString(track: Track): String {
val gpxString = StringBuilder("")
// Header
gpxString.appendLine("""
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<gpx
version="1.1" creator="Trackbook App (Android)"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"
>
""".trimIndent())
gpxString.appendLine("\t<metadata>")
gpxString.appendLine("\t\t<name>Trackbook Recording: ${track.name}</name>")
gpxString.appendLine("\t</metadata>")
// POIs
val poiList: List<WayPoint> = track.wayPoints.filter { it.starred }
poiList.forEach { poi ->
gpxString.appendLine("\t<wpt lat=\"${poi.latitude}\" lon=\"${poi.longitude}\">")
gpxString.appendLine("\t\t<name>Point of interest</name>")
gpxString.appendLine("\t\t<ele>${poi.altitude}</ele>")
gpxString.appendLine("\t</wpt>")
}
// TRK
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
gpxString.appendLine("\t<trk>")
gpxString.appendLine("\t\t<name>${track.name}</name>")
gpxString.appendLine("\t\t<trkseg>")
track.wayPoints.forEach { wayPoint ->
gpxString.appendLine("\t\t\t<trkpt lat=\"${wayPoint.latitude}\" lon=\"${wayPoint.longitude}\">")
gpxString.appendLine("\t\t\t\t<ele>${wayPoint.altitude}</ele>")
gpxString.appendLine("\t\t\t\t<time>${dateFormat.format(Date(wayPoint.time))}</time>")
gpxString.appendLine("\t\t\t\t<sat>${wayPoint.numberSatellites}</sat>")
gpxString.appendLine("\t\t\t</trkpt>")
}
gpxString.appendLine("\t\t</trkseg>")
gpxString.appendLine("\t</trk>")
gpxString.appendLine("</gpx>")
return gpxString.toString()
}
/* Toggles starred flag for given position */
fun toggleStarred(context: Context, track: Track, latitude: Double, longitude: Double): Track {
fun toggle_waypoint_starred(context: Context, track: Track, latitude: Double, longitude: Double)
{
track.wayPoints.forEach { waypoint ->
if (waypoint.latitude == latitude && waypoint.longitude == longitude) {
waypoint.starred = !waypoint.starred
@ -167,6 +48,5 @@ object TrackHelper {
}
}
}
return track
}
}

View File

@ -30,7 +30,6 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.*
@ -38,8 +37,9 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import org.y20k.trackbook.Keys
import org.y20k.trackbook.R
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.Tracklist
import org.y20k.trackbook.core.TracklistElement
import org.y20k.trackbook.core.load_tracklist
import org.y20k.trackbook.helpers.*
@ -61,24 +61,20 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
/* Listener Interface */
interface TracklistAdapterListener {
fun onTrackElementTapped(tracklistElement: TracklistElement) { }
// fun onTrackElementStarred(trackId: Long, starred: Boolean)
fun onTrackElementTapped(track: Track) { }
}
/* Overrides onAttachedToRecyclerView from RecyclerView.Adapter */
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
// get reference to listener
override fun onAttachedToRecyclerView(recyclerView: RecyclerView)
{
tracklistListener = fragment as TracklistAdapterListener
// load tracklist
tracklist = FileHelper.readTracklist(context)
tracklist.tracklistElements.sortByDescending { tracklistElement -> tracklistElement.date }
tracklist = load_tracklist(context)
}
/* Overrides onCreateViewHolder from RecyclerView.Adapter */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
{
when (viewType) {
Keys.VIEW_TYPE_STATISTICS -> {
val v = LayoutInflater.from(parent.context).inflate(R.layout.element_statistics, parent, false)
@ -104,16 +100,16 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
/* Overrides getItemCount from RecyclerView.Adapter */
override fun getItemCount(): Int {
// +1 ==> the total statistics element
return tracklist.tracklistElements.size + 1
// +1 because of the total statistics element
return tracklist.tracks.size + 1
}
/* Overrides onBindViewHolder from RecyclerView.Adapter */
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
{
when (holder)
{
// CASE STATISTICS ELEMENT
is ElementStatisticsViewHolder -> {
val elementStatisticsViewHolder: ElementStatisticsViewHolder = holder as ElementStatisticsViewHolder
@ -124,14 +120,14 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
is ElementTrackViewHolder -> {
val positionInTracklist: Int = position - 1 // Element 0 is the statistics element.
val elementTrackViewHolder: ElementTrackViewHolder = holder as ElementTrackViewHolder
elementTrackViewHolder.trackNameView.text = tracklist.tracklistElements[positionInTracklist].name
elementTrackViewHolder.trackNameView.text = tracklist.tracks[positionInTracklist].name
elementTrackViewHolder.trackDataView.text = createTrackDataString(positionInTracklist)
when (tracklist.tracklistElements[positionInTracklist].starred) {
when (tracklist.tracks[positionInTracklist].starred) {
true -> elementTrackViewHolder.starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_filled_24dp))
false -> elementTrackViewHolder.starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_outline_24dp))
}
elementTrackViewHolder.trackElement.setOnClickListener {
tracklistListener.onTrackElementTapped(tracklist.tracklistElements[positionInTracklist])
tracklistListener.onTrackElementTapped(tracklist.tracks[positionInTracklist])
}
elementTrackViewHolder.starButton.setOnClickListener {
toggleStarred(it, positionInTracklist)
@ -144,102 +140,76 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
/* Get track name for given position */
fun getTrackName(positionInRecyclerView: Int): String {
// first position is always the statistics element
return tracklist.tracklistElements[positionInRecyclerView - 1].name
fun getTrackName(positionInRecyclerView: Int): String
{
// Minus 1 because first position is always the statistics element
return tracklist.tracks[positionInRecyclerView - 1].name
}
/* Removes track and track files for given position - used by TracklistFragment */
fun removeTrackAtPosition(context: Context, position: Int) {
fun delete_track_at_position(context: Context, ui_index: Int)
{
CoroutineScope(IO).launch {
val index = position - 1 // position 0 is the statistics element
val tracklist_element = tracklist.tracklistElements[index]
val deferred: Deferred<Tracklist> = async { FileHelper.deleteTrackSuspended(context, tracklist_element, tracklist) }
val track_index = ui_index - 1 // position 0 is the statistics element
val track = tracklist.tracks[track_index]
val deferred: Deferred<Unit> = async { track.delete_suspended(context) }
// wait for result and store in tracklist
withContext(Main) {
tracklist = deferred.await()
deferred.await()
tracklist.tracks.remove(track)
notifyItemChanged(0)
notifyItemRemoved(position)
notifyItemRangeChanged(position, tracklist.tracklistElements.size)
notifyItemRemoved(ui_index)
notifyItemRangeChanged(ui_index, tracklist.tracks.size)
}
}
}
/* Suspend function: Wrapper for removeTrackAtPosition */
suspend fun removeTrackAtPositionSuspended(context: Context, position: Int) {
suspend fun delete_track_at_position_suspended(context: Context, position: Int) {
return suspendCoroutine { cont ->
cont.resume(removeTrackAtPosition(context, position))
cont.resume(delete_track_at_position(context, position))
}
}
/* Removes track and track files for given track id - used by TracklistFragment */
fun removeTrackById(context: Context, trackId: Long) {
fun delete_track_by_id(context: Context, trackId: Long) {
CoroutineScope(IO).launch {
// reload tracklist //todo check if necessary
tracklist = FileHelper.readTracklist(context)
val index: Int = tracklist.tracklistElements.indexOfFirst {it.id == trackId}
val index: Int = tracklist.tracks.indexOfFirst {it.id == trackId}
if (index == -1) {
return@launch
}
val tracklist_element = tracklist.tracklistElements[index]
val deferred: Deferred<Tracklist> = async { FileHelper.deleteTrackSuspended(context, tracklist_element, tracklist) }
// wait for result and store in tracklist
val position = index + 1 // position 0 is the statistics element
withContext(Main) {
tracklist = deferred.await()
notifyItemChanged(0)
notifyItemRemoved(position)
notifyItemRangeChanged(position, tracklist.tracklistElements.size)
}
delete_track_at_position(context, index + 1)
}
}
/* Returns if the adapter is empty */
fun isEmpty(): Boolean {
return tracklist.tracklistElements.size == 0
return tracklist.tracks.size == 0
}
/* Finds current position of track element in adapter list */
private fun findPosition(trackId: Long): Int {
tracklist.tracklistElements.forEachIndexed {index, tracklistElement ->
if (tracklistElement.id == 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(ContextCompat.getDrawable(context, R.drawable.ic_star_outline_24dp))
tracklist.tracklistElements[position].starred = false
}
false -> {
starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_filled_24dp))
tracklist.tracklistElements[position].starred = true
}
if (tracklist.tracks[position].starred)
{
starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_outline_24dp))
tracklist.tracks[position].starred = false
}
else
{
starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_filled_24dp))
tracklist.tracks[position].starred = true
}
tracklist.tracks[position].save_json(context)
}
/* Creates the track data string */
private fun createTrackDataString(position: Int): String {
val tracklistElement: TracklistElement = tracklist.tracklistElements[position]
val track_duration_string = DateTimeHelper.convertToReadableTime(context, tracklistElement.duration)
val track: Track = tracklist.tracks[position]
val track_duration_string = DateTimeHelper.convertToReadableTime(context, track.duration)
val trackDataString: String
when (tracklistElement.name == tracklistElement.dateString) {
when (track.name == track.dateString) {
// CASE: no individual name set - exclude date
true -> trackDataString = "${LengthUnitHelper.convertDistanceToString(tracklistElement.distance, useImperial)}${track_duration_string}"
true -> trackDataString = "${LengthUnitHelper.convertDistanceToString(track.distance, useImperial)}${track_duration_string}"
// CASE: no individual name set - include date
false -> trackDataString = "${tracklistElement.dateString}${LengthUnitHelper.convertDistanceToString(tracklistElement.distance, useImperial)}${track_duration_string}"
false -> trackDataString = "${track.dateString}${LengthUnitHelper.convertDistanceToString(track.distance, useImperial)}${track_duration_string}"
}
return trackDataString
}
@ -251,22 +221,22 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
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]
val oldItem = oldList.tracks[oldItemPosition]
val newItem = newList.tracks[newItemPosition]
return oldItem.id == newItem.id
}
override fun getOldListSize(): Int {
return oldList.tracklistElements.size
return oldList.tracks.size
}
override fun getNewListSize(): Int {
return newList.tracklistElements.size
return newList.tracks.size
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList.tracklistElements[oldItemPosition]
val newItem = newList.tracklistElements[newItemPosition]
val oldItem = oldList.tracks[oldItemPosition]
val newItem = newList.tracks[newItemPosition]
return (oldItem.id == newItem.id) && (oldItem.distance == newItem.distance)
}
}

View File

@ -187,7 +187,7 @@ data class TrackFragmentLayoutHolder(private var context: Context, private var m
mapView.overlays.add(trackSpecialMarkersOverlay)
}
// save track
CoroutineScope(Dispatchers.IO).launch { FileHelper.saveTrackSuspended(track, true) }
CoroutineScope(Dispatchers.IO).launch { track.save_both_suspended(context) }
}
@ -196,7 +196,7 @@ data class TrackFragmentLayoutHolder(private var context: Context, private var m
{
if (track.latitude != 0.0 && track.longitude != 0.0)
{
CoroutineScope(Dispatchers.IO).launch { FileHelper.saveTrackSuspended(track, false) }
CoroutineScope(Dispatchers.IO).launch { track.save_json_suspended(context) }
}
}