Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add journey generator #153

Merged
merged 9 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.canopas.yourspace.data.models.user.USER_STATE_NO_NETWORK_OR_PHONE_OFF
import com.canopas.yourspace.data.models.user.USER_STATE_UNKNOWN
import com.canopas.yourspace.data.repository.JourneyRepository
import com.canopas.yourspace.data.service.auth.AuthService
import com.canopas.yourspace.data.service.location.LocationManager
import com.canopas.yourspace.data.storage.UserPreferences
import com.canopas.yourspace.data.utils.isLocationPermissionGranted
import com.canopas.yourspace.domain.utils.isNetWorkConnected
Expand Down Expand Up @@ -87,6 +88,9 @@ class YourSpaceFcmService : FirebaseMessagingService() {
@Inject
lateinit var journeyRepository: JourneyRepository

@Inject
lateinit var locationManager: LocationManager

private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)

Expand Down Expand Up @@ -165,13 +169,7 @@ class YourSpaceFcmService : FirebaseMessagingService() {

scope.launch {
try {
authService.currentUser?.id?.let { userId ->
val lastKnownJourney = journeyRepository.getLastKnownLocation(userId)
journeyRepository.checkAndSaveLocationOnDayChanged(
userId = userId,
lastKnownJourney = lastKnownJourney
)
}
locationManager.startLocationTracking()
} catch (e: Exception) {
Timber.e(e, "Failed to update location")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.canopas.yourspace.R
import com.canopas.yourspace.data.models.location.LocationJourney
import com.canopas.yourspace.data.models.location.isSteadyLocation
import com.canopas.yourspace.data.models.location.isSteady
import com.canopas.yourspace.domain.utils.getAddress
import com.canopas.yourspace.domain.utils.getPlaceAddress
import com.canopas.yourspace.domain.utils.isToday
Expand All @@ -71,7 +71,7 @@ fun LocationHistoryItem(
showJourneyDetails: () -> Unit,
selectedMapStyle: String
) {
if (location.isSteadyLocation()) {
if (location.isSteady()) {
SteadyLocationItem(location, isFirstItem, isLastItem, journeyList) {
addPlaceTap(location.from_latitude, location.from_longitude)
}
Expand Down Expand Up @@ -175,7 +175,7 @@ fun SteadyLocationItem(

val nextJourney = sortedList
.subList(currentIndex + 1, sortedList.size)
.firstOrNull { !it.isSteadyLocation() }
.firstOrNull { !it.isSteady() }

val createdAt = location.created_at ?: System.currentTimeMillis()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ private fun JourneyInfo(journey: LocationJourney) {
.padding(start = 16.dp)
.weight(1f)
) {
PlaceInfo(toAddressStr, getFormattedLocationTime(journey.update_at!!))
journey.update_at?.let { getFormattedLocationTime(it) }
?.let { PlaceInfo(toAddressStr, it) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.canopas.yourspace.data.models.location.LocationJourney
import com.canopas.yourspace.data.models.user.ApiUser
import com.canopas.yourspace.data.repository.JourneyRepository
import com.canopas.yourspace.data.service.auth.AuthService
import com.canopas.yourspace.data.service.location.ApiJourneyService
import com.canopas.yourspace.data.service.user.ApiUserService
Expand All @@ -30,10 +29,9 @@ class JourneyTimelineViewModel @Inject constructor(
private val journeyService: ApiJourneyService,
private val apiUserService: ApiUserService,
private val authService: AuthService,
private val journeyRepository: JourneyRepository,
private val appDispatcher: AppDispatcher,
private val connectivityObserver: ConnectivityObserver,
private val userPreferences: UserPreferences
userPreferences: UserPreferences
) : ViewModel() {

private var userId: String =
Expand Down Expand Up @@ -120,23 +118,6 @@ class JourneyTimelineViewModel @Inject constructor(
appending = false,
isLoading = false
)

if (!hasMoreItems && locationJourneys.isEmpty()) {
// No journey found. Try checking in local database
// If any location is found for current user and current date then add it to the list as well as remote database
val lastJourney = journeyRepository.checkAndAddLocalJourneyToRemoteDatabase(userId)
val locationJourney = (
lastJourney?.let {
listOf(it)
} ?: emptyList()
).groupByDate()
_state.value = _state.value.copy(
groupedLocation = locationJourney,
hasMoreLocations = false,
appending = false,
isLoading = false
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to fetch location history")
_state.value =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,21 @@ data class LocationJourney(
@Keep
data class JourneyRoute(val latitude: Double = 0.0, val longitude: Double = 0.0)

/**
* Data class to hold the result of the journey generation.
*/
data class JourneyResult(
val updatedJourney: LocationJourney?,
val newJourney: LocationJourney?
)

fun Location.toRoute(): JourneyRoute {
return JourneyRoute(latitude, longitude)
}

fun JourneyRoute.toLatLng() = LatLng(latitude, longitude)
fun LocationJourney.toRoute() =
if (isSteadyLocation()) {
if (isSteady()) {
emptyList()
} else {
listOf(
Expand All @@ -44,7 +52,7 @@ fun LocationJourney.toRoute() =
)
}

fun LocationJourney.isSteadyLocation(): Boolean {
fun LocationJourney.isSteady(): Boolean {
return to_latitude == null && to_longitude == null
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package com.canopas.yourspace.data.repository

import android.location.Location
import com.canopas.yourspace.data.models.location.JourneyResult
import com.canopas.yourspace.data.models.location.LocationJourney
import com.canopas.yourspace.data.models.location.isSteady
import com.canopas.yourspace.data.models.location.toLocationFromMovingJourney
import com.canopas.yourspace.data.models.location.toLocationFromSteadyJourney
import com.canopas.yourspace.data.models.location.toRoute
import java.util.Calendar
import kotlin.math.sqrt

private const val MIN_DISTANCE = 100.0 // 100 meters
private const val MIN_TIME_DIFFERENCE = 5 * 60 * 1000L // 5 minutes
private const val MIN_DISTANCE_FOR_MOVING = 10.0 // 10 meters
private const val MIN_UPDATE_INTERVAL_MINUTE = 30000L // 10 seconds
cp-megh-l marked this conversation as resolved.
Show resolved Hide resolved

/**
* Function to generate a new journey or update an existing one.
*
* @param userId ID of current user
* @param newLocation The newly received location
* @param lastKnownJourney The last known journey (could be null)
* @param lastLocations A list of previous location fixes used for e.g. geometric median
* @return A [JourneyResult] holding either updatedJourney or newJourney or both, or null if no changes.
*/
fun getJourney(
userId: String,
newLocation: Location,
lastKnownJourney: LocationJourney?,
lastLocations: List<Location>
): JourneyResult? {
cp-radhika-s marked this conversation as resolved.
Show resolved Hide resolved
// 1. If there is no previous journey, create a new STEADY journey
if (lastKnownJourney == null) {
val newSteadyJourney = LocationJourney(
user_id = userId,
from_latitude = newLocation.latitude,
from_longitude = newLocation.longitude,
// to_latitude and to_longitude remain null => steady
created_at = System.currentTimeMillis(),
update_at = System.currentTimeMillis()
cp-megh-l marked this conversation as resolved.
Show resolved Hide resolved
)
return JourneyResult(null, newSteadyJourney)
}

// 2. Calculate a geometric median if needed (optional if lastLocations is empty or 1)
val geometricMedian = if (lastLocations.isNotEmpty()) {
geometricMedianCalculation(lastLocations)
} else {
null
}

// 3. Determine how far the newLocation is from our reference point
val distance = if (lastKnownJourney.isSteady()) {
// Compare newLocation with "from" location
distanceBetween(
geometricMedian ?: newLocation,
lastKnownJourney.toLocationFromSteadyJourney()
)
} else {
// Compare newLocation with "to" location
distanceBetween(
geometricMedian ?: newLocation,
lastKnownJourney.toLocationFromMovingJourney()
)
}

// 4. Calculate time difference
val timeDifference = newLocation.time - (lastKnownJourney.update_at ?: 0L)

// 5. Check if the day changed
val dayChanged = isDayChanged(newLocation, lastKnownJourney)

// 6. If lastKnownJourney is STEADY, distance < MIN_DISTANCE, but the day changed -> update the existing journey
if (lastKnownJourney.isSteady() && distance < MIN_DISTANCE && dayChanged) {
val updatedJourney = lastKnownJourney.copy(
from_latitude = newLocation.latitude,
from_longitude = newLocation.longitude,
update_at = System.currentTimeMillis()
)
return JourneyResult(updatedJourney, null)
}

// -----------------------------------------------------------------
// Manage Journey
// 1. lastKnownJourney is null, create a new journey
// 2. If user is stationary
// a. update the journey with the last location and update the update_at
// b. If distance > 150,
// - update the journey with the last location and update the update_at
// - create a new moving journey
// 3. If user is moving
// a. If distance > 150, update the last location, route and update_at
// b. If distance < 150 and time diff between two location updates > 5 mins,
// - update the journey with the last location and update the update_at, and stop the journey
// - create a new stationary journey
// -----------------------------------------------------------------
if (lastKnownJourney.isSteady()) {
// STEADY journey (to_latitude/to_longitude == null)

// If distance > MIN_DISTANCE => user started moving
if (distance > MIN_DISTANCE) {
// 1. Update last STEADY journey with new "from" lat/lng
val updatedJourney = lastKnownJourney.copy(
from_latitude = newLocation.latitude,
from_longitude = newLocation.longitude,
update_at = System.currentTimeMillis()
)

// 2. Create NEW MOVING journey
val newMovingJourney = LocationJourney(
user_id = userId,
from_latitude = lastKnownJourney.from_latitude,
from_longitude = lastKnownJourney.from_longitude,
to_latitude = newLocation.latitude,
to_longitude = newLocation.longitude,
routes = lastLocations.map { it.toRoute() },
route_distance = distance,
route_duration = timeDifference,
created_at = System.currentTimeMillis(),
update_at = System.currentTimeMillis()
)
return JourneyResult(updatedJourney, newMovingJourney)
}
// If distance < MIN_DISTANCE && timeDifference > MIN_UPDATE_INTERVAL_MINUTE => just update STEADY
else if (distance < MIN_DISTANCE && timeDifference > MIN_UPDATE_INTERVAL_MINUTE) {
val updatedJourney = lastKnownJourney.copy(
from_latitude = newLocation.latitude,
from_longitude = newLocation.longitude,
update_at = System.currentTimeMillis()
)
return JourneyResult(updatedJourney, null)
}
} else {
// MOVING journey (to_latitude/to_longitude != null)

// If the user likely stopped moving => (timeDifference > MIN_TIME_DIFFERENCE)
if (timeDifference > MIN_TIME_DIFFERENCE) {
// 1. Update last moving journey
val updatedJourney = lastKnownJourney.copy(
to_latitude = newLocation.latitude,
to_longitude = newLocation.longitude,
route_distance = distance, // or add to existing distance if desired
route_duration = (lastKnownJourney.update_at ?: 0L) -
(lastKnownJourney.created_at ?: 0L),
cp-megh-l marked this conversation as resolved.
Show resolved Hide resolved
routes = lastKnownJourney.routes + newLocation.toRoute()
)

// 2. Create NEW STEADY journey
val newSteadyJourney = LocationJourney(
user_id = userId,
from_latitude = newLocation.latitude,
from_longitude = newLocation.longitude,
// to_latitude and to_longitude remain null => steady
created_at = lastKnownJourney.update_at ?: System.currentTimeMillis(),
update_at = System.currentTimeMillis()
)
return JourneyResult(updatedJourney, newSteadyJourney)
}
// If user is still moving => distance > MIN_DISTANCE_FOR_MOVING, timeDifference > MIN_UPDATE_INTERVAL_MINUTE => update route
else if (distance > MIN_DISTANCE_FOR_MOVING && timeDifference > MIN_UPDATE_INTERVAL_MINUTE) {
val updatedJourney = lastKnownJourney.copy(
to_latitude = newLocation.latitude,
to_longitude = newLocation.longitude,
// Add new distance to previous distance, if you want cumulative
route_distance = (lastKnownJourney.route_distance ?: 0.0) + distance,
route_duration = (lastKnownJourney.update_at ?: 0L) -
(lastKnownJourney.created_at ?: 0L),
routes = lastKnownJourney.routes + newLocation.toRoute(),
update_at = System.currentTimeMillis()
)
return JourneyResult(updatedJourney, null)
}
}

// If none of the conditions are satisfied, return null
return null
}

/**
* Checks if the day of [newLocation]'s time differs from the day of [lastKnownJourney]'s updated_at.
*/
private fun isDayChanged(
newLocation: Location,
lastKnownJourney: LocationJourney
): Boolean {
val lastMillis = lastKnownJourney.update_at ?: System.currentTimeMillis()
val lastCal = Calendar.getInstance().apply { timeInMillis = lastMillis }
val lastDay = lastCal.get(Calendar.DAY_OF_YEAR)

val newCal = Calendar.getInstance().apply { timeInMillis = newLocation.time }
val newDay = newCal.get(Calendar.DAY_OF_YEAR)

return lastDay != newDay
}

/**
* Computes distance in meters between two [Location] objects.
*/
private fun distanceBetween(loc1: Location, loc2: Location): Double {
return loc1.distanceTo(loc2).toDouble()
}

/**
* Rough "geometric median" among a list of [Location] objects by scanning
* which point yields smallest sum of distances to all others.
*/
private fun geometricMedianCalculation(locations: List<Location>): Location {
val result = locations.minByOrNull { candidate ->
locations.sumOf { location ->
val distance = distance(candidate, location)
distance
}
} ?: throw IllegalArgumentException("Location list is empty")
return result
}

private fun distance(loc1: Location, loc2: Location): Double {
val latDiff = loc1.latitude - loc2.latitude
val lonDiff = loc1.longitude - loc2.longitude
return sqrt(latDiff * latDiff + lonDiff * lonDiff)
}
cp-megh-l marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading