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.
This commit is contained in:
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 android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -36,11 +37,9 @@ import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.y20k.trackbook.core.Track import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.TracklistElement
import org.y20k.trackbook.helpers.* import org.y20k.trackbook.helpers.*
import org.y20k.trackbook.ui.MapFragmentLayoutHolder import org.y20k.trackbook.ui.MapFragmentLayoutHolder
/* /*
* MapFragment class * MapFragment class
*/ */
@ -49,7 +48,6 @@ class MapFragment : Fragment(), YesNoDialog.YesNoDialogListener, MapOverlayHelpe
/* Define log tag */ /* Define log tag */
private val TAG: String = LogHelper.makeLogTag(MapFragment::class.java) private val TAG: String = LogHelper.makeLogTag(MapFragment::class.java)
/* Main class variables */ /* Main class variables */
private var bound: Boolean = false private var bound: Boolean = false
private val handler: Handler = Handler(Looper.getMainLooper()) private val handler: Handler = Handler(Looper.getMainLooper())
@ -217,7 +215,7 @@ class MapFragment : Fragment(), YesNoDialog.YesNoDialogListener, MapOverlayHelpe
override fun onMarkerTapped(latitude: Double, longitude: Double) { override fun onMarkerTapped(latitude: Double, longitude: Double) {
super.onMarkerTapped(latitude, longitude) super.onMarkerTapped(latitude, longitude)
if (bound) { 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) layout.overlayCurrentTrack(track, trackingState)
trackerService.track = track trackerService.track = track
} }
@ -298,16 +296,12 @@ class MapFragment : Fragment(), YesNoDialog.YesNoDialogListener, MapOverlayHelpe
else else
{ {
CoroutineScope(IO).launch { CoroutineScope(IO).launch {
// step 1: create and store filenames for json and gpx files track.save_json(activity as Context)
track.trackUriString = FileHelper.getTrackFileUri(activity as Context, track).toString() track.save_gpx(activity as Context)
track.gpxUriString = FileHelper.getGpxFileUri(activity as Context, track).toString()
// step 2: save track
FileHelper.saveTrackSuspended(track, saveGpxToo = true)
// step 3: clear track
trackerService.clearTrack() trackerService.clearTrack()
// step 4: open track in TrackFragement
withContext(Main) { 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 */ /* Opens a track in TrackFragment */
private fun openTrack(tracklistElement: TracklistElement) { private fun openTrack(track: Track) {
val bundle: Bundle = Bundle() val bundle: Bundle = Bundle()
bundle.putString(Keys.ARG_TRACK_TITLE, tracklistElement.name) bundle.putString(Keys.ARG_TRACK_TITLE, track.name)
bundle.putString(Keys.ARG_TRACK_FILE_URI, tracklistElement.trackUriString) bundle.putString(Keys.ARG_TRACK_FILE_URI, track.get_json_file(activity as Context).toUri().toString())
bundle.putString(Keys.ARG_GPX_FILE_URI, tracklistElement.gpxUriString) bundle.putString(Keys.ARG_GPX_FILE_URI, track.get_gpx_file(activity as Context).toUri().toString())
bundle.putLong(Keys.ARG_TRACK_ID, tracklistElement.id) bundle.putLong(Keys.ARG_TRACK_ID, track.id)
findNavController().navigate(R.id.fragment_track, bundle) 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.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.y20k.trackbook.core.Tracklist import org.y20k.trackbook.core.Tracklist
import org.y20k.trackbook.core.load_tracklist
import org.y20k.trackbook.helpers.AppThemeHelper import org.y20k.trackbook.helpers.AppThemeHelper
import org.y20k.trackbook.helpers.FileHelper import org.y20k.trackbook.helpers.FileHelper
import org.y20k.trackbook.helpers.LengthUnitHelper 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 */ /* Removes track and track files for given position - used by TracklistFragment */
private fun deleteNonStarred(context: Context) { private fun deleteNonStarred(context: Context) {
CoroutineScope(IO).launch { var tracklist: Tracklist = load_tracklist(context)
var tracklist: Tracklist = FileHelper.readTracklist(context) tracklist.delete_non_starred(context)
val deferred: Deferred<Tracklist> = async { FileHelper.deleteNonStarredSuspended(context, tracklist) }
// wait for result and store in tracklist
tracklist = deferred.await()
}
} }

View file

@ -33,6 +33,7 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@ -40,7 +41,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.y20k.trackbook.core.Track import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.track_from_file
import org.y20k.trackbook.dialogs.RenameTrackDialog import org.y20k.trackbook.dialogs.RenameTrackDialog
import org.y20k.trackbook.helpers.DateTimeHelper
import org.y20k.trackbook.helpers.FileHelper import org.y20k.trackbook.helpers.FileHelper
import org.y20k.trackbook.helpers.LogHelper import org.y20k.trackbook.helpers.LogHelper
import org.y20k.trackbook.helpers.MapOverlayHelper import org.y20k.trackbook.helpers.MapOverlayHelper
@ -60,7 +63,8 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onCreate from Fragment */ /* Overrides onCreate from Fragment */
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
trackFileUriString = arguments?.getString(Keys.ARG_TRACK_FILE_URI, String()) ?: String() 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 { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
// initialize layout // initialize layout
val track: Track val track: Track
if (this::trackFileUriString.isInitialized && trackFileUriString.isNotBlank()) { if (this::trackFileUriString.isInitialized && trackFileUriString.isNotBlank())
track = FileHelper.readTrack(activity as Context, Uri.parse(trackFileUriString)) {
track = track_from_file(activity as Context, Uri.parse(trackFileUriString).toFile())
} else { } else {
track = Track() track = Track()
} }
@ -107,13 +112,15 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onResume from Fragment */ /* Overrides onResume from Fragment */
override fun onResume() { override fun onResume()
{
super.onResume() super.onResume()
} }
/* Overrides onPause from Fragment */ /* Overrides onPause from Fragment */
override fun onPause() { override fun onPause()
{
super.onPause() super.onPause()
// save zoom level and map center // save zoom level and map center
layout.saveViewStateToTrack() layout.saveViewStateToTrack()
@ -125,12 +132,15 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Pass the activity result */ /* Pass the activity result */
private fun requestSaveGpxResult(result: ActivityResult) { private fun requestSaveGpxResult(result: ActivityResult)
{
// save GPX file to result file location // save GPX file to result file location
if (result.resultCode == Activity.RESULT_OK && result.data != null) { if (result.resultCode == Activity.RESULT_OK && result.data != null)
val sourceUri: Uri = Uri.parse(layout.track.gpxUriString) {
val sourceUri: Uri = layout.track.get_gpx_file(activity as Context).toUri()
val targetUri: Uri? = result.data?.data val targetUri: Uri? = result.data?.data
if (targetUri != null) { if (targetUri != null)
{
// copy file async (= fire & forget - no return value needed) // copy file async (= fire & forget - no return value needed)
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
FileHelper.saveCopyOfFileSuspended(activity as Context, originalFileUri = sourceUri, targetFileUri = targetUri) FileHelper.saveCopyOfFileSuspended(activity as Context, originalFileUri = sourceUri, targetFileUri = targetUri)
@ -142,7 +152,8 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onRenameTrackDialog from RenameTrackDialog */ /* Overrides onRenameTrackDialog from RenameTrackDialog */
override fun onRenameTrackDialog(textInput: String) { override fun onRenameTrackDialog(textInput: String)
{
// rename track async (= fire & forget - no return value needed) // rename track async (= fire & forget - no return value needed)
CoroutineScope(Dispatchers.IO).launch { FileHelper.renameTrackSuspended(activity as Context, layout.track, textInput) } CoroutineScope(Dispatchers.IO).launch { FileHelper.renameTrackSuspended(activity as Context, layout.track, textInput) }
// update name in layout // update name in layout
@ -152,10 +163,13 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onYesNoDialog from YesNoDialogListener */ /* Overrides onYesNoDialog from YesNoDialogListener */
override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) { override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String)
when (type) { {
when (type)
{
Keys.DIALOG_DELETE_TRACK -> { Keys.DIALOG_DELETE_TRACK -> {
when (dialogResult) { when (dialogResult)
{
// user tapped remove track // user tapped remove track
true -> { true -> {
// switch to TracklistFragment and remove track there // switch to TracklistFragment and remove track there
@ -169,25 +183,31 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
/* Overrides onMarkerTapped from MarkerListener */ /* Overrides onMarkerTapped from MarkerListener */
override fun onMarkerTapped(latitude: Double, longitude: Double) { override fun onMarkerTapped(latitude: Double, longitude: Double)
{
super.onMarkerTapped(latitude, longitude) super.onMarkerTapped(latitude, longitude)
// update track display TrackHelper.toggle_waypoint_starred(activity as Context, layout.track, latitude, longitude)
layout.track = TrackHelper.toggleStarred(activity as Context, layout.track, latitude, longitude)
layout.updateTrackOverlay() layout.updateTrackOverlay()
} }
/* Opens up a file picker to select the save location */ /* 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 { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE) addCategory(Intent.CATEGORY_OPENABLE)
type = Keys.MIME_TYPE_GPX 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 // file gets saved in the ActivityResult
try { try
{
requestSaveGpxLauncher.launch(intent) requestSaveGpxLauncher.launch(intent)
} catch (e: Exception) { }
catch (e: Exception)
{
LogHelper.e(TAG, "Unable to save GPX.") LogHelper.e(TAG, "Unable to save GPX.")
Toast.makeText(activity as Context, R.string.toast_message_install_file_helper, Toast.LENGTH_LONG).show() 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 */ /* Share track as GPX via share sheet */
private fun shareGpxTrack() { private fun shareGpxTrack()
val gpxFile = Uri.parse(layout.track.gpxUriString).toFile() {
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 gpxShareUri = FileProvider.getUriForFile(this.activity as Context, "${requireActivity().applicationContext.packageName}.provider", gpxFile)
val shareIntent: Intent = Intent.createChooser(Intent().apply { val shareIntent: Intent = Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
@ -208,9 +229,12 @@ class TrackFragment : Fragment(), RenameTrackDialog.RenameTrackListener, YesNoDi
// show share sheet - if file helper is available // show share sheet - if file helper is available
val packageManager: PackageManager? = activity?.packageManager val packageManager: PackageManager? = activity?.packageManager
if (packageManager != null && shareIntent.resolveActivity(packageManager) != null) { if (packageManager != null && shareIntent.resolveActivity(packageManager) != null)
{
startActivity(shareIntent) startActivity(shareIntent)
} else { }
else
{
Toast.makeText(activity, R.string.toast_message_install_file_helper, Toast.LENGTH_LONG).show() 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.launch
import kotlinx.coroutines.Runnable import kotlinx.coroutines.Runnable
import org.y20k.trackbook.core.Track import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.load_temp_track
import org.y20k.trackbook.helpers.* import org.y20k.trackbook.helpers.*
/* /*
@ -61,6 +62,8 @@ class TrackerService: Service(), SensorEventListener {
var currentBestLocation: Location = LocationHelper.getDefaultLocation() var currentBestLocation: Location = LocationHelper.getDefaultLocation()
var lastSave: Date = Keys.DEFAULT_DATE var lastSave: Date = Keys.DEFAULT_DATE
var stepCountOffset: Float = 0f 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 resumed: Boolean = false
var track: Track = Track() var track: Track = Track()
var gpsLocationListenerRegistered: Boolean = false var gpsLocationListenerRegistered: Boolean = false
@ -93,7 +96,7 @@ class TrackerService: Service(), SensorEventListener {
networkLocationListener = createLocationListener() networkLocationListener = createLocationListener()
trackingState = PreferencesHelper.loadTrackingState() trackingState = PreferencesHelper.loadTrackingState()
currentBestLocation = LocationHelper.getLastKnownLocation(this) currentBestLocation = LocationHelper.getLastKnownLocation(this)
track = FileHelper.readTrack(this, FileHelper.getTempFileUri(this)) track = load_temp_track(this)
// altitudeValues.capacity = PreferencesHelper.loadAltitudeSmoothingValue() // altitudeValues.capacity = PreferencesHelper.loadAltitudeSmoothingValue()
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener) PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
} }
@ -195,72 +198,68 @@ class TrackerService: Service(), SensorEventListener {
/* Resume tracking after stop/pause */ /* Resume tracking after stop/pause */
fun resumeTracking() { fun resumeTracking() {
// load temp track - returns an empty track if not available // load temp track - returns an empty track if there is no temp file.
track = FileHelper.readTrack(this, FileHelper.getTempFileUri(this)) track = load_temp_track(this)
// try to mark last waypoint as stopover // try to mark last waypoint as stopover
if (track.wayPoints.size > 0) { if (track.wayPoints.size > 0) {
val lastWayPointIndex = track.wayPoints.size - 1 val lastWayPointIndex = track.wayPoints.size - 1
track.wayPoints[lastWayPointIndex].isStopOver = true track.wayPoints[lastWayPointIndex].isStopOver = true
} }
// set resumed flag
resumed = true resumed = true
// calculate length of recording break // calculate length of recording break
track.recordingPaused = track.recordingPaused + TrackHelper.calculateDurationOfPause(track.recordingStop) track.recordingPaused += TrackHelper.calculateDurationOfPause(track.recordingStop)
// start tracking
startTracking(newTrack = false) startTracking(newTrack = false)
} }
/* Start tracking location */ /* Start tracking location */
fun startTracking(newTrack: Boolean = true) { fun startTracking(newTrack: Boolean = true) {
// start receiving location updates
addGpsLocationListener() addGpsLocationListener()
addNetworkLocationListener() addNetworkLocationListener()
// set up new track // set up new track
if (newTrack) { if (newTrack) {
track = Track() track = Track()
track.name = DateTimeHelper.convertToReadableDate(track.recordingStart) resumed = false
stepCountOffset = 0f stepCountOffset = 0f
} }
// set state
trackingState = Keys.STATE_TRACKING_ACTIVE trackingState = Keys.STATE_TRACKING_ACTIVE
PreferencesHelper.saveTrackingState(trackingState) PreferencesHelper.saveTrackingState(trackingState)
// start recording steps and location fixes
startStepCounter() startStepCounter()
handler.postDelayed(periodicTrackUpdate, 0) handler.postDelayed(periodicTrackUpdate, 0)
// show notification
startForeground(Keys.TRACKER_SERVICE_NOTIFICATION_ID, displayNotification()) startForeground(Keys.TRACKER_SERVICE_NOTIFICATION_ID, displayNotification())
} }
/* Stop tracking location */ /* Stop tracking location */
fun stopTracking() { fun stopTracking() {
// save temp track
track.recordingStop = GregorianCalendar.getInstance().time track.recordingStop = GregorianCalendar.getInstance().time
CoroutineScope(IO).launch { FileHelper.saveTempTrackSuspended(this@TrackerService, track) } val context: Context = this as Context
// save state CoroutineScope(IO).launch { track.save_temp_suspended(context) }
trackingState = Keys.STATE_TRACKING_STOPPED
trackingState = Keys.STATE_TRACKING_PAUSED trackingState = Keys.STATE_TRACKING_PAUSED
PreferencesHelper.saveTrackingState(trackingState) PreferencesHelper.saveTrackingState(trackingState)
// reset altitude values queue
altitudeValues.reset() altitudeValues.reset()
// stop recording steps and location fixes
sensorManager.unregisterListener(this) sensorManager.unregisterListener(this)
handler.removeCallbacks(periodicTrackUpdate) handler.removeCallbacks(periodicTrackUpdate)
// update notification
displayNotification() displayNotification()
stopForeground(false) stopForeground(false)
} }
/* Clear track recording */ fun clearTrack()
fun clearTrack() { {
track = Track() track = Track()
FileHelper.deleteTempFile(this) resumed = false
FileHelper.delete_temp_file(this as Context)
trackingState = Keys.STATE_TRACKING_NOT_STARTED trackingState = Keys.STATE_TRACKING_NOT_STARTED
PreferencesHelper.saveTrackingState(trackingState) PreferencesHelper.saveTrackingState(trackingState)
stopForeground(true) stopForeground(true)
notificationManager.cancel(Keys.TRACKER_SERVICE_NOTIFICATION_ID) // this call was not necessary prior to Android 12 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 { return object : LocationListener {
override fun onLocationChanged(location: Location) { override fun onLocationChanged(location: Location) {
// update currentBestLocation if a better location is available // update currentBestLocation if a better location is available
@ -268,7 +267,8 @@ class TrackerService: Service(), SensorEventListener {
currentBestLocation = location currentBestLocation = location
} }
} }
override fun onProviderEnabled(provider: String) { override fun onProviderEnabled(provider: String)
{
LogHelper.v(TAG, "onProviderEnabled $provider") LogHelper.v(TAG, "onProviderEnabled $provider")
when (provider) { when (provider) {
LocationManager.GPS_PROVIDER -> gpsProviderActive = LocationHelper.isGpsEnabled( 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") LogHelper.v(TAG, "onProviderDisabled $provider")
when (provider) { when (provider) {
LocationManager.GPS_PROVIDER -> gpsProviderActive = LocationHelper.isGpsEnabled( LocationManager.GPS_PROVIDER -> gpsProviderActive = LocationHelper.isGpsEnabled(
@ -292,25 +293,36 @@ class TrackerService: Service(), SensorEventListener {
) )
} }
} }
override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?) { override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?)
{
// deprecated method // deprecated method
} }
} }
} }
/* Adds a GPS location listener to location manager */ /* Adds a GPS location listener to location manager */
private fun addGpsLocationListener() { private fun addGpsLocationListener()
// check if already registered {
if (!gpsLocationListenerRegistered) { if (gpsLocationListenerRegistered)
// check if Network provider is available {
LogHelper.v(TAG, "GPS location listener has already been added.")
return
}
gpsProviderActive = LocationHelper.isGpsEnabled(locationManager) gpsProviderActive = LocationHelper.isGpsEnabled(locationManager)
if (gpsProviderActive) { if (! gpsProviderActive)
// check for location permission {
if (ContextCompat.checkSelfPermission( LogHelper.w(TAG, "Device GPS is not enabled.")
this, return
Manifest.permission.ACCESS_FINE_LOCATION }
) == PackageManager.PERMISSION_GRANTED) {
// adds GPS location listener 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.requestLocationUpdates(
LocationManager.GPS_PROVIDER, LocationManager.GPS_PROVIDER,
0, 0,
@ -319,32 +331,20 @@ class TrackerService: Service(), SensorEventListener {
) )
gpsLocationListenerRegistered = true gpsLocationListenerRegistered = true
LogHelper.v(TAG, "Added GPS location listener.") 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.")
}
} }
/* Adds a Network location listener to location manager */ /* Adds a Network location listener to location manager */
private fun addNetworkLocationListener() { private fun addNetworkLocationListener() {
if (gpsOnly) if (gpsOnly)
{ {
LogHelper.v(TAG, "User prefers GPS-only.") LogHelper.v(TAG, "Skipping Network listener. User prefers GPS-only.")
return; return
} }
if (networkLocationListenerRegistered) if (networkLocationListenerRegistered)
{ {
LogHelper.v(TAG, "Network location listener has already been added.") LogHelper.v(TAG, "Network location listener has already been added.")
return; return
} }
networkProviderActive = LocationHelper.isNetworkEnabled(locationManager) networkProviderActive = LocationHelper.isNetworkEnabled(locationManager)
@ -378,10 +378,7 @@ class TrackerService: Service(), SensorEventListener {
gpsLocationListenerRegistered = false gpsLocationListenerRegistered = false
LogHelper.v(TAG, "Removed GPS location listener.") LogHelper.v(TAG, "Removed GPS location listener.")
} else { } else {
LogHelper.w( LogHelper.w(TAG, "Unable to remove GPS location listener. Location permission is needed.")
TAG,
"Unable to remove GPS location listener. Location permission is needed."
)
} }
} }
@ -392,10 +389,7 @@ class TrackerService: Service(), SensorEventListener {
networkLocationListenerRegistered = false networkLocationListenerRegistered = false
LogHelper.v(TAG, "Removed Network location listener.") LogHelper.v(TAG, "Removed Network location listener.")
} else { } else {
LogHelper.w( LogHelper.w(TAG, "Unable to remove Network location listener. Location permission is needed.")
TAG,
"Unable to remove Network location listener. Location permission is needed."
)
} }
} }
@ -460,19 +454,13 @@ class TrackerService: Service(), SensorEventListener {
/* /*
* Runnable: Periodically track updates (if recording active) * Runnable: Periodically track updates (if recording active)
*/ */
private val periodicTrackUpdate: Runnable = object : Runnable { private val periodicTrackUpdate: Runnable = object : Runnable
{
override fun run() { override fun run() {
// add waypoint to track - step count is continuously updated in onSensorChanged // add waypoint to track - step count is continuously updated in onSensorChanged
val result: Pair<Boolean, Track> = TrackHelper.addWayPointToTrack(track, currentBestLocation, omitRests, resumed) val success = track.add_waypoint(currentBestLocation, omitRests, resumed)
// get results if (success) {
val successfullyAdded: Boolean = result.first
track = result.second
// check, if waypoint was added
if (successfullyAdded) {
// reset resumed flag, if necessary
if (resumed) {
resumed = false resumed = false
}
// store previous smoothed altitude // store previous smoothed altitude
val previousAltitude: Double = altitudeValues.getAverage() val previousAltitude: Double = altitudeValues.getAverage()
@ -480,7 +468,7 @@ class TrackerService: Service(), SensorEventListener {
val currentBestLocationAltitude: Double = currentBestLocation.altitude val currentBestLocationAltitude: Double = currentBestLocation.altitude
if (currentBestLocationAltitude != Keys.DEFAULT_ALTITUDE) altitudeValues.add(currentBestLocationAltitude) if (currentBestLocationAltitude != Keys.DEFAULT_ALTITUDE) altitudeValues.add(currentBestLocationAltitude)
// TODO remove // 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 //altitudeValues.add(getTestAltitude()) // TODO remove
// TODO remove // TODO remove
@ -499,7 +487,7 @@ class TrackerService: Service(), SensorEventListener {
val now: Date = GregorianCalendar.getInstance().time val now: Date = GregorianCalendar.getInstance().time
if (now.time - lastSave.time > Keys.SAVE_TEMP_TRACK_INTERVAL) { if (now.time - lastSave.time > Keys.SAVE_TEMP_TRACK_INTERVAL) {
lastSave = now lastSave = now
CoroutineScope(IO).launch { FileHelper.saveTempTrackSuspended(this@TrackerService, track) } CoroutineScope(IO).launch { track.save_temp_suspended(this@TrackerService) }
} }
} }
// update notification // update notification
@ -514,10 +502,12 @@ class TrackerService: Service(), SensorEventListener {
/* Simple queue that evicts older elements and holds an average */ /* Simple queue that evicts older elements and holds an average */
/* Credit: CircularQueue https://stackoverflow.com/a/51923797 */ /* 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 var prepared: Boolean = false
private var sum: Double = 0.0 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 prepared = this.size + 1 >= Keys.MIN_NUMBER_OF_WAYPOINTS_FOR_ELEVATION_CALCULATION
if (this.size >= capacity) { if (this.size >= capacity) {
sum -= this.first sum -= this.first
@ -526,8 +516,12 @@ class TrackerService: Service(), SensorEventListener {
sum += element sum += element
return super.add(element) return super.add(element)
} }
fun getAverage(): Double = sum / this.size fun getAverage(): Double
fun reset() { {
return sum / this.size
}
fun reset()
{
this.clear() this.clear()
prepared = false prepared = false
sum = 0.0 sum = 0.0

View file

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

View file

@ -17,21 +17,30 @@
package org.y20k.trackbook.core package org.y20k.trackbook.core
import android.content.Context import android.content.Context
import android.location.Location
import android.os.Parcelable import android.os.Parcelable
import android.util.Log
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.Expose import com.google.gson.annotations.Expose
import java.io.File
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.random.Random import kotlin.random.Random
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.y20k.trackbook.Keys import org.y20k.trackbook.Keys
import org.y20k.trackbook.helpers.DateTimeHelper import org.y20k.trackbook.helpers.DateTimeHelper
import org.y20k.trackbook.helpers.FileHelper
import org.y20k.trackbook.helpers.LocationHelper
/* /*
* Track data class * Track data class
*/ */
@Keep @Keep
@Parcelize @Parcelize
data class Track (@Expose val id: Long = make_random_id(), data class Track (
@Expose val id: Long = make_random_id(),
@Expose var trackFormatVersion: Int = Keys.CURRENT_TRACK_FORMAT_VERSION, @Expose var trackFormatVersion: Int = Keys.CURRENT_TRACK_FORMAT_VERSION,
@Expose val wayPoints: MutableList<WayPoint> = mutableListOf<WayPoint>(), @Expose val wayPoints: MutableList<WayPoint> = mutableListOf<WayPoint>(),
@Expose var distance: Float = 0f, @Expose var distance: Float = 0f,
@ -39,34 +48,249 @@ data class Track (@Expose val id: Long = make_random_id(),
@Expose var recordingPaused: Long = 0L, @Expose var recordingPaused: Long = 0L,
@Expose var stepCount: Float = 0f, @Expose var stepCount: Float = 0f,
@Expose var recordingStart: Date = GregorianCalendar.getInstance().time, @Expose var recordingStart: Date = GregorianCalendar.getInstance().time,
@Expose var dateString: String = DateTimeHelper.convertToReadableDate(recordingStart),
@Expose var recordingStop: Date = recordingStart, @Expose var recordingStop: Date = recordingStart,
@Expose var maxAltitude: Double = 0.0, @Expose var maxAltitude: Double = 0.0,
@Expose var minAltitude: Double = 0.0, @Expose var minAltitude: Double = 0.0,
@Expose var positiveElevation: Double = 0.0, @Expose var positiveElevation: Double = 0.0,
@Expose var negativeElevation: 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 latitude: Double = Keys.DEFAULT_LATITUDE,
@Expose var longitude: Double = Keys.DEFAULT_LONGITUDE, @Expose var longitude: Double = Keys.DEFAULT_LONGITUDE,
@Expose var zoomLevel: Double = Keys.DEFAULT_ZOOM_LEVEL, @Expose var zoomLevel: Double = Keys.DEFAULT_ZOOM_LEVEL,
@Expose var name: String = String()): Parcelable @Expose var name: String = DateTimeHelper.convertToReadableDate(recordingStart),
@Expose var starred: Boolean = false,
): Parcelable
{ {
/* Creates a TracklistElement */ fun add_waypoint(location: Location, omitRests: Boolean, resumed: Boolean): Boolean
fun toTracklistElement(context: Context): TracklistElement { {
val readableDateString: String = DateTimeHelper.convertToReadableDate(recordingStart) // Step 1: Get previous location
val readableDurationString: String = DateTimeHelper.convertToReadableTime(context, duration) val previousLocation: Location?
return TracklistElement( var numberOfWayPoints: Int = this.wayPoints.size
id = id,
name = name, // CASE: First location
date = recordingStart, if (numberOfWayPoints == 0)
dateString = readableDateString, {
distance = distance, previousLocation = null
duration = duration,
trackUriString = trackUriString,
gpxUriString = gpxUriString,
starred = false
)
} }
// 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 fun make_random_id(): Long

View file

@ -17,54 +17,79 @@
package org.y20k.trackbook.core package org.y20k.trackbook.core
import android.content.Context
import android.os.Parcelable import android.os.Parcelable
import android.util.Log
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.Expose import com.google.gson.annotations.Expose
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.y20k.trackbook.Keys import org.y20k.trackbook.Keys
import org.y20k.trackbook.helpers.TrackHelper
import java.util.*
/* /*
* Tracklist data class * Tracklist data class
*/ */
@Keep @Keep
@Parcelize @Parcelize
data class Tracklist (@Expose val tracklistFormatVersion: Int = Keys.CURRENT_TRACKLIST_FORMAT_VERSION, data class Tracklist (
@Expose val tracklistElements: MutableList<TracklistElement> = mutableListOf<TracklistElement>()): Parcelable { @Expose val tracklistFormatVersion: Int = Keys.CURRENT_TRACKLIST_FORMAT_VERSION,
@Expose val tracks: MutableList<Track> = mutableListOf<Track>()
/* Return trackelement for given track id */ ): Parcelable
fun getTrackElement(trackId: Long): TracklistElement? { {
tracklistElements.forEach { tracklistElement -> fun delete_non_starred(context: Context)
if (tracklistElement.id == trackId) {
return tracklistElement
}
}
return null
}
fun get_total_distance(): Float
{ {
var total: Float = 0F val to_delete: List<Track> = this.tracks.filter{! it.starred}
tracklistElements.forEach { tracklist_element -> to_delete.forEach { track ->
total += tracklist_element.distance if (!track.starred)
{
track.delete(context)
} }
return total }
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(): Double
{
return this.tracks.sumOf {it.distance.toDouble()}
} }
fun get_total_duration(): Long fun get_total_duration(): Long
{ {
var total: Long = 0L return this.tracks.sumOf {it.duration}
tracklistElements.forEach { tracklist_element ->
total += tracklist_element.duration
}
return total
} }
/* Create a deep copy */ fun deepCopy(): Tracklist
fun deepCopy(): Tracklist { {
return Tracklist(tracklistFormatVersion, mutableListOf<TracklistElement>().apply { addAll(tracklistElements) }) 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 package org.y20k.trackbook.helpers
import android.content.Context import android.content.Context
import android.database.Cursor
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder 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.io.*
import java.text.NumberFormat
import java.util.*
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.math.ln import org.y20k.trackbook.Keys
import kotlin.math.pow import org.y20k.trackbook.core.Track
/* /*
@ -48,185 +38,29 @@ object FileHelper {
/* Define log tag */ /* Define log tag */
private val TAG: String = LogHelper.makeLogTag(FileHelper::class.java) private val TAG: String = LogHelper.makeLogTag(FileHelper::class.java)
fun delete_temp_file(context: Context)
/* 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)
{ {
folder.walk().filter{ f: File -> f.isFile }.forEach{ track_file -> val temp: File = get_temp_file(context)
val track_json = readTextFile(context, track_file.toUri()) if (temp.isFile())
Log.i("VOUSSOIR", track_json) {
val track = getCustomGson().fromJson(track_json, Track::class.java) temp.delete()
val tracklist_element = track.toTracklistElement(context)
tracklist.tracklistElements.add(tracklist_element)
} }
} }
return tracklist fun get_temp_file(context: Context): File
} {
return File(context.getExternalFilesDir(Keys.FOLDER_TEMP), Keys.TEMP_FILE)
/* 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()
} }
/* Suspend function: Wrapper for renameTrack */ /* Suspend function: Wrapper for renameTrack */
suspend fun renameTrackSuspended(context: Context, track: Track, newName: String) { suspend fun renameTrackSuspended(context: Context, track: Track, newName: String) {
return suspendCoroutine { cont -> 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 function: Wrapper for copyFile */
suspend fun saveCopyOfFileSuspended(context: Context, originalFileUri: Uri, targetFileUri: Uri, deleteOriginal: Boolean = false) { 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 */ /* Copies file to specified target */
private fun copyFile(context: Context, originalFileUri: Uri, targetFileUri: Uri, deleteOriginal: Boolean = false) { private fun copyFile(context: Context, originalFileUri: Uri, targetFileUri: Uri, deleteOriginal: Boolean = false) {
@ -325,58 +82,19 @@ object FileHelper {
} }
} }
fun getCustomGson(): Gson
/* 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() val gsonBuilder = GsonBuilder()
gsonBuilder.setDateFormat("yyyy-MM-dd-HH-mm-ss") gsonBuilder.setDateFormat("yyyy-MM-dd-HH-mm-ss")
gsonBuilder.excludeFieldsWithoutExposeAnnotation() gsonBuilder.excludeFieldsWithoutExposeAnnotation()
return gsonBuilder.create() 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 */ /* 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 // todo read https://commonsware.com/blog/2016/03/15/how-consume-content-uri.html
// https://developer.android.com/training/secure-file-sharing/retrieve-info // https://developer.android.com/training/secure-file-sharing/retrieve-info
val file: File = fileUri.toFile()
// check if file exists
if (!file.exists()) { if (!file.exists()) {
return String() return String()
} }
@ -393,27 +111,12 @@ object FileHelper {
/* Writes given text to file on storage */ /* 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()) { if (text.isNotEmpty()) {
val file: File = fileUri.toFile()
file.writeText(text) file.writeText(text)
} else { } 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 package org.y20k.trackbook.helpers
import android.content.Context import android.content.Context
import android.location.Location
import android.widget.Toast import android.widget.Toast
import androidx.core.net.toUri
import java.text.SimpleDateFormat
import java.util.* 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.R
import org.y20k.trackbook.core.Track 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 * TrackHelper object
@ -41,123 +31,14 @@ object TrackHelper {
private val TAG: String = LogHelper.makeLogTag(TrackHelper::class.java) private val TAG: String = LogHelper.makeLogTag(TrackHelper::class.java)
/* Adds given locatiom as waypoint to track */ /* 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 */ /* Calculates time passed since last stop of recording */
fun calculateDurationOfPause(recordingStop: Date): Long = GregorianCalendar.getInstance().time.time - recordingStop.time 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 */ /* 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 -> track.wayPoints.forEach { waypoint ->
if (waypoint.latitude == latitude && waypoint.longitude == longitude) { if (waypoint.latitude == latitude && waypoint.longitude == longitude) {
waypoint.starred = !waypoint.starred 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.fragment.app.Fragment
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import java.util.*
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -38,8 +37,9 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import org.y20k.trackbook.Keys import org.y20k.trackbook.Keys
import org.y20k.trackbook.R import org.y20k.trackbook.R
import org.y20k.trackbook.core.Track
import org.y20k.trackbook.core.Tracklist import org.y20k.trackbook.core.Tracklist
import org.y20k.trackbook.core.TracklistElement import org.y20k.trackbook.core.load_tracklist
import org.y20k.trackbook.helpers.* import org.y20k.trackbook.helpers.*
@ -61,24 +61,20 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
/* Listener Interface */ /* Listener Interface */
interface TracklistAdapterListener { interface TracklistAdapterListener {
fun onTrackElementTapped(tracklistElement: TracklistElement) { } fun onTrackElementTapped(track: Track) { }
// fun onTrackElementStarred(trackId: Long, starred: Boolean)
} }
/* Overrides onAttachedToRecyclerView from RecyclerView.Adapter */ override fun onAttachedToRecyclerView(recyclerView: RecyclerView)
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { {
// get reference to listener
tracklistListener = fragment as TracklistAdapterListener tracklistListener = fragment as TracklistAdapterListener
// load tracklist tracklist = load_tracklist(context)
tracklist = FileHelper.readTracklist(context)
tracklist.tracklistElements.sortByDescending { tracklistElement -> tracklistElement.date }
} }
/* Overrides onCreateViewHolder from RecyclerView.Adapter */ /* 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) { when (viewType) {
Keys.VIEW_TYPE_STATISTICS -> { Keys.VIEW_TYPE_STATISTICS -> {
val v = LayoutInflater.from(parent.context).inflate(R.layout.element_statistics, parent, false) 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 */ /* Overrides getItemCount from RecyclerView.Adapter */
override fun getItemCount(): Int { override fun getItemCount(): Int {
// +1 ==> the total statistics element // +1 because of the total statistics element
return tracklist.tracklistElements.size + 1 return tracklist.tracks.size + 1
} }
/* Overrides onBindViewHolder from RecyclerView.Adapter */ /* Overrides onBindViewHolder from RecyclerView.Adapter */
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
{
when (holder) { when (holder)
{
// CASE STATISTICS ELEMENT // CASE STATISTICS ELEMENT
is ElementStatisticsViewHolder -> { is ElementStatisticsViewHolder -> {
val elementStatisticsViewHolder: ElementStatisticsViewHolder = holder as ElementStatisticsViewHolder val elementStatisticsViewHolder: ElementStatisticsViewHolder = holder as ElementStatisticsViewHolder
@ -124,14 +120,14 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
is ElementTrackViewHolder -> { is ElementTrackViewHolder -> {
val positionInTracklist: Int = position - 1 // Element 0 is the statistics element. val positionInTracklist: Int = position - 1 // Element 0 is the statistics element.
val elementTrackViewHolder: ElementTrackViewHolder = holder as ElementTrackViewHolder 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) 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)) 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)) false -> elementTrackViewHolder.starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_outline_24dp))
} }
elementTrackViewHolder.trackElement.setOnClickListener { elementTrackViewHolder.trackElement.setOnClickListener {
tracklistListener.onTrackElementTapped(tracklist.tracklistElements[positionInTracklist]) tracklistListener.onTrackElementTapped(tracklist.tracks[positionInTracklist])
} }
elementTrackViewHolder.starButton.setOnClickListener { elementTrackViewHolder.starButton.setOnClickListener {
toggleStarred(it, positionInTracklist) toggleStarred(it, positionInTracklist)
@ -144,102 +140,76 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
/* Get track name for given position */ /* Get track name for given position */
fun getTrackName(positionInRecyclerView: Int): String { fun getTrackName(positionInRecyclerView: Int): String
// first position is always the statistics element {
return tracklist.tracklistElements[positionInRecyclerView - 1].name // Minus 1 because first position is always the statistics element
return tracklist.tracks[positionInRecyclerView - 1].name
} }
fun delete_track_at_position(context: Context, ui_index: Int)
/* Removes track and track files for given position - used by TracklistFragment */ {
fun removeTrackAtPosition(context: Context, position: Int) {
CoroutineScope(IO).launch { CoroutineScope(IO).launch {
val index = position - 1 // position 0 is the statistics element val track_index = ui_index - 1 // position 0 is the statistics element
val tracklist_element = tracklist.tracklistElements[index] val track = tracklist.tracks[track_index]
val deferred: Deferred<Tracklist> = async { FileHelper.deleteTrackSuspended(context, tracklist_element, tracklist) } val deferred: Deferred<Unit> = async { track.delete_suspended(context) }
// wait for result and store in tracklist // wait for result and store in tracklist
withContext(Main) { withContext(Main) {
tracklist = deferred.await() deferred.await()
tracklist.tracks.remove(track)
notifyItemChanged(0) notifyItemChanged(0)
notifyItemRemoved(position) notifyItemRemoved(ui_index)
notifyItemRangeChanged(position, tracklist.tracklistElements.size) notifyItemRangeChanged(ui_index, tracklist.tracks.size)
} }
} }
} }
/* Suspend function: Wrapper for removeTrackAtPosition */ suspend fun delete_track_at_position_suspended(context: Context, position: Int) {
suspend fun removeTrackAtPositionSuspended(context: Context, position: Int) {
return suspendCoroutine { cont -> 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 delete_track_by_id(context: Context, trackId: Long) {
fun removeTrackById(context: Context, trackId: Long) {
CoroutineScope(IO).launch { CoroutineScope(IO).launch {
// reload tracklist //todo check if necessary val index: Int = tracklist.tracks.indexOfFirst {it.id == trackId}
tracklist = FileHelper.readTracklist(context)
val index: Int = tracklist.tracklistElements.indexOfFirst {it.id == trackId}
if (index == -1) { if (index == -1) {
return@launch return@launch
} }
val tracklist_element = tracklist.tracklistElements[index] delete_track_at_position(context, index + 1)
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)
} }
} }
}
/* Returns if the adapter is empty */ /* Returns if the adapter is empty */
fun isEmpty(): Boolean { 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 */ /* Toggles the starred state of tracklist element - and saves tracklist */
private fun toggleStarred(view: View, position: Int) { private fun toggleStarred(view: View, position: Int) {
val starButton: ImageButton = view as ImageButton val starButton: ImageButton = view as ImageButton
when (tracklist.tracklistElements[position].starred) { if (tracklist.tracks[position].starred)
true -> { {
starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_outline_24dp)) starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_outline_24dp))
tracklist.tracklistElements[position].starred = false tracklist.tracks[position].starred = false
} }
false -> { else
{
starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_filled_24dp)) starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_filled_24dp))
tracklist.tracklistElements[position].starred = true tracklist.tracks[position].starred = true
} }
tracklist.tracks[position].save_json(context)
} }
}
/* Creates the track data string */ /* Creates the track data string */
private fun createTrackDataString(position: Int): String { private fun createTrackDataString(position: Int): String {
val tracklistElement: TracklistElement = tracklist.tracklistElements[position] val track: Track = tracklist.tracks[position]
val track_duration_string = DateTimeHelper.convertToReadableTime(context, tracklistElement.duration) val track_duration_string = DateTimeHelper.convertToReadableTime(context, track.duration)
val trackDataString: String val trackDataString: String
when (tracklistElement.name == tracklistElement.dateString) { when (track.name == track.dateString) {
// CASE: no individual name set - exclude date // 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 // 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 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() { private inner class DiffCallback(val oldList: Tracklist, val newList: Tracklist): DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList.tracklistElements[oldItemPosition] val oldItem = oldList.tracks[oldItemPosition]
val newItem = newList.tracklistElements[newItemPosition] val newItem = newList.tracks[newItemPosition]
return oldItem.id == newItem.id return oldItem.id == newItem.id
} }
override fun getOldListSize(): Int { override fun getOldListSize(): Int {
return oldList.tracklistElements.size return oldList.tracks.size
} }
override fun getNewListSize(): Int { override fun getNewListSize(): Int {
return newList.tracklistElements.size return newList.tracks.size
} }
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList.tracklistElements[oldItemPosition] val oldItem = oldList.tracks[oldItemPosition]
val newItem = newList.tracklistElements[newItemPosition] val newItem = newList.tracks[newItemPosition]
return (oldItem.id == newItem.id) && (oldItem.distance == newItem.distance) 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) mapView.overlays.add(trackSpecialMarkersOverlay)
} }
// save track // 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) 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) }
} }
} }