Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 22 additions & 9 deletions app/src/main/java/org/nitri/opentopo/MapFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ import org.nitri.opentopo.overlay.GestureOverlay
import org.nitri.opentopo.overlay.GestureOverlay.GestureCallback
import org.nitri.opentopo.overlay.OverlayHelper
import org.nitri.opentopo.analytics.AnalyticsProvider
import org.nitri.opentopo.analytics.OrsOutcome
import org.nitri.opentopo.analytics.OrsStartSource
import org.nitri.opentopo.analytics.durationBucket
import org.nitri.opentopo.util.MapOrientation
import org.nitri.opentopo.util.OrientationSensor
import org.nitri.opentopo.util.Utils
Expand Down Expand Up @@ -585,14 +588,14 @@ class MapFragment : Fragment(), LocationListener, PopupMenu.OnMenuItemClickListe
val language = locale.language.lowercase()
listener?.getOpenRouteServiceClient()?.let { client ->
val directions = Directions(client, profile)
val destinationCount = markers.size
val startSource = if (currentLocation != null) OrsStartSource.CURRENT_LOCATION else OrsStartSource.MARKER_ONLY
val startedAt = System.nanoTime() / 1_000_000
val analytics = AnalyticsProvider.get(requireContext())
directions.getRouteGpx(coordinates, language, object : Directions.RouteGpxResult {
override fun onSuccess(gpx: String) {
Log.d(TAG, "GPX: $gpx")
// Analytics: route calculation succeeded (Play flavor reports it; FOSS no-ops)
AnalyticsProvider.get(requireContext()).trackRouteCalculated(
profile = profile,
waypointCount = coordinates.size
)
Log.d(TAG, "ORS route GPX received (${gpx.length} chars)")
trackRouteResult(OrsOutcome.SUCCESS)
gpxViewModel?.markerCoordinates = markers
gpxViewModel?.orsProfile = profile

Expand All @@ -607,16 +610,26 @@ class MapFragment : Fragment(), LocationListener, PopupMenu.OnMenuItemClickListe
}
}

override fun onError(message: String) {
Log.e(TAG, "Error fetching GPX: $message")
override fun onEmpty() {
trackRouteResult(OrsOutcome.EMPTY)
context?.let { ctx ->
Toast.makeText(
ctx,
"Error fetching GPX: $message",
R.string.unable_to_calculate_route,
Toast.LENGTH_LONG
).show()
}
}

override fun onError(category: org.nitri.opentopo.analytics.OrsErrorCategory) {
trackRouteResult(OrsOutcome.ERROR, category)
context?.let { Toast.makeText(it, R.string.unable_to_calculate_route, Toast.LENGTH_LONG).show() }
}

private fun trackRouteResult(outcome: OrsOutcome, category: org.nitri.opentopo.analytics.OrsErrorCategory? = null) {
analytics.trackOrsRouteResult(outcome, profile, destinationCount,
startSource, durationBucket(System.nanoTime() / 1_000_000 - startedAt), category)
}
}
)
}
Expand Down
20 changes: 15 additions & 5 deletions app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,17 @@ interface AnalyticsTracker {
fileName: String?
)

fun trackRouteCalculated(
profile: String?,
waypointCount: Int
)
fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int,
startSource: OrsStartSource, durationBucket: DurationBucket,
errorCategory: OrsErrorCategory? = null)

fun trackOrsSearchOpened(hasMapFocus: Boolean)

fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int,
durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket,
isRetry: Boolean, errorCategory: OrsErrorCategory? = null)

fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int)

fun trackMapLayerSelected(baseMap: String, overlay: String)

Expand All @@ -40,7 +47,10 @@ object NoOpAnalyticsTracker : AnalyticsTracker {
override fun trackScreen(screenName: String, screenClass: String) { /* no-op */ }
override fun trackGpxLoaded(source: String, gpx: Gpx, fileName: String?) { /* no-op */ }
override fun trackKmlLoaded(source: String, contentType: String, fileName: String?) { /* no-op */ }
override fun trackRouteCalculated(profile: String?, waypointCount: Int) { /* no-op */ }
override fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int, startSource: OrsStartSource, durationBucket: DurationBucket, errorCategory: OrsErrorCategory?) { /* no-op */ }
override fun trackOrsSearchOpened(hasMapFocus: Boolean) { /* no-op */ }
override fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int, durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket, isRetry: Boolean, errorCategory: OrsErrorCategory?) { /* no-op */ }
override fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int) { /* no-op */ }
override fun trackMapLayerSelected(baseMap: String, overlay: String) { /* no-op */ }
override fun trackMarkersImported(importedCount: Int, skippedCount: Int) { /* no-op */ }
override fun trackMarkersExported(markerCount: Int) { /* no-op */ }
Expand Down
50 changes: 50 additions & 0 deletions app/src/main/java/org/nitri/opentopo/analytics/OrsAnalytics.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package org.nitri.opentopo.analytics

import com.google.gson.JsonParseException
import retrofit2.HttpException
import java.io.IOException
import java.net.SocketTimeoutException

enum class OrsOutcome(val value: String) { SUCCESS("success"), EMPTY("empty"), ERROR("error") }
enum class OrsStartSource(val value: String) { CURRENT_LOCATION("current_location"), MARKER_ONLY("marker_only") }
enum class DurationBucket(val value: String) { UNDER_1S("under_1s"), ONE_TO_THREE_SECONDS("1_3s"), OVER_3S("over_3s") }
enum class QueryLengthBucket(val value: String) { THREE_TO_FIVE("3_5"), SIX_TO_TEN("6_10"), OVER_TEN("over_10") }
enum class ResultPositionBucket(val value: String) { FIRST("1"), SECOND_OR_THIRD("2_3"), FOURTH_OR_LATER("4_plus") }
enum class OrsErrorCategory(val value: String) {
NETWORK("network"), TIMEOUT("timeout"), AUTH("auth"), RATE_LIMIT("rate_limit"),
SERVER("server"), INVALID_RESPONSE("invalid_response"), UNKNOWN("unknown")
}

fun durationBucket(durationMillis: Long) = when {
durationMillis < 1_000 -> DurationBucket.UNDER_1S
durationMillis <= 3_000 -> DurationBucket.ONE_TO_THREE_SECONDS
else -> DurationBucket.OVER_3S
}

fun queryLengthBucket(length: Int) = when (length) {
in 3..5 -> QueryLengthBucket.THREE_TO_FIVE
in 6..10 -> QueryLengthBucket.SIX_TO_TEN
else -> QueryLengthBucket.OVER_TEN
}

/** [zeroBasedIndex] is intentionally positional; result data never enters analytics. */
fun resultPositionBucket(zeroBasedIndex: Int) = when (zeroBasedIndex) {
0 -> ResultPositionBucket.FIRST
1, 2 -> ResultPositionBucket.SECOND_OR_THIRD
else -> ResultPositionBucket.FOURTH_OR_LATER
}

fun classifyOrsError(error: Throwable): OrsErrorCategory = when (error) {
is SocketTimeoutException -> OrsErrorCategory.TIMEOUT
is HttpException -> classifyOrsHttpStatus(error.code())
is JsonParseException -> OrsErrorCategory.INVALID_RESPONSE
is IOException -> OrsErrorCategory.NETWORK
else -> OrsErrorCategory.UNKNOWN
}

fun classifyOrsHttpStatus(status: Int): OrsErrorCategory = when (status) {
401, 403 -> OrsErrorCategory.AUTH
429 -> OrsErrorCategory.RATE_LIMIT
in 500..599 -> OrsErrorCategory.SERVER
else -> OrsErrorCategory.UNKNOWN
}
11 changes: 7 additions & 4 deletions app/src/main/java/org/nitri/opentopo/ors/Directions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.nitri.ors.OrsClient
import org.nitri.ors.helper.RouteHelper
import org.nitri.opentopo.analytics.OrsErrorCategory
import org.nitri.opentopo.analytics.classifyOrsError

class Directions(val client: OrsClient, private val profile: String) {

Expand All @@ -19,19 +21,20 @@ class Directions(val client: OrsClient, private val profile: String) {
if (gpxXml.isNotBlank()) {
result.onSuccess(gpxXml)
} else {
result.onError("Empty response body")
result.onEmpty()
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
result.onError("Failed to fetch GPX: ${e.message}")
result.onError(classifyOrsError(e))
}
}
}
}

interface RouteGpxResult {
fun onSuccess(gpx: String)
fun onError(message: String)
fun onEmpty()
fun onError(category: OrsErrorCategory)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
Expand Down Expand Up @@ -53,13 +53,17 @@ import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import org.nitri.opentopo.MapFragment
import org.nitri.opentopo.R
import org.nitri.opentopo.analytics.AnalyticsProvider
import org.nitri.opentopo.analytics.resultPositionBucket
import org.nitri.opentopo.model.PlaceSearchResult
import org.nitri.opentopo.ui.theme.OpenTopoTheme
import org.nitri.opentopo.viewmodel.PlaceSearchUiState
import org.nitri.opentopo.viewmodel.PlaceSearchViewModel

class PlaceSearchDialogFragment : DialogFragment() {

private val analytics by lazy { AnalyticsProvider.get(requireContext()) }

private val viewModel: PlaceSearchViewModel by viewModels {
val focusLon = arguments?.getDouble(ARG_FOCUS_LON)
val focusLat = arguments?.getDouble(ARG_FOCUS_LAT)
Expand All @@ -72,10 +76,20 @@ class PlaceSearchDialogFragment : DialogFragment() {
}
},
focusLon = focusLon,
focusLat = focusLat
focusLat = focusLat,
analytics = analytics
)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
analytics.trackOrsSearchOpened(
arguments?.containsKey(ARG_FOCUS_LON) == true && arguments?.containsKey(ARG_FOCUS_LAT) == true
)
}
}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val composeView = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
Expand All @@ -84,7 +98,8 @@ class PlaceSearchDialogFragment : DialogFragment() {
PlaceSearchDialogContent(
viewModel = viewModel,
isInitialLaunch = savedInstanceState == null,
onResultSelected = { result ->
onResultSelected = { result, index, resultCount ->
analytics.trackOrsSearchSelection(resultPositionBucket(index), resultCount.coerceAtMost(10))
setFragmentResult(
REQUEST_KEY,
Bundle().apply {
Expand Down Expand Up @@ -146,7 +161,7 @@ class PlaceSearchDialogFragment : DialogFragment() {
private fun PlaceSearchDialogContent(
viewModel: PlaceSearchViewModel,
isInitialLaunch: Boolean,
onResultSelected: (PlaceSearchResult) -> Unit,
onResultSelected: (PlaceSearchResult, Int, Int) -> Unit,
onDismiss: () -> Unit
) {
val query by viewModel.query.collectAsState()
Expand Down Expand Up @@ -249,14 +264,14 @@ private fun PlaceSearchDialogContent(
.heightIn(max = 280.dp)
.padding(vertical = 8.dp)
) {
items(
itemsIndexed(
items = state.results,
key = { it.stableId }
) { result ->
key = { _, item -> item.stableId }
) { index, result ->
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onResultSelected(result) }
.clickable { onResultSelected(result, index, state.results.size) }
.padding(vertical = 12.dp, horizontal = 4.dp)
) {
Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import org.nitri.opentopo.model.PlaceSearchResult
import org.nitri.opentopo.analytics.AnalyticsTracker
import org.nitri.opentopo.analytics.NoOpAnalyticsTracker
import org.nitri.opentopo.analytics.OrsOutcome
import org.nitri.opentopo.analytics.classifyOrsError
import org.nitri.opentopo.analytics.durationBucket
import org.nitri.opentopo.analytics.queryLengthBucket
import org.nitri.opentopo.ors.mapGeocodeFeaturesToPlaceSearchResults
import org.nitri.ors.OrsClient
import kotlin.coroutines.cancellation.CancellationException
Expand All @@ -34,9 +40,13 @@ sealed interface PlaceSearchUiState {
class PlaceSearchViewModel(
private val clientProvider: () -> OrsClient?,
private val focusLon: Double?,
private val focusLat: Double?
private val focusLat: Double?,
private val analytics: AnalyticsTracker = NoOpAnalyticsTracker,
private val clockMillis: () -> Long = { System.nanoTime() / 1_000_000 }
) : ViewModel() {

private data class SearchRequest(val query: String, val isRetry: Boolean)

private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()

Expand All @@ -52,12 +62,14 @@ class PlaceSearchViewModel(
.debounce { normalizedQuery ->
if (normalizedQuery.length >= 3) 400L else 0L
}
.map { SearchRequest(it, false) }

val retryQuery = _retryTrigger.map { _query.value.trim() }
val retryQuery = _retryTrigger.map { SearchRequest(_query.value.trim(), true) }

merge(debouncedQuery, retryQuery)
.flatMapLatest { normalizedQuery ->
.flatMapLatest { request ->
flow {
val normalizedQuery = request.query
if (normalizedQuery.length < 3) {
emit(PlaceSearchUiState.QueryTooShort)
return@flow
Expand All @@ -71,6 +83,7 @@ class PlaceSearchViewModel(

emit(PlaceSearchUiState.Loading)

val startedAt = clockMillis()
try {
val response = client.geocodeAutocomplete(
text = normalizedQuery,
Expand All @@ -79,6 +92,10 @@ class PlaceSearchViewModel(
size = 10
)
val results = mapGeocodeFeaturesToPlaceSearchResults(response.features)
val outcome = if (results.isEmpty()) OrsOutcome.EMPTY else OrsOutcome.SUCCESS
analytics.trackOrsSearchResult(outcome, results.size.coerceAtMost(10),
durationBucket(clockMillis() - startedAt), queryLengthBucket(normalizedQuery.length),
request.isRetry)
if (results.isEmpty()) {
emit(PlaceSearchUiState.Empty)
} else {
Expand All @@ -87,7 +104,10 @@ class PlaceSearchViewModel(
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
emit(PlaceSearchUiState.Error(e.localizedMessage))
analytics.trackOrsSearchResult(OrsOutcome.ERROR, 0,
durationBucket(clockMillis() - startedAt), queryLengthBucket(normalizedQuery.length),
request.isRetry, classifyOrsError(e))
emit(PlaceSearchUiState.Error(null))
}
}
}
Expand All @@ -108,11 +128,12 @@ class PlaceSearchViewModel(
class Factory(
private val clientProvider: () -> OrsClient?,
private val focusLon: Double?,
private val focusLat: Double?
private val focusLat: Double?,
private val analytics: AnalyticsTracker = NoOpAnalyticsTracker
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PlaceSearchViewModel(clientProvider, focusLon, focusLat) as T
return PlaceSearchViewModel(clientProvider, focusLon, focusLat, analytics) as T
}
}
}
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ This cannot be undone.</string>
<string name="search_min_chars">Enter at least 3 characters</string>
<string name="no_matching_places">No matching places found</string>
<string name="unable_to_search_places">Unable to search places</string>
<string name="unable_to_calculate_route">Unable to calculate route</string>
<string name="retry">Retry</string>

</resources>
Loading
Loading