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:
parent
45de00b9c5
commit
e3bc911de4
12 changed files with 555 additions and 791 deletions
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers.IO
|
|||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import org.y20k.trackbook.core.Tracklist
|
||||
import org.y20k.trackbook.core.load_tracklist
|
||||
import org.y20k.trackbook.helpers.AppThemeHelper
|
||||
import org.y20k.trackbook.helpers.FileHelper
|
||||
import org.y20k.trackbook.helpers.LengthUnitHelper
|
||||
|
@ -216,12 +217,8 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
|
|||
|
||||
/* Removes track and track files for given position - used by TracklistFragment */
|
||||
private fun deleteNonStarred(context: Context) {
|
||||
CoroutineScope(IO).launch {
|
||||
var tracklist: Tracklist = FileHelper.readTracklist(context)
|
||||
val deferred: Deferred<Tracklist> = async { FileHelper.deleteNonStarredSuspended(context, tracklist) }
|
||||
// wait for result and store in tracklist
|
||||
tracklist = deferred.await()
|
||||
}
|
||||
var tracklist: Tracklist = load_tracklist(context)
|
||||
tracklist.delete_non_starred(context)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ import kotlinx.coroutines.Dispatchers.IO
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Runnable
|
||||
import org.y20k.trackbook.core.Track
|
||||
import org.y20k.trackbook.core.load_temp_track
|
||||
import org.y20k.trackbook.helpers.*
|
||||
|
||||
/*
|
||||
|
@ -61,6 +62,8 @@ class TrackerService: Service(), SensorEventListener {
|
|||
var currentBestLocation: Location = LocationHelper.getDefaultLocation()
|
||||
var lastSave: Date = Keys.DEFAULT_DATE
|
||||
var stepCountOffset: Float = 0f
|
||||
// The resumed flag will be true for the first point that is received after unpausing a
|
||||
// recording, so that the distance travelled while paused is not added to the track.distance.
|
||||
var resumed: Boolean = false
|
||||
var track: Track = Track()
|
||||
var gpsLocationListenerRegistered: Boolean = false
|
||||
|
@ -93,7 +96,7 @@ class TrackerService: Service(), SensorEventListener {
|
|||
networkLocationListener = createLocationListener()
|
||||
trackingState = PreferencesHelper.loadTrackingState()
|
||||
currentBestLocation = LocationHelper.getLastKnownLocation(this)
|
||||
track = FileHelper.readTrack(this, FileHelper.getTempFileUri(this))
|
||||
track = load_temp_track(this)
|
||||
// altitudeValues.capacity = PreferencesHelper.loadAltitudeSmoothingValue()
|
||||
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
|
||||
}
|
||||
|
@ -195,72 +198,68 @@ class TrackerService: Service(), SensorEventListener {
|
|||
|
||||
/* Resume tracking after stop/pause */
|
||||
fun resumeTracking() {
|
||||
// load temp track - returns an empty track if not available
|
||||
track = FileHelper.readTrack(this, FileHelper.getTempFileUri(this))
|
||||
// load temp track - returns an empty track if there is no temp file.
|
||||
track = load_temp_track(this)
|
||||
// try to mark last waypoint as stopover
|
||||
if (track.wayPoints.size > 0) {
|
||||
val lastWayPointIndex = track.wayPoints.size - 1
|
||||
track.wayPoints[lastWayPointIndex].isStopOver = true
|
||||
}
|
||||
// set resumed flag
|
||||
resumed = true
|
||||
// calculate length of recording break
|
||||
track.recordingPaused = track.recordingPaused + TrackHelper.calculateDurationOfPause(track.recordingStop)
|
||||
// start tracking
|
||||
track.recordingPaused += TrackHelper.calculateDurationOfPause(track.recordingStop)
|
||||
startTracking(newTrack = false)
|
||||
}
|
||||
|
||||
/* Start tracking location */
|
||||
fun startTracking(newTrack: Boolean = true) {
|
||||
// start receiving location updates
|
||||
addGpsLocationListener()
|
||||
addNetworkLocationListener()
|
||||
// set up new track
|
||||
if (newTrack) {
|
||||
track = Track()
|
||||
track.name = DateTimeHelper.convertToReadableDate(track.recordingStart)
|
||||
resumed = false
|
||||
stepCountOffset = 0f
|
||||
}
|
||||
// set state
|
||||
trackingState = Keys.STATE_TRACKING_ACTIVE
|
||||
PreferencesHelper.saveTrackingState(trackingState)
|
||||
// start recording steps and location fixes
|
||||
startStepCounter()
|
||||
handler.postDelayed(periodicTrackUpdate, 0)
|
||||
// show notification
|
||||
startForeground(Keys.TRACKER_SERVICE_NOTIFICATION_ID, displayNotification())
|
||||
}
|
||||
|
||||
/* Stop tracking location */
|
||||
fun stopTracking() {
|
||||
// save temp track
|
||||
track.recordingStop = GregorianCalendar.getInstance().time
|
||||
CoroutineScope(IO).launch { FileHelper.saveTempTrackSuspended(this@TrackerService, track) }
|
||||
// save state
|
||||
val context: Context = this as Context
|
||||
CoroutineScope(IO).launch { track.save_temp_suspended(context) }
|
||||
|
||||
trackingState = Keys.STATE_TRACKING_STOPPED
|
||||
trackingState = Keys.STATE_TRACKING_PAUSED
|
||||
PreferencesHelper.saveTrackingState(trackingState)
|
||||
// reset altitude values queue
|
||||
|
||||
altitudeValues.reset()
|
||||
// stop recording steps and location fixes
|
||||
|
||||
sensorManager.unregisterListener(this)
|
||||
handler.removeCallbacks(periodicTrackUpdate)
|
||||
// update notification
|
||||
|
||||
displayNotification()
|
||||
stopForeground(false)
|
||||
}
|
||||
|
||||
/* Clear track recording */
|
||||
fun clearTrack() {
|
||||
fun clearTrack()
|
||||
{
|
||||
track = Track()
|
||||
FileHelper.deleteTempFile(this)
|
||||
resumed = false
|
||||
FileHelper.delete_temp_file(this as Context)
|
||||
trackingState = Keys.STATE_TRACKING_NOT_STARTED
|
||||
PreferencesHelper.saveTrackingState(trackingState)
|
||||
stopForeground(true)
|
||||
notificationManager.cancel(Keys.TRACKER_SERVICE_NOTIFICATION_ID) // this call was not necessary prior to Android 12
|
||||
}
|
||||
|
||||
/* Creates location listener */
|
||||
private fun createLocationListener(): LocationListener {
|
||||
private fun createLocationListener(): LocationListener
|
||||
{
|
||||
return object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
// update currentBestLocation if a better location is available
|
||||
|
@ -268,7 +267,8 @@ class TrackerService: Service(), SensorEventListener {
|
|||
currentBestLocation = location
|
||||
}
|
||||
}
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
override fun onProviderEnabled(provider: String)
|
||||
{
|
||||
LogHelper.v(TAG, "onProviderEnabled $provider")
|
||||
when (provider) {
|
||||
LocationManager.GPS_PROVIDER -> gpsProviderActive = LocationHelper.isGpsEnabled(
|
||||
|
@ -280,7 +280,8 @@ class TrackerService: Service(), SensorEventListener {
|
|||
)
|
||||
}
|
||||
}
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
override fun onProviderDisabled(provider: String)
|
||||
{
|
||||
LogHelper.v(TAG, "onProviderDisabled $provider")
|
||||
when (provider) {
|
||||
LocationManager.GPS_PROVIDER -> gpsProviderActive = LocationHelper.isGpsEnabled(
|
||||
|
@ -292,59 +293,58 @@ class TrackerService: Service(), SensorEventListener {
|
|||
)
|
||||
}
|
||||
}
|
||||
override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?) {
|
||||
override fun onStatusChanged(p0: String?, p1: Int, p2: Bundle?)
|
||||
{
|
||||
// deprecated method
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Adds a GPS location listener to location manager */
|
||||
private fun addGpsLocationListener() {
|
||||
// check if already registered
|
||||
if (!gpsLocationListenerRegistered) {
|
||||
// check if Network provider is available
|
||||
gpsProviderActive = LocationHelper.isGpsEnabled(locationManager)
|
||||
if (gpsProviderActive) {
|
||||
// check for location permission
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED) {
|
||||
// adds GPS location listener
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
0,
|
||||
0f,
|
||||
gpsLocationListener
|
||||
)
|
||||
gpsLocationListenerRegistered = true
|
||||
LogHelper.v(TAG, "Added GPS location listener.")
|
||||
} else {
|
||||
LogHelper.w(
|
||||
TAG,
|
||||
"Unable to add GPS location listener. Location permission is not granted."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LogHelper.w(TAG, "Unable to add GPS location listener.")
|
||||
}
|
||||
} else {
|
||||
LogHelper.v(TAG, "Skipping registration. GPS location listener has already been added.")
|
||||
private fun addGpsLocationListener()
|
||||
{
|
||||
if (gpsLocationListenerRegistered)
|
||||
{
|
||||
LogHelper.v(TAG, "GPS location listener has already been added.")
|
||||
return
|
||||
}
|
||||
|
||||
gpsProviderActive = LocationHelper.isGpsEnabled(locationManager)
|
||||
if (! gpsProviderActive)
|
||||
{
|
||||
LogHelper.w(TAG, "Device GPS is not enabled.")
|
||||
return
|
||||
}
|
||||
|
||||
val has_permission: Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
if (! has_permission)
|
||||
{
|
||||
LogHelper.w(TAG, "Location permission is not granted.")
|
||||
return
|
||||
}
|
||||
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
0,
|
||||
0f,
|
||||
gpsLocationListener
|
||||
)
|
||||
gpsLocationListenerRegistered = true
|
||||
LogHelper.v(TAG, "Added GPS location listener.")
|
||||
}
|
||||
|
||||
/* Adds a Network location listener to location manager */
|
||||
private fun addNetworkLocationListener() {
|
||||
if (gpsOnly)
|
||||
{
|
||||
LogHelper.v(TAG, "User prefers GPS-only.")
|
||||
return;
|
||||
LogHelper.v(TAG, "Skipping Network listener. User prefers GPS-only.")
|
||||
return
|
||||
}
|
||||
|
||||
if (networkLocationListenerRegistered)
|
||||
{
|
||||
LogHelper.v(TAG, "Network location listener has already been added.")
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
networkProviderActive = LocationHelper.isNetworkEnabled(locationManager)
|
||||
|
@ -378,10 +378,7 @@ class TrackerService: Service(), SensorEventListener {
|
|||
gpsLocationListenerRegistered = false
|
||||
LogHelper.v(TAG, "Removed GPS location listener.")
|
||||
} else {
|
||||
LogHelper.w(
|
||||
TAG,
|
||||
"Unable to remove GPS location listener. Location permission is needed."
|
||||
)
|
||||
LogHelper.w(TAG, "Unable to remove GPS location listener. Location permission is needed.")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -392,10 +389,7 @@ class TrackerService: Service(), SensorEventListener {
|
|||
networkLocationListenerRegistered = false
|
||||
LogHelper.v(TAG, "Removed Network location listener.")
|
||||
} else {
|
||||
LogHelper.w(
|
||||
TAG,
|
||||
"Unable to remove Network location listener. Location permission is needed."
|
||||
)
|
||||
LogHelper.w(TAG, "Unable to remove Network location listener. Location permission is needed.")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -424,25 +418,25 @@ class TrackerService: Service(), SensorEventListener {
|
|||
* Defines the listener for changes in shared preferences
|
||||
*/
|
||||
private val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
|
||||
when (key) {
|
||||
// preference "Restrict to GPS"
|
||||
Keys.PREF_GPS_ONLY -> {
|
||||
gpsOnly = PreferencesHelper.loadGpsOnly()
|
||||
when (gpsOnly) {
|
||||
true -> removeNetworkLocationListener()
|
||||
false -> addNetworkLocationListener()
|
||||
}
|
||||
}
|
||||
// preference "Use Imperial Measurements"
|
||||
Keys.PREF_USE_IMPERIAL_UNITS -> {
|
||||
useImperial = PreferencesHelper.loadUseImperialUnits()
|
||||
}
|
||||
// preference "Recording Accuracy"
|
||||
Keys.PREF_OMIT_RESTS -> {
|
||||
omitRests = PreferencesHelper.loadOmitRests()
|
||||
when (key) {
|
||||
// preference "Restrict to GPS"
|
||||
Keys.PREF_GPS_ONLY -> {
|
||||
gpsOnly = PreferencesHelper.loadGpsOnly()
|
||||
when (gpsOnly) {
|
||||
true -> removeNetworkLocationListener()
|
||||
false -> addNetworkLocationListener()
|
||||
}
|
||||
}
|
||||
// preference "Use Imperial Measurements"
|
||||
Keys.PREF_USE_IMPERIAL_UNITS -> {
|
||||
useImperial = PreferencesHelper.loadUseImperialUnits()
|
||||
}
|
||||
// preference "Recording Accuracy"
|
||||
Keys.PREF_OMIT_RESTS -> {
|
||||
omitRests = PreferencesHelper.loadOmitRests()
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of declaration
|
||||
*/
|
||||
|
@ -460,19 +454,13 @@ class TrackerService: Service(), SensorEventListener {
|
|||
/*
|
||||
* Runnable: Periodically track updates (if recording active)
|
||||
*/
|
||||
private val periodicTrackUpdate: Runnable = object : Runnable {
|
||||
private val periodicTrackUpdate: Runnable = object : Runnable
|
||||
{
|
||||
override fun run() {
|
||||
// add waypoint to track - step count is continuously updated in onSensorChanged
|
||||
val result: Pair<Boolean, Track> = TrackHelper.addWayPointToTrack(track, currentBestLocation, omitRests, resumed)
|
||||
// get results
|
||||
val successfullyAdded: Boolean = result.first
|
||||
track = result.second
|
||||
// check, if waypoint was added
|
||||
if (successfullyAdded) {
|
||||
// reset resumed flag, if necessary
|
||||
if (resumed) {
|
||||
resumed = false
|
||||
}
|
||||
val success = track.add_waypoint(currentBestLocation, omitRests, resumed)
|
||||
if (success) {
|
||||
resumed = false
|
||||
|
||||
// store previous smoothed altitude
|
||||
val previousAltitude: Double = altitudeValues.getAverage()
|
||||
|
@ -480,7 +468,7 @@ class TrackerService: Service(), SensorEventListener {
|
|||
val currentBestLocationAltitude: Double = currentBestLocation.altitude
|
||||
if (currentBestLocationAltitude != Keys.DEFAULT_ALTITUDE) altitudeValues.add(currentBestLocationAltitude)
|
||||
// TODO remove
|
||||
// uncomment to use test altitude values - useful if testing wirth an emulator
|
||||
// uncomment to use test altitude values - useful if testing with an emulator
|
||||
//altitudeValues.add(getTestAltitude()) // TODO remove
|
||||
// TODO remove
|
||||
|
||||
|
@ -499,7 +487,7 @@ class TrackerService: Service(), SensorEventListener {
|
|||
val now: Date = GregorianCalendar.getInstance().time
|
||||
if (now.time - lastSave.time > Keys.SAVE_TEMP_TRACK_INTERVAL) {
|
||||
lastSave = now
|
||||
CoroutineScope(IO).launch { FileHelper.saveTempTrackSuspended(this@TrackerService, track) }
|
||||
CoroutineScope(IO).launch { track.save_temp_suspended(this@TrackerService) }
|
||||
}
|
||||
}
|
||||
// update notification
|
||||
|
@ -514,10 +502,12 @@ class TrackerService: Service(), SensorEventListener {
|
|||
|
||||
/* Simple queue that evicts older elements and holds an average */
|
||||
/* Credit: CircularQueue https://stackoverflow.com/a/51923797 */
|
||||
class SimpleMovingAverageQueue(var capacity: Int) : LinkedList<Double>() {
|
||||
class SimpleMovingAverageQueue(var capacity: Int) : LinkedList<Double>()
|
||||
{
|
||||
var prepared: Boolean = false
|
||||
private var sum: Double = 0.0
|
||||
override fun add(element: Double): Boolean {
|
||||
override fun add(element: Double): Boolean
|
||||
{
|
||||
prepared = this.size + 1 >= Keys.MIN_NUMBER_OF_WAYPOINTS_FOR_ELEVATION_CALCULATION
|
||||
if (this.size >= capacity) {
|
||||
sum -= this.first
|
||||
|
@ -526,8 +516,12 @@ class TrackerService: Service(), SensorEventListener {
|
|||
sum += element
|
||||
return super.add(element)
|
||||
}
|
||||
fun getAverage(): Double = sum / this.size
|
||||
fun reset() {
|
||||
fun getAverage(): Double
|
||||
{
|
||||
return sum / this.size
|
||||
}
|
||||
fun reset()
|
||||
{
|
||||
this.clear()
|
||||
prepared = false
|
||||
sum = 0.0
|
||||
|
|
|
@ -24,6 +24,7 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
|
@ -33,11 +34,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import org.y20k.trackbook.core.Tracklist
|
||||
import org.y20k.trackbook.core.TracklistElement
|
||||
import org.y20k.trackbook.helpers.FileHelper
|
||||
import org.y20k.trackbook.core.Track
|
||||
import org.y20k.trackbook.helpers.LogHelper
|
||||
import org.y20k.trackbook.helpers.TrackHelper
|
||||
import org.y20k.trackbook.helpers.UiHelper
|
||||
import org.y20k.trackbook.tracklist.TracklistAdapter
|
||||
|
||||
|
@ -95,19 +93,17 @@ class TracklistFragment : Fragment(), TracklistAdapter.TracklistAdapterListener,
|
|||
return rootView
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onTrackElementTapped from TracklistElementAdapterListener */
|
||||
override fun onTrackElementTapped(tracklistElement: TracklistElement) {
|
||||
override fun onTrackElementTapped(track: Track) {
|
||||
val bundle: Bundle = bundleOf(
|
||||
Keys.ARG_TRACK_TITLE to tracklistElement.name,
|
||||
Keys.ARG_TRACK_FILE_URI to tracklistElement.trackUriString,
|
||||
Keys.ARG_GPX_FILE_URI to tracklistElement.gpxUriString,
|
||||
Keys.ARG_TRACK_ID to tracklistElement.id
|
||||
Keys.ARG_TRACK_TITLE to track.name,
|
||||
Keys.ARG_TRACK_FILE_URI to track.get_json_file(activity as Context).toUri().toString(),
|
||||
Keys.ARG_GPX_FILE_URI to track.get_gpx_file(activity as Context).toUri().toString(),
|
||||
Keys.ARG_TRACK_ID to track.id
|
||||
)
|
||||
findNavController().navigate(R.id.fragment_track, bundle)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onYesNoDialog from YesNoDialogListener */
|
||||
override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
|
@ -117,7 +113,7 @@ class TracklistFragment : Fragment(), TracklistAdapter.TracklistAdapterListener,
|
|||
// user tapped remove track
|
||||
true -> {
|
||||
toggleOnboardingLayout()
|
||||
val deferred: Deferred<Unit> = async { tracklistAdapter.removeTrackAtPositionSuspended(activity as Context, payload) }
|
||||
val deferred: Deferred<Unit> = async { tracklistAdapter.delete_track_at_position_suspended(activity as Context, payload) }
|
||||
// wait for result and store in tracklist
|
||||
withContext(Main) {
|
||||
deferred.await()
|
||||
|
@ -173,7 +169,7 @@ class TracklistFragment : Fragment(), TracklistAdapter.TracklistAdapterListener,
|
|||
return;
|
||||
}
|
||||
CoroutineScope(Main). launch {
|
||||
tracklistAdapter.removeTrackById(this@TracklistFragment.activity as Context, deleteTrackId)
|
||||
tracklistAdapter.delete_track_by_id(this@TracklistFragment.activity as Context, deleteTrackId)
|
||||
toggleOnboardingLayout()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,56 +17,280 @@
|
|||
package org.y20k.trackbook.core
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.Expose
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.random.Random
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.y20k.trackbook.Keys
|
||||
import org.y20k.trackbook.helpers.DateTimeHelper
|
||||
import org.y20k.trackbook.helpers.FileHelper
|
||||
import org.y20k.trackbook.helpers.LocationHelper
|
||||
|
||||
/*
|
||||
* Track data class
|
||||
*/
|
||||
@Keep
|
||||
@Parcelize
|
||||
data class Track (@Expose val id: Long = make_random_id(),
|
||||
@Expose var trackFormatVersion: Int = Keys.CURRENT_TRACK_FORMAT_VERSION,
|
||||
@Expose val wayPoints: MutableList<WayPoint> = mutableListOf<WayPoint>(),
|
||||
@Expose var distance: Float = 0f,
|
||||
@Expose var duration: Long = 0L,
|
||||
@Expose var recordingPaused: Long = 0L,
|
||||
@Expose var stepCount: Float = 0f,
|
||||
@Expose var recordingStart: Date = GregorianCalendar.getInstance().time,
|
||||
@Expose var recordingStop: Date = recordingStart,
|
||||
@Expose var maxAltitude: Double = 0.0,
|
||||
@Expose var minAltitude: Double = 0.0,
|
||||
@Expose var positiveElevation: Double = 0.0,
|
||||
@Expose var negativeElevation: Double = 0.0,
|
||||
@Expose var trackUriString: String = String(),
|
||||
@Expose var gpxUriString: String = String(),
|
||||
@Expose var latitude: Double = Keys.DEFAULT_LATITUDE,
|
||||
@Expose var longitude: Double = Keys.DEFAULT_LONGITUDE,
|
||||
@Expose var zoomLevel: Double = Keys.DEFAULT_ZOOM_LEVEL,
|
||||
@Expose var name: String = String()): Parcelable
|
||||
data class Track (
|
||||
@Expose val id: Long = make_random_id(),
|
||||
@Expose var trackFormatVersion: Int = Keys.CURRENT_TRACK_FORMAT_VERSION,
|
||||
@Expose val wayPoints: MutableList<WayPoint> = mutableListOf<WayPoint>(),
|
||||
@Expose var distance: Float = 0f,
|
||||
@Expose var duration: Long = 0L,
|
||||
@Expose var recordingPaused: Long = 0L,
|
||||
@Expose var stepCount: Float = 0f,
|
||||
@Expose var recordingStart: Date = GregorianCalendar.getInstance().time,
|
||||
@Expose var dateString: String = DateTimeHelper.convertToReadableDate(recordingStart),
|
||||
@Expose var recordingStop: Date = recordingStart,
|
||||
@Expose var maxAltitude: Double = 0.0,
|
||||
@Expose var minAltitude: Double = 0.0,
|
||||
@Expose var positiveElevation: Double = 0.0,
|
||||
@Expose var negativeElevation: Double = 0.0,
|
||||
@Expose var latitude: Double = Keys.DEFAULT_LATITUDE,
|
||||
@Expose var longitude: Double = Keys.DEFAULT_LONGITUDE,
|
||||
@Expose var zoomLevel: Double = Keys.DEFAULT_ZOOM_LEVEL,
|
||||
@Expose var name: String = DateTimeHelper.convertToReadableDate(recordingStart),
|
||||
@Expose var starred: Boolean = false,
|
||||
): Parcelable
|
||||
{
|
||||
/* Creates a TracklistElement */
|
||||
fun toTracklistElement(context: Context): TracklistElement {
|
||||
val readableDateString: String = DateTimeHelper.convertToReadableDate(recordingStart)
|
||||
val readableDurationString: String = DateTimeHelper.convertToReadableTime(context, duration)
|
||||
return TracklistElement(
|
||||
id = id,
|
||||
name = name,
|
||||
date = recordingStart,
|
||||
dateString = readableDateString,
|
||||
distance = distance,
|
||||
duration = duration,
|
||||
trackUriString = trackUriString,
|
||||
gpxUriString = gpxUriString,
|
||||
starred = false
|
||||
fun add_waypoint(location: Location, omitRests: Boolean, resumed: Boolean): Boolean
|
||||
{
|
||||
// Step 1: Get previous location
|
||||
val previousLocation: Location?
|
||||
var numberOfWayPoints: Int = this.wayPoints.size
|
||||
|
||||
// CASE: First location
|
||||
if (numberOfWayPoints == 0)
|
||||
{
|
||||
previousLocation = null
|
||||
}
|
||||
// CASE: Second location - check if first location was plausible & remove implausible location
|
||||
else if (numberOfWayPoints == 1 && !LocationHelper.isFirstLocationPlausible(location, this))
|
||||
{
|
||||
previousLocation = null
|
||||
numberOfWayPoints = 0
|
||||
this.wayPoints.removeAt(0)
|
||||
}
|
||||
// CASE: Third location or second location (if first was plausible)
|
||||
else
|
||||
{
|
||||
previousLocation = this.wayPoints[numberOfWayPoints - 1].toLocation()
|
||||
}
|
||||
|
||||
// Step 2: Update duration
|
||||
val now: Date = GregorianCalendar.getInstance().time
|
||||
val difference: Long = now.time - this.recordingStop.time
|
||||
this.duration += difference
|
||||
this.recordingStop = now
|
||||
|
||||
// Step 3: Add waypoint, if recent and accurate and different enough
|
||||
val shouldBeAdded: Boolean = (
|
||||
LocationHelper.isRecentEnough(location) &&
|
||||
LocationHelper.isAccurateEnough(location, Keys.DEFAULT_THRESHOLD_LOCATION_ACCURACY) &&
|
||||
LocationHelper.isDifferentEnough(previousLocation, location, omitRests)
|
||||
)
|
||||
if (! shouldBeAdded)
|
||||
{
|
||||
return false
|
||||
}
|
||||
// Step 3.1: Update distance (do not update if resumed -> we do not want to add values calculated during a recording pause)
|
||||
if (!resumed)
|
||||
{
|
||||
this.distance = this.distance + LocationHelper.calculateDistance(previousLocation, location)
|
||||
}
|
||||
// Step 3.2: Update altitude values
|
||||
val altitude: Double = location.altitude
|
||||
if (altitude != 0.0)
|
||||
{
|
||||
if (numberOfWayPoints == 0)
|
||||
{
|
||||
this.maxAltitude = altitude
|
||||
this.minAltitude = altitude
|
||||
}
|
||||
else
|
||||
{
|
||||
if (altitude > this.maxAltitude) this.maxAltitude = altitude
|
||||
if (altitude < this.minAltitude) this.minAltitude = altitude
|
||||
}
|
||||
}
|
||||
// Step 3.3: Toggle stop over status, if necessary
|
||||
if (this.wayPoints.size < 0)
|
||||
{
|
||||
this.wayPoints[this.wayPoints.size - 1].isStopOver = LocationHelper.isStopOver(previousLocation, location)
|
||||
}
|
||||
|
||||
// Step 3.4: Add current location as point to center on for later display
|
||||
this.latitude = location.latitude
|
||||
this.longitude = location.longitude
|
||||
|
||||
// Step 3.5: Add location as new waypoint
|
||||
this.wayPoints.add(WayPoint(location = location, distanceToStartingPoint = this.distance))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun delete(context: Context)
|
||||
{
|
||||
Log.i("VOUSSOIR", "Deleting track ${this.id}.")
|
||||
val json_file: File = this.get_json_file(context)
|
||||
if (json_file.isFile)
|
||||
{
|
||||
json_file.delete()
|
||||
}
|
||||
val gpx_file: File = this.get_gpx_file(context)
|
||||
if (gpx_file.isFile)
|
||||
{
|
||||
gpx_file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete_suspended(context: Context)
|
||||
{
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(this.delete(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun get_gpx_file(context: Context): File
|
||||
{
|
||||
val basename: String = this.id.toString() + Keys.GPX_FILE_EXTENSION
|
||||
return File(context.getExternalFilesDir(Keys.FOLDER_GPX), basename)
|
||||
}
|
||||
|
||||
fun get_json_file(context: Context): File
|
||||
{
|
||||
val basename: String = this.id.toString() + Keys.TRACKBOOK_FILE_EXTENSION
|
||||
return File(context.getExternalFilesDir(Keys.FOLDER_TRACKS), basename)
|
||||
}
|
||||
|
||||
fun save_both(context: Context)
|
||||
{
|
||||
this.save_json(context)
|
||||
this.save_gpx(context)
|
||||
}
|
||||
|
||||
suspend fun save_both_suspended(context: Context)
|
||||
{
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(this.save_both(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun save_gpx(context: Context)
|
||||
{
|
||||
val gpx: String = this.to_gpx()
|
||||
FileHelper.write_text_file_noblank(gpx, this.get_gpx_file(context))
|
||||
Log.i("VOUSSOIR", "Saved ${this.id}.gpx")
|
||||
}
|
||||
|
||||
suspend fun save_gpx_suspended(context: Context)
|
||||
{
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(this.save_gpx(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun save_json(context: Context)
|
||||
{
|
||||
val json: String = this.to_json()
|
||||
FileHelper.write_text_file_noblank(json, this.get_json_file(context))
|
||||
Log.i("VOUSSOIR", "Saved ${this.id}.json")
|
||||
}
|
||||
|
||||
suspend fun save_json_suspended(context: Context)
|
||||
{
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(this.save_json(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun save_temp(context: Context)
|
||||
{
|
||||
val json: String = this.to_json()
|
||||
FileHelper.write_text_file_noblank(json, FileHelper.get_temp_file(context))
|
||||
}
|
||||
|
||||
suspend fun save_temp_suspended(context: Context)
|
||||
{
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(this.save_temp(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun to_gpx(): String {
|
||||
val gpxString = StringBuilder("")
|
||||
|
||||
// Header
|
||||
gpxString.appendLine("""
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||
<gpx
|
||||
version="1.1" creator="Trackbook App (Android)"
|
||||
xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"
|
||||
>
|
||||
""".trimIndent())
|
||||
gpxString.appendLine("\t<metadata>")
|
||||
gpxString.appendLine("\t\t<name>Trackbook Recording: ${this.name}</name>")
|
||||
gpxString.appendLine("\t</metadata>")
|
||||
|
||||
// POIs
|
||||
val poiList: List<WayPoint> = this.wayPoints.filter { it.starred }
|
||||
poiList.forEach { poi ->
|
||||
gpxString.appendLine("\t<wpt lat=\"${poi.latitude}\" lon=\"${poi.longitude}\">")
|
||||
gpxString.appendLine("\t\t<name>Point of interest</name>")
|
||||
gpxString.appendLine("\t\t<ele>${poi.altitude}</ele>")
|
||||
gpxString.appendLine("\t</wpt>")
|
||||
}
|
||||
|
||||
// TRK
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
|
||||
gpxString.appendLine("\t<trk>")
|
||||
gpxString.appendLine("\t\t<name>${this.name}</name>")
|
||||
gpxString.appendLine("\t\t<trkseg>")
|
||||
this.wayPoints.forEach { wayPoint ->
|
||||
gpxString.appendLine("\t\t\t<trkpt lat=\"${wayPoint.latitude}\" lon=\"${wayPoint.longitude}\">")
|
||||
gpxString.appendLine("\t\t\t\t<ele>${wayPoint.altitude}</ele>")
|
||||
gpxString.appendLine("\t\t\t\t<time>${dateFormat.format(Date(wayPoint.time))}</time>")
|
||||
gpxString.appendLine("\t\t\t\t<sat>${wayPoint.numberSatellites}</sat>")
|
||||
gpxString.appendLine("\t\t\t</trkpt>")
|
||||
}
|
||||
gpxString.appendLine("\t\t</trkseg>")
|
||||
gpxString.appendLine("\t</trk>")
|
||||
gpxString.appendLine("</gpx>")
|
||||
|
||||
return gpxString.toString()
|
||||
}
|
||||
|
||||
fun to_json(): String
|
||||
{
|
||||
return FileHelper.getCustomGson().toJson(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun load_temp_track(context: Context): Track
|
||||
{
|
||||
return track_from_file(context, FileHelper.get_temp_file(context))
|
||||
}
|
||||
|
||||
fun track_from_file(context: Context, file: File): Track
|
||||
{
|
||||
// get JSON from text file
|
||||
val json: String = FileHelper.readTextFile(context, file)
|
||||
if (json.isEmpty())
|
||||
{
|
||||
return Track()
|
||||
}
|
||||
return FileHelper.getCustomGson().fromJson(json, Track::class.java)
|
||||
}
|
||||
|
||||
fun make_random_id(): Long
|
||||
|
|
|
@ -17,54 +17,79 @@
|
|||
|
||||
package org.y20k.trackbook.core
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.Expose
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.y20k.trackbook.Keys
|
||||
import org.y20k.trackbook.helpers.TrackHelper
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* Tracklist data class
|
||||
*/
|
||||
@Keep
|
||||
@Parcelize
|
||||
data class Tracklist (@Expose val tracklistFormatVersion: Int = Keys.CURRENT_TRACKLIST_FORMAT_VERSION,
|
||||
@Expose val tracklistElements: MutableList<TracklistElement> = mutableListOf<TracklistElement>()): Parcelable {
|
||||
|
||||
/* Return trackelement for given track id */
|
||||
fun getTrackElement(trackId: Long): TracklistElement? {
|
||||
tracklistElements.forEach { tracklistElement ->
|
||||
if (tracklistElement.id == trackId) {
|
||||
return tracklistElement
|
||||
data class Tracklist (
|
||||
@Expose val tracklistFormatVersion: Int = Keys.CURRENT_TRACKLIST_FORMAT_VERSION,
|
||||
@Expose val tracks: MutableList<Track> = mutableListOf<Track>()
|
||||
): Parcelable
|
||||
{
|
||||
fun delete_non_starred(context: Context)
|
||||
{
|
||||
val to_delete: List<Track> = this.tracks.filter{! it.starred}
|
||||
to_delete.forEach { track ->
|
||||
if (!track.starred)
|
||||
{
|
||||
track.delete(context)
|
||||
}
|
||||
}
|
||||
return null
|
||||
this.tracks.removeIf{! it.starred}
|
||||
}
|
||||
suspend fun delete_non_starred_suspended(context: Context)
|
||||
{
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(this.delete_non_starred(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun get_total_distance(): Float
|
||||
fun get_total_distance(): Double
|
||||
{
|
||||
var total: Float = 0F
|
||||
tracklistElements.forEach { tracklist_element ->
|
||||
total += tracklist_element.distance
|
||||
}
|
||||
return total
|
||||
return this.tracks.sumOf {it.distance.toDouble()}
|
||||
}
|
||||
|
||||
fun get_total_duration(): Long
|
||||
{
|
||||
var total: Long = 0L
|
||||
tracklistElements.forEach { tracklist_element ->
|
||||
total += tracklist_element.duration
|
||||
}
|
||||
return total
|
||||
return this.tracks.sumOf {it.duration}
|
||||
}
|
||||
|
||||
/* Create a deep copy */
|
||||
fun deepCopy(): Tracklist {
|
||||
return Tracklist(tracklistFormatVersion, mutableListOf<TracklistElement>().apply { addAll(tracklistElements) })
|
||||
fun deepCopy(): Tracklist
|
||||
{
|
||||
return Tracklist(tracklistFormatVersion, mutableListOf<Track>().apply { addAll(tracks) })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun load_tracklist(context: Context): Tracklist {
|
||||
Log.i("VOUSSOIR", "Loading tracklist.")
|
||||
val folder = context.getExternalFilesDir("tracks")
|
||||
var tracklist: Tracklist = Tracklist()
|
||||
if (folder == null)
|
||||
{
|
||||
return tracklist
|
||||
}
|
||||
folder.walk().filter{ f: File -> f.isFile }.forEach{ json_file ->
|
||||
val track = track_from_file(context, json_file)
|
||||
tracklist.tracks.add(track)
|
||||
}
|
||||
tracklist.tracks.sortByDescending {it.recordingStart}
|
||||
return tracklist
|
||||
}
|
||||
|
||||
suspend fun load_tracklist_suspended(context: Context): Tracklist
|
||||
{
|
||||
return suspendCoroutine {cont -> cont.resume(load_tracklist(context))}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
}
|
|
@ -18,26 +18,16 @@
|
|||
package org.y20k.trackbook.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import org.y20k.trackbook.Keys
|
||||
import org.y20k.trackbook.core.Track
|
||||
import org.y20k.trackbook.core.Tracklist
|
||||
import org.y20k.trackbook.core.TracklistElement
|
||||
import java.io.*
|
||||
import java.text.NumberFormat
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
import org.y20k.trackbook.Keys
|
||||
import org.y20k.trackbook.core.Track
|
||||
|
||||
|
||||
/*
|
||||
|
@ -48,185 +38,29 @@ object FileHelper {
|
|||
/* Define log tag */
|
||||
private val TAG: String = LogHelper.makeLogTag(FileHelper::class.java)
|
||||
|
||||
|
||||
/* Return an InputStream for given Uri */
|
||||
fun getTextFileStream(context: Context, uri: Uri): InputStream? {
|
||||
var stream : InputStream? = null
|
||||
try {
|
||||
stream = context.contentResolver.openInputStream(uri)
|
||||
} catch (e : Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
|
||||
/* Get file size for given Uri */
|
||||
fun getFileSize(context: Context, uri: Uri): Long {
|
||||
val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
|
||||
if (cursor != null) {
|
||||
val sizeIndex: Int = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
val size: Long = cursor.getLong(sizeIndex)
|
||||
cursor.close()
|
||||
return size
|
||||
} else {
|
||||
return 0L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Get file name for given Uri */
|
||||
fun getFileName(context: Context, uri: Uri): String {
|
||||
val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
|
||||
if (cursor != null) {
|
||||
val nameIndex: Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
val name: String = cursor.getString(nameIndex)
|
||||
cursor.close()
|
||||
return name
|
||||
} else {
|
||||
return String()
|
||||
}
|
||||
}
|
||||
|
||||
/* Clears given folder - keeps given number of files */
|
||||
fun clearFolder(folder: File?, keep: Int, deleteFolder: Boolean = false) {
|
||||
if (folder != null && folder.exists()) {
|
||||
val files = folder.listFiles()
|
||||
val fileCount: Int = files.size
|
||||
files.sortBy { it.lastModified() }
|
||||
for (fileNumber in files.indices) {
|
||||
if (fileNumber < fileCount - keep) {
|
||||
files[fileNumber].delete()
|
||||
}
|
||||
}
|
||||
if (deleteFolder && keep == 0) {
|
||||
folder.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Reads tracklist from storage using GSON */
|
||||
fun readTracklist(context: Context): Tracklist {
|
||||
LogHelper.v(TAG, "Reading Tracklist - Thread: ${Thread.currentThread().name}")
|
||||
var folder = context.getExternalFilesDir("tracks")
|
||||
var tracklist: Tracklist = Tracklist()
|
||||
Log.i(TAG, folder.toString())
|
||||
if (folder != null)
|
||||
fun delete_temp_file(context: Context)
|
||||
{
|
||||
val temp: File = get_temp_file(context)
|
||||
if (temp.isFile())
|
||||
{
|
||||
folder.walk().filter{ f: File -> f.isFile }.forEach{ track_file ->
|
||||
val track_json = readTextFile(context, track_file.toUri())
|
||||
Log.i("VOUSSOIR", track_json)
|
||||
val track = getCustomGson().fromJson(track_json, Track::class.java)
|
||||
val tracklist_element = track.toTracklistElement(context)
|
||||
tracklist.tracklistElements.add(tracklist_element)
|
||||
}
|
||||
temp.delete()
|
||||
}
|
||||
return tracklist
|
||||
}
|
||||
|
||||
|
||||
/* Reads track from storage using GSON */
|
||||
fun readTrack(context: Context, fileUri: Uri): Track {
|
||||
// get JSON from text file
|
||||
val json: String = readTextFile(context, fileUri)
|
||||
var track: Track = Track()
|
||||
when (json.isNotEmpty()) {
|
||||
// convert JSON and return as track
|
||||
true -> try {
|
||||
track = getCustomGson().fromJson(json, Track::class.java)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
|
||||
/* Deletes temp track file */
|
||||
fun deleteTempFile(context: Context) {
|
||||
getTempFileUri(context).toFile().delete()
|
||||
}
|
||||
|
||||
|
||||
/* Checks if temp track file exists */
|
||||
fun tempFileExists(context: Context): Boolean {
|
||||
return getTempFileUri(context).toFile().exists()
|
||||
}
|
||||
|
||||
|
||||
/* Creates Uri for Gpx file of a track */
|
||||
fun getGpxFileUri(context: Context, track: Track): Uri = File(context.getExternalFilesDir(Keys.FOLDER_GPX), getGpxFileName(track)).toUri()
|
||||
|
||||
|
||||
/* Creates file name for Gpx file of a track */
|
||||
fun getGpxFileName(track: Track): String = DateTimeHelper.convertToSortableDateString(track.recordingStart) + Keys.GPX_FILE_EXTENSION
|
||||
|
||||
|
||||
/* Creates Uri for json track file */
|
||||
fun getTrackFileUri(context: Context, track: Track): Uri {
|
||||
val fileName: String = DateTimeHelper.convertToSortableDateString(track.recordingStart) + Keys.TRACKBOOK_FILE_EXTENSION
|
||||
return File(context.getExternalFilesDir(Keys.FOLDER_TRACKS), fileName).toUri()
|
||||
}
|
||||
|
||||
|
||||
/* Creates Uri for json temp track file */
|
||||
fun getTempFileUri(context: Context): Uri {
|
||||
return File(context.getExternalFilesDir(Keys.FOLDER_TEMP), Keys.TEMP_FILE).toUri()
|
||||
fun get_temp_file(context: Context): File
|
||||
{
|
||||
return File(context.getExternalFilesDir(Keys.FOLDER_TEMP), Keys.TEMP_FILE)
|
||||
}
|
||||
|
||||
/* Suspend function: Wrapper for renameTrack */
|
||||
suspend fun renameTrackSuspended(context: Context, track: Track, newName: String) {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(renameTrack(context, track, newName))
|
||||
track.name = newName
|
||||
track.save_both(context)
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Wrapper for saveTrack */
|
||||
suspend fun saveTrackSuspended(track: Track, saveGpxToo: Boolean) {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(saveTrack(track, saveGpxToo))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Wrapper for saveTempTrack */
|
||||
suspend fun saveTempTrackSuspended(context: Context, track: Track) {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(saveTempTrack(context, track))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Wrapper for deleteTrack */
|
||||
suspend fun deleteTrackSuspended(context: Context, tracklist_element: TracklistElement, tracklist: Tracklist): Tracklist {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(deleteTrack(context, tracklist_element, tracklist))
|
||||
}
|
||||
}
|
||||
|
||||
/* Suspend function: Deletes tracks that are not starred using deleteTracks */
|
||||
suspend fun deleteNonStarredSuspended(context: Context, tracklist: Tracklist): Tracklist {
|
||||
return suspendCoroutine { cont ->
|
||||
val tracklistElements = mutableListOf<TracklistElement>()
|
||||
tracklist.tracklistElements.forEach { tracklistElement ->
|
||||
if (!tracklistElement.starred) {
|
||||
tracklistElements.add(tracklistElement)
|
||||
}
|
||||
}
|
||||
cont.resume(deleteTracks(context, tracklistElements, tracklist))
|
||||
}
|
||||
}
|
||||
|
||||
/* Suspend function: Wrapper for readTracklist */
|
||||
suspend fun readTracklistSuspended(context: Context): Tracklist {
|
||||
return suspendCoroutine {cont ->
|
||||
cont.resume(readTracklist(context))
|
||||
}
|
||||
}
|
||||
|
||||
/* Suspend function: Wrapper for copyFile */
|
||||
suspend fun saveCopyOfFileSuspended(context: Context, originalFileUri: Uri, targetFileUri: Uri, deleteOriginal: Boolean = false) {
|
||||
|
@ -235,83 +69,6 @@ object FileHelper {
|
|||
}
|
||||
}
|
||||
|
||||
/* Save Track as JSON to storage */
|
||||
private fun saveTrack(track: Track, saveGpxToo: Boolean) {
|
||||
val jsonString: String = getTrackJsonString(track)
|
||||
if (jsonString.isNotBlank()) {
|
||||
// write track file
|
||||
writeTextFile(jsonString, track.trackUriString.toUri())
|
||||
}
|
||||
if (saveGpxToo) {
|
||||
val gpxString: String = TrackHelper.createGpxString(track)
|
||||
if (gpxString.isNotBlank()) {
|
||||
// write GPX file
|
||||
writeTextFile(gpxString, track.gpxUriString.toUri())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Save Temp Track as JSON to storage */
|
||||
private fun saveTempTrack(context: Context, track: Track) {
|
||||
val json: String = getTrackJsonString(track)
|
||||
if (json.isNotBlank()) {
|
||||
writeTextFile(json, getTempFileUri(context))
|
||||
}
|
||||
}
|
||||
|
||||
/* Creates Uri for tracklist file */
|
||||
private fun getTracklistFileUri(context: Context): Uri {
|
||||
return File(context.getExternalFilesDir(""), Keys.TRACKLIST_FILE).toUri()
|
||||
}
|
||||
|
||||
/* Renames track */
|
||||
private fun renameTrack(context: Context, track: Track, newName: String) {
|
||||
// search track in tracklist
|
||||
val tracklist: Tracklist = readTracklist(context)
|
||||
var trackUriString: String = String()
|
||||
tracklist.tracklistElements.forEach { tracklistElement ->
|
||||
if (tracklistElement.id == track.id) {
|
||||
// rename tracklist element
|
||||
tracklistElement.name = newName
|
||||
trackUriString = tracklistElement.trackUriString
|
||||
}
|
||||
}
|
||||
if (trackUriString.isNotEmpty()) {
|
||||
// rename track
|
||||
track.name = newName
|
||||
// save track
|
||||
saveTrack(track, saveGpxToo = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Deletes multiple tracks */
|
||||
private fun deleteTracks(context: Context, tracklistElements: MutableList<TracklistElement>, tracklist: Tracklist): Tracklist {
|
||||
tracklistElements.forEach { tracklistElement ->
|
||||
deleteTrack(context, tracklistElement, tracklist)
|
||||
}
|
||||
return tracklist
|
||||
}
|
||||
|
||||
|
||||
/* Deletes one track */
|
||||
private fun deleteTrack(context: Context, tracklist_element: TracklistElement, tracklist: Tracklist): Tracklist {
|
||||
// delete track files
|
||||
val json_file: File = tracklist_element.trackUriString.toUri().toFile()
|
||||
if (json_file.isFile)
|
||||
{
|
||||
json_file.delete()
|
||||
}
|
||||
val gpx_file: File = tracklist_element.gpxUriString.toUri().toFile()
|
||||
if (gpx_file.isFile)
|
||||
{
|
||||
gpx_file.delete()
|
||||
}
|
||||
// remove track element from list
|
||||
tracklist.tracklistElements.removeIf {it.id == tracklist_element.id}
|
||||
return tracklist
|
||||
}
|
||||
|
||||
|
||||
/* Copies file to specified target */
|
||||
private fun copyFile(context: Context, originalFileUri: Uri, targetFileUri: Uri, deleteOriginal: Boolean = false) {
|
||||
|
@ -325,58 +82,19 @@ object FileHelper {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/* Converts track to JSON */
|
||||
private fun getTrackJsonString(track: Track): String {
|
||||
val gson: Gson = getCustomGson()
|
||||
var json: String = String()
|
||||
try {
|
||||
json = gson.toJson(track)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
/* Creates a Gson object */
|
||||
private fun getCustomGson(): Gson {
|
||||
fun getCustomGson(): Gson
|
||||
{
|
||||
val gsonBuilder = GsonBuilder()
|
||||
gsonBuilder.setDateFormat("yyyy-MM-dd-HH-mm-ss")
|
||||
gsonBuilder.excludeFieldsWithoutExposeAnnotation()
|
||||
return gsonBuilder.create()
|
||||
}
|
||||
|
||||
/* Converts byte value into a human readable format */
|
||||
// Source: https://programming.guide/java/formatting-byte-size-to-human-readable-format.html
|
||||
fun getReadableByteCount(bytes: Long, si: Boolean = true): String {
|
||||
|
||||
// check if Decimal prefix symbol (SI) or Binary prefix symbol (IEC) requested
|
||||
val unit: Long = if (si) 1000L else 1024L
|
||||
|
||||
// just return bytes if file size is smaller than requested unit
|
||||
if (bytes < unit) return "$bytes B"
|
||||
|
||||
// calculate exp
|
||||
val exp: Int = (ln(bytes.toDouble()) / ln(unit.toDouble())).toInt()
|
||||
|
||||
// determine prefix symbol
|
||||
val prefix: String = ((if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i")
|
||||
|
||||
// calculate result and set number format
|
||||
val result: Double = bytes / unit.toDouble().pow(exp.toDouble())
|
||||
val numberFormat = NumberFormat.getNumberInstance()
|
||||
numberFormat.maximumFractionDigits = 1
|
||||
|
||||
return numberFormat.format(result) + " " + prefix + "B"
|
||||
}
|
||||
|
||||
|
||||
/* Reads InputStream from file uri and returns it as String */
|
||||
private fun readTextFile(context: Context, fileUri: Uri): String {
|
||||
fun readTextFile(context: Context, file: File): String {
|
||||
// todo read https://commonsware.com/blog/2016/03/15/how-consume-content-uri.html
|
||||
// https://developer.android.com/training/secure-file-sharing/retrieve-info
|
||||
val file: File = fileUri.toFile()
|
||||
// check if file exists
|
||||
if (!file.exists()) {
|
||||
return String()
|
||||
}
|
||||
|
@ -393,27 +111,12 @@ object FileHelper {
|
|||
|
||||
|
||||
/* Writes given text to file on storage */
|
||||
private fun writeTextFile(text: String, fileUri: Uri) {
|
||||
fun write_text_file_noblank(text: String, file: File)
|
||||
{
|
||||
if (text.isNotEmpty()) {
|
||||
val file: File = fileUri.toFile()
|
||||
file.writeText(text)
|
||||
} else {
|
||||
LogHelper.w(TAG, "Writing text file $fileUri failed. Empty text string text was provided.")
|
||||
LogHelper.w(TAG, "Writing text file ${file.toUri()} failed. Empty text string was provided.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Writes given bitmap as image file to storage */
|
||||
private fun writeImageFile(context: Context, bitmap: Bitmap, file: File, format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, quality: Int = 75) {
|
||||
if (file.exists()) file.delete ()
|
||||
try {
|
||||
val out = FileOutputStream(file)
|
||||
bitmap.compress(format, quality, out)
|
||||
out.flush()
|
||||
out.close()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,20 +17,10 @@
|
|||
package org.y20k.trackbook.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toUri
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import org.y20k.trackbook.Keys
|
||||
import org.y20k.trackbook.R
|
||||
import org.y20k.trackbook.core.Track
|
||||
import org.y20k.trackbook.core.Tracklist
|
||||
import org.y20k.trackbook.core.TracklistElement
|
||||
import org.y20k.trackbook.core.WayPoint
|
||||
|
||||
/*
|
||||
* TrackHelper object
|
||||
|
@ -41,123 +31,14 @@ object TrackHelper {
|
|||
private val TAG: String = LogHelper.makeLogTag(TrackHelper::class.java)
|
||||
|
||||
/* Adds given locatiom as waypoint to track */
|
||||
fun addWayPointToTrack(track: Track, location: Location, omitRests: Boolean, resumed: Boolean): Pair<Boolean, Track> {
|
||||
// Step 1: Get previous location
|
||||
val previousLocation: Location?
|
||||
var numberOfWayPoints: Int = track.wayPoints.size
|
||||
|
||||
// CASE: First location
|
||||
if (numberOfWayPoints == 0) {
|
||||
previousLocation = null
|
||||
}
|
||||
// CASE: Second location - check if first location was plausible & remove implausible location
|
||||
else if (numberOfWayPoints == 1 && !LocationHelper.isFirstLocationPlausible(location, track)) {
|
||||
previousLocation = null
|
||||
numberOfWayPoints = 0
|
||||
track.wayPoints.removeAt(0)
|
||||
}
|
||||
// CASE: Third location or second location (if first was plausible)
|
||||
else {
|
||||
previousLocation = track.wayPoints[numberOfWayPoints - 1].toLocation()
|
||||
}
|
||||
|
||||
// Step 2: Update duration
|
||||
val now: Date = GregorianCalendar.getInstance().time
|
||||
val difference: Long = now.time - track.recordingStop.time
|
||||
track.duration = track.duration + difference
|
||||
track.recordingStop = now
|
||||
|
||||
// Step 3: Add waypoint, ifrecent and accurate and different enough
|
||||
val shouldBeAdded: Boolean = (
|
||||
LocationHelper.isRecentEnough(location) &&
|
||||
LocationHelper.isAccurateEnough(location, Keys.DEFAULT_THRESHOLD_LOCATION_ACCURACY) &&
|
||||
LocationHelper.isDifferentEnough(previousLocation, location, omitRests)
|
||||
)
|
||||
if (shouldBeAdded) {
|
||||
// Step 3.1: Update distance (do not update if resumed -> we do not want to add values calculated during a recording pause)
|
||||
if (!resumed) {
|
||||
track.distance = track.distance + LocationHelper.calculateDistance(previousLocation, location)
|
||||
}
|
||||
// Step 3.2: Update altitude values
|
||||
val altitude: Double = location.altitude
|
||||
if (altitude != 0.0) {
|
||||
if (numberOfWayPoints == 0) {
|
||||
track.maxAltitude = altitude
|
||||
track.minAltitude = altitude
|
||||
}
|
||||
else {
|
||||
if (altitude > track.maxAltitude) track.maxAltitude = altitude
|
||||
if (altitude < track.minAltitude) track.minAltitude = altitude
|
||||
}
|
||||
}
|
||||
// Step 3.3: Toggle stop over status, if necessary
|
||||
if (track.wayPoints.size < 0) {
|
||||
track.wayPoints[track.wayPoints.size - 1].isStopOver = LocationHelper.isStopOver(previousLocation, location)
|
||||
}
|
||||
|
||||
// Step 3.4: Add current location as point to center on for later display
|
||||
track.latitude = location.latitude
|
||||
track.longitude = location.longitude
|
||||
|
||||
// Step 3.5: Add location as new waypoint
|
||||
track.wayPoints.add(WayPoint(location = location, distanceToStartingPoint = track.distance))
|
||||
}
|
||||
|
||||
return Pair(shouldBeAdded, track)
|
||||
}
|
||||
|
||||
/* Calculates time passed since last stop of recording */
|
||||
fun calculateDurationOfPause(recordingStop: Date): Long = GregorianCalendar.getInstance().time.time - recordingStop.time
|
||||
|
||||
/* Creates GPX string for given track */
|
||||
fun createGpxString(track: Track): String {
|
||||
val gpxString = StringBuilder("")
|
||||
|
||||
// Header
|
||||
gpxString.appendLine("""
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||
<gpx
|
||||
version="1.1" creator="Trackbook App (Android)"
|
||||
xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"
|
||||
>
|
||||
""".trimIndent())
|
||||
gpxString.appendLine("\t<metadata>")
|
||||
gpxString.appendLine("\t\t<name>Trackbook Recording: ${track.name}</name>")
|
||||
gpxString.appendLine("\t</metadata>")
|
||||
|
||||
// POIs
|
||||
val poiList: List<WayPoint> = track.wayPoints.filter { it.starred }
|
||||
poiList.forEach { poi ->
|
||||
gpxString.appendLine("\t<wpt lat=\"${poi.latitude}\" lon=\"${poi.longitude}\">")
|
||||
gpxString.appendLine("\t\t<name>Point of interest</name>")
|
||||
gpxString.appendLine("\t\t<ele>${poi.altitude}</ele>")
|
||||
gpxString.appendLine("\t</wpt>")
|
||||
}
|
||||
|
||||
// TRK
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
|
||||
gpxString.appendLine("\t<trk>")
|
||||
gpxString.appendLine("\t\t<name>${track.name}</name>")
|
||||
gpxString.appendLine("\t\t<trkseg>")
|
||||
track.wayPoints.forEach { wayPoint ->
|
||||
gpxString.appendLine("\t\t\t<trkpt lat=\"${wayPoint.latitude}\" lon=\"${wayPoint.longitude}\">")
|
||||
gpxString.appendLine("\t\t\t\t<ele>${wayPoint.altitude}</ele>")
|
||||
gpxString.appendLine("\t\t\t\t<time>${dateFormat.format(Date(wayPoint.time))}</time>")
|
||||
gpxString.appendLine("\t\t\t\t<sat>${wayPoint.numberSatellites}</sat>")
|
||||
gpxString.appendLine("\t\t\t</trkpt>")
|
||||
}
|
||||
gpxString.appendLine("\t\t</trkseg>")
|
||||
gpxString.appendLine("\t</trk>")
|
||||
gpxString.appendLine("</gpx>")
|
||||
|
||||
return gpxString.toString()
|
||||
}
|
||||
|
||||
/* Toggles starred flag for given position */
|
||||
fun toggleStarred(context: Context, track: Track, latitude: Double, longitude: Double): Track {
|
||||
fun toggle_waypoint_starred(context: Context, track: Track, latitude: Double, longitude: Double)
|
||||
{
|
||||
track.wayPoints.forEach { waypoint ->
|
||||
if (waypoint.latitude == latitude && waypoint.longitude == longitude) {
|
||||
waypoint.starred = !waypoint.starred
|
||||
|
@ -167,6 +48,5 @@ object TrackHelper {
|
|||
}
|
||||
}
|
||||
}
|
||||
return track
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,6 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlinx.coroutines.*
|
||||
|
@ -38,8 +37,9 @@ import kotlinx.coroutines.Dispatchers.IO
|
|||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import org.y20k.trackbook.Keys
|
||||
import org.y20k.trackbook.R
|
||||
import org.y20k.trackbook.core.Track
|
||||
import org.y20k.trackbook.core.Tracklist
|
||||
import org.y20k.trackbook.core.TracklistElement
|
||||
import org.y20k.trackbook.core.load_tracklist
|
||||
import org.y20k.trackbook.helpers.*
|
||||
|
||||
|
||||
|
@ -61,24 +61,20 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
|
|||
|
||||
/* Listener Interface */
|
||||
interface TracklistAdapterListener {
|
||||
fun onTrackElementTapped(tracklistElement: TracklistElement) { }
|
||||
// fun onTrackElementStarred(trackId: Long, starred: Boolean)
|
||||
fun onTrackElementTapped(track: Track) { }
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onAttachedToRecyclerView from RecyclerView.Adapter */
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
// get reference to listener
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView)
|
||||
{
|
||||
tracklistListener = fragment as TracklistAdapterListener
|
||||
// load tracklist
|
||||
tracklist = FileHelper.readTracklist(context)
|
||||
tracklist.tracklistElements.sortByDescending { tracklistElement -> tracklistElement.date }
|
||||
tracklist = load_tracklist(context)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onCreateViewHolder from RecyclerView.Adapter */
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
|
||||
{
|
||||
when (viewType) {
|
||||
Keys.VIEW_TYPE_STATISTICS -> {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.element_statistics, parent, false)
|
||||
|
@ -104,16 +100,16 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
|
|||
|
||||
/* Overrides getItemCount from RecyclerView.Adapter */
|
||||
override fun getItemCount(): Int {
|
||||
// +1 ==> the total statistics element
|
||||
return tracklist.tracklistElements.size + 1
|
||||
// +1 because of the total statistics element
|
||||
return tracklist.tracks.size + 1
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onBindViewHolder from RecyclerView.Adapter */
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
|
||||
when (holder) {
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
|
||||
{
|
||||
when (holder)
|
||||
{
|
||||
// CASE STATISTICS ELEMENT
|
||||
is ElementStatisticsViewHolder -> {
|
||||
val elementStatisticsViewHolder: ElementStatisticsViewHolder = holder as ElementStatisticsViewHolder
|
||||
|
@ -124,14 +120,14 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
|
|||
is ElementTrackViewHolder -> {
|
||||
val positionInTracklist: Int = position - 1 // Element 0 is the statistics element.
|
||||
val elementTrackViewHolder: ElementTrackViewHolder = holder as ElementTrackViewHolder
|
||||
elementTrackViewHolder.trackNameView.text = tracklist.tracklistElements[positionInTracklist].name
|
||||
elementTrackViewHolder.trackNameView.text = tracklist.tracks[positionInTracklist].name
|
||||
elementTrackViewHolder.trackDataView.text = createTrackDataString(positionInTracklist)
|
||||
when (tracklist.tracklistElements[positionInTracklist].starred) {
|
||||
when (tracklist.tracks[positionInTracklist].starred) {
|
||||
true -> elementTrackViewHolder.starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_filled_24dp))
|
||||
false -> elementTrackViewHolder.starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_outline_24dp))
|
||||
}
|
||||
elementTrackViewHolder.trackElement.setOnClickListener {
|
||||
tracklistListener.onTrackElementTapped(tracklist.tracklistElements[positionInTracklist])
|
||||
tracklistListener.onTrackElementTapped(tracklist.tracks[positionInTracklist])
|
||||
}
|
||||
elementTrackViewHolder.starButton.setOnClickListener {
|
||||
toggleStarred(it, positionInTracklist)
|
||||
|
@ -144,102 +140,76 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
|
|||
|
||||
|
||||
/* Get track name for given position */
|
||||
fun getTrackName(positionInRecyclerView: Int): String {
|
||||
// first position is always the statistics element
|
||||
return tracklist.tracklistElements[positionInRecyclerView - 1].name
|
||||
fun getTrackName(positionInRecyclerView: Int): String
|
||||
{
|
||||
// Minus 1 because first position is always the statistics element
|
||||
return tracklist.tracks[positionInRecyclerView - 1].name
|
||||
}
|
||||
|
||||
|
||||
/* Removes track and track files for given position - used by TracklistFragment */
|
||||
fun removeTrackAtPosition(context: Context, position: Int) {
|
||||
fun delete_track_at_position(context: Context, ui_index: Int)
|
||||
{
|
||||
CoroutineScope(IO).launch {
|
||||
val index = position - 1 // position 0 is the statistics element
|
||||
val tracklist_element = tracklist.tracklistElements[index]
|
||||
val deferred: Deferred<Tracklist> = async { FileHelper.deleteTrackSuspended(context, tracklist_element, tracklist) }
|
||||
val track_index = ui_index - 1 // position 0 is the statistics element
|
||||
val track = tracklist.tracks[track_index]
|
||||
val deferred: Deferred<Unit> = async { track.delete_suspended(context) }
|
||||
// wait for result and store in tracklist
|
||||
withContext(Main) {
|
||||
tracklist = deferred.await()
|
||||
deferred.await()
|
||||
tracklist.tracks.remove(track)
|
||||
notifyItemChanged(0)
|
||||
notifyItemRemoved(position)
|
||||
notifyItemRangeChanged(position, tracklist.tracklistElements.size)
|
||||
notifyItemRemoved(ui_index)
|
||||
notifyItemRangeChanged(ui_index, tracklist.tracks.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Suspend function: Wrapper for removeTrackAtPosition */
|
||||
suspend fun removeTrackAtPositionSuspended(context: Context, position: Int) {
|
||||
suspend fun delete_track_at_position_suspended(context: Context, position: Int) {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(removeTrackAtPosition(context, position))
|
||||
cont.resume(delete_track_at_position(context, position))
|
||||
}
|
||||
}
|
||||
|
||||
/* Removes track and track files for given track id - used by TracklistFragment */
|
||||
fun removeTrackById(context: Context, trackId: Long) {
|
||||
fun delete_track_by_id(context: Context, trackId: Long) {
|
||||
CoroutineScope(IO).launch {
|
||||
// reload tracklist //todo check if necessary
|
||||
tracklist = FileHelper.readTracklist(context)
|
||||
val index: Int = tracklist.tracklistElements.indexOfFirst {it.id == trackId}
|
||||
val index: Int = tracklist.tracks.indexOfFirst {it.id == trackId}
|
||||
if (index == -1) {
|
||||
return@launch
|
||||
}
|
||||
val tracklist_element = tracklist.tracklistElements[index]
|
||||
val deferred: Deferred<Tracklist> = async { FileHelper.deleteTrackSuspended(context, tracklist_element, tracklist) }
|
||||
// wait for result and store in tracklist
|
||||
val position = index + 1 // position 0 is the statistics element
|
||||
withContext(Main) {
|
||||
tracklist = deferred.await()
|
||||
notifyItemChanged(0)
|
||||
notifyItemRemoved(position)
|
||||
notifyItemRangeChanged(position, tracklist.tracklistElements.size)
|
||||
}
|
||||
delete_track_at_position(context, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Returns if the adapter is empty */
|
||||
fun isEmpty(): Boolean {
|
||||
return tracklist.tracklistElements.size == 0
|
||||
return tracklist.tracks.size == 0
|
||||
}
|
||||
|
||||
|
||||
/* Finds current position of track element in adapter list */
|
||||
private fun findPosition(trackId: Long): Int {
|
||||
tracklist.tracklistElements.forEachIndexed {index, tracklistElement ->
|
||||
if (tracklistElement.id == trackId)
|
||||
{
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
|
||||
/* Toggles the starred state of tracklist element - and saves tracklist */
|
||||
private fun toggleStarred(view: View, position: Int) {
|
||||
val starButton: ImageButton = view as ImageButton
|
||||
when (tracklist.tracklistElements[position].starred) {
|
||||
true -> {
|
||||
starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_outline_24dp))
|
||||
tracklist.tracklistElements[position].starred = false
|
||||
}
|
||||
false -> {
|
||||
starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_filled_24dp))
|
||||
tracklist.tracklistElements[position].starred = true
|
||||
}
|
||||
if (tracklist.tracks[position].starred)
|
||||
{
|
||||
starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_outline_24dp))
|
||||
tracklist.tracks[position].starred = false
|
||||
}
|
||||
else
|
||||
{
|
||||
starButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_star_filled_24dp))
|
||||
tracklist.tracks[position].starred = true
|
||||
}
|
||||
tracklist.tracks[position].save_json(context)
|
||||
}
|
||||
|
||||
|
||||
/* Creates the track data string */
|
||||
private fun createTrackDataString(position: Int): String {
|
||||
val tracklistElement: TracklistElement = tracklist.tracklistElements[position]
|
||||
val track_duration_string = DateTimeHelper.convertToReadableTime(context, tracklistElement.duration)
|
||||
val track: Track = tracklist.tracks[position]
|
||||
val track_duration_string = DateTimeHelper.convertToReadableTime(context, track.duration)
|
||||
val trackDataString: String
|
||||
when (tracklistElement.name == tracklistElement.dateString) {
|
||||
when (track.name == track.dateString) {
|
||||
// CASE: no individual name set - exclude date
|
||||
true -> trackDataString = "${LengthUnitHelper.convertDistanceToString(tracklistElement.distance, useImperial)} • ${track_duration_string}"
|
||||
true -> trackDataString = "${LengthUnitHelper.convertDistanceToString(track.distance, useImperial)} • ${track_duration_string}"
|
||||
// CASE: no individual name set - include date
|
||||
false -> trackDataString = "${tracklistElement.dateString} • ${LengthUnitHelper.convertDistanceToString(tracklistElement.distance, useImperial)} • ${track_duration_string}"
|
||||
false -> trackDataString = "${track.dateString} • ${LengthUnitHelper.convertDistanceToString(track.distance, useImperial)} • ${track_duration_string}"
|
||||
}
|
||||
return trackDataString
|
||||
}
|
||||
|
@ -251,22 +221,22 @@ class TracklistAdapter(private val fragment: Fragment) : RecyclerView.Adapter<Re
|
|||
private inner class DiffCallback(val oldList: Tracklist, val newList: Tracklist): DiffUtil.Callback() {
|
||||
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList.tracklistElements[oldItemPosition]
|
||||
val newItem = newList.tracklistElements[newItemPosition]
|
||||
val oldItem = oldList.tracks[oldItemPosition]
|
||||
val newItem = newList.tracks[newItemPosition]
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun getOldListSize(): Int {
|
||||
return oldList.tracklistElements.size
|
||||
return oldList.tracks.size
|
||||
}
|
||||
|
||||
override fun getNewListSize(): Int {
|
||||
return newList.tracklistElements.size
|
||||
return newList.tracks.size
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList.tracklistElements[oldItemPosition]
|
||||
val newItem = newList.tracklistElements[newItemPosition]
|
||||
val oldItem = oldList.tracks[oldItemPosition]
|
||||
val newItem = newList.tracks[newItemPosition]
|
||||
return (oldItem.id == newItem.id) && (oldItem.distance == newItem.distance)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -187,7 +187,7 @@ data class TrackFragmentLayoutHolder(private var context: Context, private var m
|
|||
mapView.overlays.add(trackSpecialMarkersOverlay)
|
||||
}
|
||||
// save track
|
||||
CoroutineScope(Dispatchers.IO).launch { FileHelper.saveTrackSuspended(track, true) }
|
||||
CoroutineScope(Dispatchers.IO).launch { track.save_both_suspended(context) }
|
||||
}
|
||||
|
||||
|
||||
|
@ -196,7 +196,7 @@ data class TrackFragmentLayoutHolder(private var context: Context, private var m
|
|||
{
|
||||
if (track.latitude != 0.0 && track.longitude != 0.0)
|
||||
{
|
||||
CoroutineScope(Dispatchers.IO).launch { FileHelper.saveTrackSuspended(track, false) }
|
||||
CoroutineScope(Dispatchers.IO).launch { track.save_json_suspended(context) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue