diff --git a/app/src/main/java/org/y20k/trackbook/MapFragment.kt b/app/src/main/java/org/y20k/trackbook/MapFragment.kt index 5ad6433..4b554b7 100644 --- a/app/src/main/java/org/y20k/trackbook/MapFragment.kt +++ b/app/src/main/java/org/y20k/trackbook/MapFragment.kt @@ -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) } diff --git a/app/src/main/java/org/y20k/trackbook/SettingsFragment.kt b/app/src/main/java/org/y20k/trackbook/SettingsFragment.kt index 6e755b3..cc1d6a1 100644 --- a/app/src/main/java/org/y20k/trackbook/SettingsFragment.kt +++ b/app/src/main/java/org/y20k/trackbook/SettingsFragment.kt @@ -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 = 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) } diff --git a/app/src/main/java/org/y20k/trackbook/TrackFragment.kt b/app/src/main/java/org/y20k/trackbook/TrackFragment.kt index dbed666..6929d45 100644 --- a/app/src/main/java/org/y20k/trackbook/TrackFragment.kt +++ b/app/src/main/java/org/y20k/trackbook/TrackFragment.kt @@ -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() } } diff --git a/app/src/main/java/org/y20k/trackbook/TrackerService.kt b/app/src/main/java/org/y20k/trackbook/TrackerService.kt index 15099b9..a53e501 100644 --- a/app/src/main/java/org/y20k/trackbook/TrackerService.kt +++ b/app/src/main/java/org/y20k/trackbook/TrackerService.kt @@ -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 = 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() { + class SimpleMovingAverageQueue(var capacity: Int) : LinkedList() + { 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 diff --git a/app/src/main/java/org/y20k/trackbook/TracklistFragment.kt b/app/src/main/java/org/y20k/trackbook/TracklistFragment.kt index 28d3bca..fd4847b 100644 --- a/app/src/main/java/org/y20k/trackbook/TracklistFragment.kt +++ b/app/src/main/java/org/y20k/trackbook/TracklistFragment.kt @@ -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 = async { tracklistAdapter.removeTrackAtPositionSuspended(activity as Context, payload) } + val deferred: Deferred = 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() } } diff --git a/app/src/main/java/org/y20k/trackbook/core/Track.kt b/app/src/main/java/org/y20k/trackbook/core/Track.kt index 7fdea24..1301861 100644 --- a/app/src/main/java/org/y20k/trackbook/core/Track.kt +++ b/app/src/main/java/org/y20k/trackbook/core/Track.kt @@ -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 = mutableListOf(), - @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 = mutableListOf(), + @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(""" + + + """.trimIndent()) + gpxString.appendLine("\t") + gpxString.appendLine("\t\tTrackbook Recording: ${this.name}") + gpxString.appendLine("\t") + + // POIs + val poiList: List = this.wayPoints.filter { it.starred } + poiList.forEach { poi -> + gpxString.appendLine("\t") + gpxString.appendLine("\t\tPoint of interest") + gpxString.appendLine("\t\t${poi.altitude}") + gpxString.appendLine("\t") + } + + // TRK + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + dateFormat.timeZone = TimeZone.getTimeZone("UTC") + gpxString.appendLine("\t") + gpxString.appendLine("\t\t${this.name}") + gpxString.appendLine("\t\t") + this.wayPoints.forEach { wayPoint -> + gpxString.appendLine("\t\t\t") + gpxString.appendLine("\t\t\t\t${wayPoint.altitude}") + gpxString.appendLine("\t\t\t\t") + gpxString.appendLine("\t\t\t\t${wayPoint.numberSatellites}") + gpxString.appendLine("\t\t\t") + } + gpxString.appendLine("\t\t") + gpxString.appendLine("\t") + gpxString.appendLine("") + + 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 diff --git a/app/src/main/java/org/y20k/trackbook/core/Tracklist.kt b/app/src/main/java/org/y20k/trackbook/core/Tracklist.kt index 1f4b5a2..5cac5b1 100644 --- a/app/src/main/java/org/y20k/trackbook/core/Tracklist.kt +++ b/app/src/main/java/org/y20k/trackbook/core/Tracklist.kt @@ -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 = mutableListOf()): 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 = mutableListOf() +): Parcelable +{ + fun delete_non_starred(context: Context) + { + val to_delete: List = 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().apply { addAll(tracklistElements) }) + fun deepCopy(): Tracklist + { + return Tracklist(tracklistFormatVersion, mutableListOf().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))} +} diff --git a/app/src/main/java/org/y20k/trackbook/core/TracklistElement.kt b/app/src/main/java/org/y20k/trackbook/core/TracklistElement.kt deleted file mode 100644 index 0121234..0000000 --- a/app/src/main/java/org/y20k/trackbook/core/TracklistElement.kt +++ /dev/null @@ -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 { -} diff --git a/app/src/main/java/org/y20k/trackbook/helpers/FileHelper.kt b/app/src/main/java/org/y20k/trackbook/helpers/FileHelper.kt index 060f41a..3c79d45 100644 --- a/app/src/main/java/org/y20k/trackbook/helpers/FileHelper.kt +++ b/app/src/main/java/org/y20k/trackbook/helpers/FileHelper.kt @@ -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() - 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, 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() - } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/y20k/trackbook/helpers/TrackHelper.kt b/app/src/main/java/org/y20k/trackbook/helpers/TrackHelper.kt index c8f5c20..0e0d098 100644 --- a/app/src/main/java/org/y20k/trackbook/helpers/TrackHelper.kt +++ b/app/src/main/java/org/y20k/trackbook/helpers/TrackHelper.kt @@ -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 { - // 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(""" - - - """.trimIndent()) - gpxString.appendLine("\t") - gpxString.appendLine("\t\tTrackbook Recording: ${track.name}") - gpxString.appendLine("\t") - - // POIs - val poiList: List = track.wayPoints.filter { it.starred } - poiList.forEach { poi -> - gpxString.appendLine("\t") - gpxString.appendLine("\t\tPoint of interest") - gpxString.appendLine("\t\t${poi.altitude}") - gpxString.appendLine("\t") - } - - // TRK - val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) - dateFormat.timeZone = TimeZone.getTimeZone("UTC") - gpxString.appendLine("\t") - gpxString.appendLine("\t\t${track.name}") - gpxString.appendLine("\t\t") - track.wayPoints.forEach { wayPoint -> - gpxString.appendLine("\t\t\t") - gpxString.appendLine("\t\t\t\t${wayPoint.altitude}") - gpxString.appendLine("\t\t\t\t") - gpxString.appendLine("\t\t\t\t${wayPoint.numberSatellites}") - gpxString.appendLine("\t\t\t") - } - gpxString.appendLine("\t\t") - gpxString.appendLine("\t") - gpxString.appendLine("") - - 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 } } diff --git a/app/src/main/java/org/y20k/trackbook/tracklist/TracklistAdapter.kt b/app/src/main/java/org/y20k/trackbook/tracklist/TracklistAdapter.kt index 11dd0e5..3df191d 100644 --- a/app/src/main/java/org/y20k/trackbook/tracklist/TracklistAdapter.kt +++ b/app/src/main/java/org/y20k/trackbook/tracklist/TracklistAdapter.kt @@ -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 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 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 { 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 = 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 = 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 = 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