diff --git a/app/src/main/java/org/nitri/opentopo/MapFragment.kt b/app/src/main/java/org/nitri/opentopo/MapFragment.kt
index 187cf530..9925f3df 100644
--- a/app/src/main/java/org/nitri/opentopo/MapFragment.kt
+++ b/app/src/main/java/org/nitri/opentopo/MapFragment.kt
@@ -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
@@ -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
@@ -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)
+ }
}
)
}
diff --git a/app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt b/app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt
index 186e944b..eac5f5a8 100644
--- a/app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt
+++ b/app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt
@@ -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)
@@ -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 */ }
diff --git a/app/src/main/java/org/nitri/opentopo/analytics/OrsAnalytics.kt b/app/src/main/java/org/nitri/opentopo/analytics/OrsAnalytics.kt
new file mode 100644
index 00000000..85de5a2f
--- /dev/null
+++ b/app/src/main/java/org/nitri/opentopo/analytics/OrsAnalytics.kt
@@ -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
+}
diff --git a/app/src/main/java/org/nitri/opentopo/ors/Directions.kt b/app/src/main/java/org/nitri/opentopo/ors/Directions.kt
index 9ec113a5..27a365a0 100644
--- a/app/src/main/java/org/nitri/opentopo/ors/Directions.kt
+++ b/app/src/main/java/org/nitri/opentopo/ors/Directions.kt
@@ -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) {
@@ -19,12 +21,12 @@ 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))
}
}
}
@@ -32,6 +34,7 @@ class Directions(val client: OrsClient, private val profile: String) {
interface RouteGpxResult {
fun onSuccess(gpx: String)
- fun onError(message: String)
+ fun onEmpty()
+ fun onError(category: OrsErrorCategory)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt b/app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt
index a623cd6f..a8fe5595 100644
--- a/app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt
+++ b/app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt
@@ -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
@@ -53,6 +53,8 @@ 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
@@ -60,6 +62,8 @@ 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)
@@ -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)
@@ -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 {
@@ -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()
@@ -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(
diff --git a/app/src/main/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModel.kt b/app/src/main/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModel.kt
index 51b36c8b..cab721ff 100644
--- a/app/src/main/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModel.kt
+++ b/app/src/main/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModel.kt
@@ -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
@@ -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 = _query.asStateFlow()
@@ -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
@@ -71,6 +83,7 @@ class PlaceSearchViewModel(
emit(PlaceSearchUiState.Loading)
+ val startedAt = clockMillis()
try {
val response = client.geocodeAutocomplete(
text = normalizedQuery,
@@ -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 {
@@ -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))
}
}
}
@@ -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 create(modelClass: Class): T {
- return PlaceSearchViewModel(clientProvider, focusLon, focusLat) as T
+ return PlaceSearchViewModel(clientProvider, focusLon, focusLat, analytics) as T
}
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d1e029b4..0ff0126b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -185,6 +185,7 @@ This cannot be undone.
Enter at least 3 characters
No matching places found
Unable to search places
+ Unable to calculate route
Retry
diff --git a/app/src/play/java/org/nitri/opentopo/analytics/FirebaseAnalyticsTracker.kt b/app/src/play/java/org/nitri/opentopo/analytics/FirebaseAnalyticsTracker.kt
index ef95e1fc..27c257ab 100644
--- a/app/src/play/java/org/nitri/opentopo/analytics/FirebaseAnalyticsTracker.kt
+++ b/app/src/play/java/org/nitri/opentopo/analytics/FirebaseAnalyticsTracker.kt
@@ -7,7 +7,10 @@ import io.ticofab.androidgpxparser.parser.domain.Gpx
private const val EVENT_GPX_LOADED = "gpx_loaded"
private const val EVENT_KML_LOADED = "kml_loaded"
-private const val EVENT_ROUTE_CALCULATED = "route_calculated"
+private const val EVENT_ORS_ROUTE_RESULT = "ors_route_result"
+private const val EVENT_ORS_SEARCH_OPENED = "ors_search_opened"
+private const val EVENT_ORS_SEARCH_RESULT = "ors_search_result"
+private const val EVENT_ORS_SEARCH_SELECTION = "ors_search_selection"
private const val EVENT_MAP_LAYER_SELECTED = "map_layer_selected"
private const val EVENT_MARKERS_IMPORTED = "markers_imported"
private const val EVENT_MARKERS_EXPORTED = "markers_exported"
@@ -28,7 +31,6 @@ class FirebaseAnalyticsTracker(context: Context) : AnalyticsTracker {
override fun trackGpxLoaded(source: String, gpx: Gpx, fileName: String?) {
val params = Bundle().apply {
putString("source", source)
- fileName?.let { putString("file_name", it) }
putInt("track_count", gpx.tracks?.size ?: 0)
putInt("route_count", gpx.routes?.size ?: 0)
putInt("waypoint_count", gpx.wayPoints?.size ?: 0)
@@ -40,17 +42,45 @@ class FirebaseAnalyticsTracker(context: Context) : AnalyticsTracker {
val params = Bundle().apply {
putString("source", source)
putString(FirebaseAnalytics.Param.CONTENT_TYPE, contentType)
- fileName?.let { putString("file_name", it) }
}
firebase.logEvent(EVENT_KML_LOADED, params)
}
- override fun trackRouteCalculated(profile: String?, waypointCount: Int) {
+ override fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int,
+ startSource: OrsStartSource, durationBucket: DurationBucket, errorCategory: OrsErrorCategory?) {
val params = Bundle().apply {
- profile?.let { putString("profile", it) }
- putInt("waypoint_count", waypointCount)
+ putString("outcome", outcome.value)
+ putString("profile", profile)
+ putInt("destination_count", destinationCount)
+ putString("start_source", startSource.value)
+ putString("duration_bucket", durationBucket.value)
+ if (outcome == OrsOutcome.ERROR) errorCategory?.let { putString("error_category", it.value) }
}
- firebase.logEvent(EVENT_ROUTE_CALCULATED, params)
+ firebase.logEvent(EVENT_ORS_ROUTE_RESULT, params)
+ }
+
+ override fun trackOrsSearchOpened(hasMapFocus: Boolean) {
+ firebase.logEvent(EVENT_ORS_SEARCH_OPENED, Bundle().apply { putBoolean("has_map_focus", hasMapFocus) })
+ }
+
+ override fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int,
+ durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket,
+ isRetry: Boolean, errorCategory: OrsErrorCategory?) {
+ firebase.logEvent(EVENT_ORS_SEARCH_RESULT, Bundle().apply {
+ putString("outcome", outcome.value)
+ putInt("result_count", resultCount.coerceIn(0, 10))
+ putString("duration_bucket", durationBucket.value)
+ putString("query_length_bucket", queryLengthBucket.value)
+ putBoolean("is_retry", isRetry)
+ if (outcome == OrsOutcome.ERROR) errorCategory?.let { putString("error_category", it.value) }
+ })
+ }
+
+ override fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int) {
+ firebase.logEvent(EVENT_ORS_SEARCH_SELECTION, Bundle().apply {
+ putString("result_position", resultPosition.value)
+ putInt("result_count", resultCount.coerceIn(0, 10))
+ })
}
override fun trackMapLayerSelected(baseMap: String, overlay: String) {
diff --git a/app/src/test/java/org/nitri/opentopo/analytics/OrsAnalyticsTest.kt b/app/src/test/java/org/nitri/opentopo/analytics/OrsAnalyticsTest.kt
new file mode 100644
index 00000000..51e2cccc
--- /dev/null
+++ b/app/src/test/java/org/nitri/opentopo/analytics/OrsAnalyticsTest.kt
@@ -0,0 +1,43 @@
+package org.nitri.opentopo.analytics
+
+import com.google.gson.JsonSyntaxException
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.IOException
+import java.net.SocketTimeoutException
+
+class OrsAnalyticsTest {
+ @Test fun durationBoundaries() {
+ assertEquals(DurationBucket.UNDER_1S, durationBucket(999))
+ assertEquals(DurationBucket.ONE_TO_THREE_SECONDS, durationBucket(1_000))
+ assertEquals(DurationBucket.ONE_TO_THREE_SECONDS, durationBucket(3_000))
+ assertEquals(DurationBucket.OVER_3S, durationBucket(3_001))
+ }
+
+ @Test fun queryLengthBoundaries() {
+ assertEquals(QueryLengthBucket.THREE_TO_FIVE, queryLengthBucket(3))
+ assertEquals(QueryLengthBucket.THREE_TO_FIVE, queryLengthBucket(5))
+ assertEquals(QueryLengthBucket.SIX_TO_TEN, queryLengthBucket(6))
+ assertEquals(QueryLengthBucket.SIX_TO_TEN, queryLengthBucket(10))
+ assertEquals(QueryLengthBucket.OVER_TEN, queryLengthBucket(11))
+ }
+
+ @Test fun resultPositionBoundaries() {
+ assertEquals(ResultPositionBucket.FIRST, resultPositionBucket(0))
+ assertEquals(ResultPositionBucket.SECOND_OR_THIRD, resultPositionBucket(1))
+ assertEquals(ResultPositionBucket.SECOND_OR_THIRD, resultPositionBucket(2))
+ assertEquals(ResultPositionBucket.FOURTH_OR_LATER, resultPositionBucket(3))
+ }
+
+ @Test fun errorsAreNormalizedByTypeAndStatus() {
+ assertEquals(OrsErrorCategory.TIMEOUT, classifyOrsError(SocketTimeoutException()))
+ assertEquals(OrsErrorCategory.NETWORK, classifyOrsError(IOException()))
+ assertEquals(OrsErrorCategory.INVALID_RESPONSE, classifyOrsError(JsonSyntaxException("bad")))
+ assertEquals(OrsErrorCategory.AUTH, classifyOrsHttpStatus(401))
+ assertEquals(OrsErrorCategory.AUTH, classifyOrsHttpStatus(403))
+ assertEquals(OrsErrorCategory.RATE_LIMIT, classifyOrsHttpStatus(429))
+ assertEquals(OrsErrorCategory.SERVER, classifyOrsHttpStatus(503))
+ assertEquals(OrsErrorCategory.UNKNOWN, classifyOrsHttpStatus(400))
+ assertEquals(OrsErrorCategory.UNKNOWN, classifyOrsError(IllegalStateException("private")))
+ }
+}
diff --git a/app/src/test/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModelTest.kt b/app/src/test/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModelTest.kt
index bdf78001..243ef4e9 100644
--- a/app/src/test/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModelTest.kt
+++ b/app/src/test/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModelTest.kt
@@ -41,10 +41,31 @@ import org.nitri.ors.domain.route.RouteResponse
import org.nitri.ors.domain.snap.SnapGeoJsonResponse
import org.nitri.ors.domain.snap.SnapRequest
import org.nitri.ors.domain.snap.SnapResponse
+import io.ticofab.androidgpxparser.parser.domain.Gpx
+import org.nitri.opentopo.analytics.*
+import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class PlaceSearchViewModelTest {
+ private data class SearchEvent(val outcome: OrsOutcome, val count: Int, val retry: Boolean, val error: OrsErrorCategory?)
+ private class RecordingTracker : AnalyticsTracker {
+ val searchEvents = mutableListOf()
+ override fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int, durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket, isRetry: Boolean, errorCategory: OrsErrorCategory?) {
+ searchEvents += SearchEvent(outcome, resultCount, isRetry, errorCategory)
+ }
+ override fun trackScreen(screenName: String, screenClass: String) = Unit
+ override fun trackGpxLoaded(source: String, gpx: Gpx, fileName: String?) = Unit
+ override fun trackKmlLoaded(source: String, contentType: String, fileName: String?) = Unit
+ override fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int, startSource: OrsStartSource, durationBucket: DurationBucket, errorCategory: OrsErrorCategory?) = Unit
+ override fun trackOrsSearchOpened(hasMapFocus: Boolean) = Unit
+ override fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int) = Unit
+ override fun trackMapLayerSelected(baseMap: String, overlay: String) = Unit
+ override fun trackMarkersImported(importedCount: Int, skippedCount: Int) = Unit
+ override fun trackMarkersExported(markerCount: Int) = Unit
+ override fun trackMarkersDeleted(markerCount: Int) = Unit
+ }
+
private val testDispatcher = StandardTestDispatcher()
@Before
@@ -164,6 +185,7 @@ class PlaceSearchViewModelTest {
@Test
fun runningRequestIsCancelledByNewQuery() = runTest {
var queryACancelled = false
+ val tracker = RecordingTracker()
val fakeClient = FakeOrsClient { text ->
if (text == "QueryA") {
@@ -184,7 +206,7 @@ class PlaceSearchViewModelTest {
}
}
- val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0)
+ val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0, tracker)
viewModel.setQuery("QueryA")
advanceTimeBy(400)
@@ -201,6 +223,7 @@ class PlaceSearchViewModelTest {
val results = (state as PlaceSearchUiState.Success).results
assertEquals(1, results.size)
assertEquals("ResultB", results[0].name)
+ assertEquals(listOf(OrsOutcome.SUCCESS), tracker.searchEvents.map { it.outcome })
}
@Test
@@ -242,34 +265,40 @@ class PlaceSearchViewModelTest {
@Test
fun emptyResponse_producesEmptyState() = runTest {
+ val tracker = RecordingTracker()
val fakeClient = FakeOrsClient {
GeocodeSearchResponse(features = emptyList())
}
- val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0)
+ val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0, tracker)
viewModel.setQuery("NonExistentPlace12345")
advanceUntilIdle()
assertEquals(1, fakeClient.callCount)
assertEquals(PlaceSearchUiState.Empty, viewModel.uiState.value)
+ assertEquals(SearchEvent(OrsOutcome.EMPTY, 0, false, null), tracker.searchEvents.single())
}
@Test
fun serviceFailure_producesErrorState() = runTest {
+ val tracker = RecordingTracker()
val fakeClient = FakeOrsClient {
- throw RuntimeException("Network error")
+ throw IOException("private raw message")
}
- val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0)
+ val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0, tracker)
viewModel.setQuery("Kleve")
advanceUntilIdle()
assertEquals(1, fakeClient.callCount)
assertTrue(viewModel.uiState.value is PlaceSearchUiState.Error)
+ assertEquals(SearchEvent(OrsOutcome.ERROR, 0, false, OrsErrorCategory.NETWORK), tracker.searchEvents.single())
+ assertEquals(null, (viewModel.uiState.value as PlaceSearchUiState.Error).message)
}
@Test
fun retry_reissuesRequestWithoutModifyingQueryText() = runTest {
+ val tracker = RecordingTracker()
var shouldFail = true
val fakeClient = FakeOrsClient { text ->
if (shouldFail) {
@@ -286,7 +315,7 @@ class PlaceSearchViewModelTest {
}
}
- val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0)
+ val viewModel = PlaceSearchViewModel({ fakeClient }, 6.0, 51.0, tracker)
viewModel.setQuery("Kleve")
advanceTimeBy(400)
@@ -305,5 +334,6 @@ class PlaceSearchViewModelTest {
assertTrue(viewModel.uiState.value is PlaceSearchUiState.Success)
val success = viewModel.uiState.value as PlaceSearchUiState.Success
assertEquals("Kleve", success.results[0].name)
+ assertEquals(listOf(false, true), tracker.searchEvents.map { it.retry })
}
}