Skip to content

Commit 506b23a

Browse files
committed
Merge remote branch “origin/master”
2 parents eb88197 + 05489d6 commit 506b23a

18 files changed

Lines changed: 282 additions & 45 deletions

File tree

‎app/build.gradle‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@ android {
5757
productFlavors {
5858
foss {
5959
dimension = "distribution"
60+
buildConfigField "boolean", "SHOW_DONATE_LINK", "true"
6061
// Configuration for FOSS (GitHub, F-Droid)
6162
// No proprietary services, no ads
6263
}
6364
play {
6465
dimension = "distribution"
66+
buildConfigField "boolean", "SHOW_DONATE_LINK", "false"
6567
// Configuration for Google Play
6668
// Google Services, Admob
6769
}

‎app/src/main/java/org/nitri/opentopo/MapFragment.kt‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ import org.nitri.opentopo.overlay.GestureOverlay
5454
import org.nitri.opentopo.overlay.GestureOverlay.GestureCallback
5555
import org.nitri.opentopo.overlay.OverlayHelper
5656
import org.nitri.opentopo.analytics.AnalyticsProvider
57+
import org.nitri.opentopo.analytics.OrsOutcome
58+
import org.nitri.opentopo.analytics.OrsStartSource
59+
import org.nitri.opentopo.analytics.durationBucket
5760
import org.nitri.opentopo.util.MapOrientation
5861
import org.nitri.opentopo.util.OrientationSensor
5962
import org.nitri.opentopo.util.Utils
@@ -585,14 +588,14 @@ class MapFragment : Fragment(), LocationListener, PopupMenu.OnMenuItemClickListe
585588
val language = locale.language.lowercase()
586589
listener?.getOpenRouteServiceClient()?.let { client ->
587590
val directions = Directions(client, profile)
591+
val destinationCount = markers.size
592+
val startSource = if (currentLocation != null) OrsStartSource.CURRENT_LOCATION else OrsStartSource.MARKER_ONLY
593+
val startedAt = System.nanoTime() / 1_000_000
594+
val analytics = AnalyticsProvider.get(requireContext())
588595
directions.getRouteGpx(coordinates, language, object : Directions.RouteGpxResult {
589596
override fun onSuccess(gpx: String) {
590-
Log.d(TAG, "GPX: $gpx")
591-
// Analytics: route calculation succeeded (Play flavor reports it; FOSS no-ops)
592-
AnalyticsProvider.get(requireContext()).trackRouteCalculated(
593-
profile = profile,
594-
waypointCount = coordinates.size
595-
)
597+
Log.d(TAG, "ORS route GPX received (${gpx.length} chars)")
598+
trackRouteResult(OrsOutcome.SUCCESS)
596599
gpxViewModel?.markerCoordinates = markers
597600
gpxViewModel?.orsProfile = profile
598601

@@ -607,16 +610,26 @@ class MapFragment : Fragment(), LocationListener, PopupMenu.OnMenuItemClickListe
607610
}
608611
}
609612

610-
override fun onError(message: String) {
611-
Log.e(TAG, "Error fetching GPX: $message")
613+
override fun onEmpty() {
614+
trackRouteResult(OrsOutcome.EMPTY)
612615
context?.let { ctx ->
613616
Toast.makeText(
614617
ctx,
615-
"Error fetching GPX: $message",
618+
R.string.unable_to_calculate_route,
616619
Toast.LENGTH_LONG
617620
).show()
618621
}
619622
}
623+
624+
override fun onError(category: org.nitri.opentopo.analytics.OrsErrorCategory) {
625+
trackRouteResult(OrsOutcome.ERROR, category)
626+
context?.let { Toast.makeText(it, R.string.unable_to_calculate_route, Toast.LENGTH_LONG).show() }
627+
}
628+
629+
private fun trackRouteResult(outcome: OrsOutcome, category: org.nitri.opentopo.analytics.OrsErrorCategory? = null) {
630+
analytics.trackOrsRouteResult(outcome, profile, destinationCount,
631+
startSource, durationBucket(System.nanoTime() / 1_000_000 - startedAt), category)
632+
}
620633
}
621634
)
622635
}

‎app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,17 @@ interface AnalyticsTracker {
2121
fileName: String?
2222
)
2323

24-
fun trackRouteCalculated(
25-
profile: String?,
26-
waypointCount: Int
27-
)
24+
fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int,
25+
startSource: OrsStartSource, durationBucket: DurationBucket,
26+
errorCategory: OrsErrorCategory? = null)
27+
28+
fun trackOrsSearchOpened(hasMapFocus: Boolean)
29+
30+
fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int,
31+
durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket,
32+
isRetry: Boolean, errorCategory: OrsErrorCategory? = null)
33+
34+
fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int)
2835

2936
fun trackMapLayerSelected(baseMap: String, overlay: String)
3037

@@ -40,7 +47,10 @@ object NoOpAnalyticsTracker : AnalyticsTracker {
4047
override fun trackScreen(screenName: String, screenClass: String) { /* no-op */ }
4148
override fun trackGpxLoaded(source: String, gpx: Gpx, fileName: String?) { /* no-op */ }
4249
override fun trackKmlLoaded(source: String, contentType: String, fileName: String?) { /* no-op */ }
43-
override fun trackRouteCalculated(profile: String?, waypointCount: Int) { /* no-op */ }
50+
override fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int, startSource: OrsStartSource, durationBucket: DurationBucket, errorCategory: OrsErrorCategory?) { /* no-op */ }
51+
override fun trackOrsSearchOpened(hasMapFocus: Boolean) { /* no-op */ }
52+
override fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int, durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket, isRetry: Boolean, errorCategory: OrsErrorCategory?) { /* no-op */ }
53+
override fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int) { /* no-op */ }
4454
override fun trackMapLayerSelected(baseMap: String, overlay: String) { /* no-op */ }
4555
override fun trackMarkersImported(importedCount: Int, skippedCount: Int) { /* no-op */ }
4656
override fun trackMarkersExported(markerCount: Int) { /* no-op */ }
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package org.nitri.opentopo.analytics
2+
3+
import com.google.gson.JsonParseException
4+
import retrofit2.HttpException
5+
import java.io.IOException
6+
import java.net.SocketTimeoutException
7+
8+
enum class OrsOutcome(val value: String) { SUCCESS("success"), EMPTY("empty"), ERROR("error") }
9+
enum class OrsStartSource(val value: String) { CURRENT_LOCATION("current_location"), MARKER_ONLY("marker_only") }
10+
enum class DurationBucket(val value: String) { UNDER_1S("under_1s"), ONE_TO_THREE_SECONDS("1_3s"), OVER_3S("over_3s") }
11+
enum class QueryLengthBucket(val value: String) { THREE_TO_FIVE("3_5"), SIX_TO_TEN("6_10"), OVER_TEN("over_10") }
12+
enum class ResultPositionBucket(val value: String) { FIRST("1"), SECOND_OR_THIRD("2_3"), FOURTH_OR_LATER("4_plus") }
13+
enum class OrsErrorCategory(val value: String) {
14+
NETWORK("network"), TIMEOUT("timeout"), AUTH("auth"), RATE_LIMIT("rate_limit"),
15+
SERVER("server"), INVALID_RESPONSE("invalid_response"), UNKNOWN("unknown")
16+
}
17+
18+
fun durationBucket(durationMillis: Long) = when {
19+
durationMillis < 1_000 -> DurationBucket.UNDER_1S
20+
durationMillis <= 3_000 -> DurationBucket.ONE_TO_THREE_SECONDS
21+
else -> DurationBucket.OVER_3S
22+
}
23+
24+
fun queryLengthBucket(length: Int) = when (length) {
25+
in 3..5 -> QueryLengthBucket.THREE_TO_FIVE
26+
in 6..10 -> QueryLengthBucket.SIX_TO_TEN
27+
else -> QueryLengthBucket.OVER_TEN
28+
}
29+
30+
/** [zeroBasedIndex] is intentionally positional; result data never enters analytics. */
31+
fun resultPositionBucket(zeroBasedIndex: Int) = when (zeroBasedIndex) {
32+
0 -> ResultPositionBucket.FIRST
33+
1, 2 -> ResultPositionBucket.SECOND_OR_THIRD
34+
else -> ResultPositionBucket.FOURTH_OR_LATER
35+
}
36+
37+
fun classifyOrsError(error: Throwable): OrsErrorCategory = when (error) {
38+
is SocketTimeoutException -> OrsErrorCategory.TIMEOUT
39+
is HttpException -> classifyOrsHttpStatus(error.code())
40+
is JsonParseException -> OrsErrorCategory.INVALID_RESPONSE
41+
is IOException -> OrsErrorCategory.NETWORK
42+
else -> OrsErrorCategory.UNKNOWN
43+
}
44+
45+
fun classifyOrsHttpStatus(status: Int): OrsErrorCategory = when (status) {
46+
401, 403 -> OrsErrorCategory.AUTH
47+
429 -> OrsErrorCategory.RATE_LIMIT
48+
in 500..599 -> OrsErrorCategory.SERVER
49+
else -> OrsErrorCategory.UNKNOWN
50+
}

‎app/src/main/java/org/nitri/opentopo/nearby/NearbyFragment.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.nitri.opentopo.nearby
22

33
import android.annotation.SuppressLint
4+
import android.content.ActivityNotFoundException
45
import android.content.Context
56
import android.content.Intent
67
import android.net.Uri
@@ -10,6 +11,7 @@ import android.view.Menu
1011
import android.view.MenuInflater
1112
import android.view.View
1213
import android.view.ViewGroup
14+
import android.widget.Toast
1315
import androidx.fragment.app.Fragment
1416
import androidx.lifecycle.Observer
1517
import androidx.lifecycle.ViewModelProvider
@@ -143,7 +145,11 @@ class NearbyFragment : Fragment(), NearbyAdapter.OnItemClickListener {
143145
override fun onItemClick(index: Int) {
144146
val uri = mNearbyItems[index]?.url?.let { Uri.parse(it) } ?: Uri.EMPTY
145147
val browserIntent = Intent(Intent.ACTION_VIEW, uri)
146-
startActivity(browserIntent)
148+
try {
149+
startActivity(browserIntent)
150+
} catch (e: ActivityNotFoundException) {
151+
Toast.makeText(requireContext(), R.string.no_browser_found, Toast.LENGTH_SHORT).show()
152+
}
147153
}
148154

149155
override fun onMapItemClick(index: Int) {

‎app/src/main/java/org/nitri/opentopo/ors/Directions.kt‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import kotlinx.coroutines.launch
66
import kotlinx.coroutines.withContext
77
import org.nitri.ors.OrsClient
88
import org.nitri.ors.helper.RouteHelper
9+
import org.nitri.opentopo.analytics.OrsErrorCategory
10+
import org.nitri.opentopo.analytics.classifyOrsError
911

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

@@ -19,19 +21,20 @@ class Directions(val client: OrsClient, private val profile: String) {
1921
if (gpxXml.isNotBlank()) {
2022
result.onSuccess(gpxXml)
2123
} else {
22-
result.onError("Empty response body")
24+
result.onEmpty()
2325
}
2426
}
2527
} catch (e: Exception) {
2628
withContext(Dispatchers.Main) {
27-
result.onError("Failed to fetch GPX: ${e.message}")
29+
result.onError(classifyOrsError(e))
2830
}
2931
}
3032
}
3133
}
3234

3335
interface RouteGpxResult {
3436
fun onSuccess(gpx: String)
35-
fun onError(message: String)
37+
fun onEmpty()
38+
fun onError(category: OrsErrorCategory)
3639
}
37-
}
40+
}

‎app/src/main/java/org/nitri/opentopo/view/AboutDialog.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import androidx.lifecycle.setViewTreeLifecycleOwner
3030
import androidx.lifecycle.setViewTreeViewModelStoreOwner
3131
import androidx.savedstate.SavedStateRegistryOwner
3232
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
33+
import org.nitri.opentopo.BuildConfig
3334
import org.nitri.opentopo.R
3435
import org.nitri.opentopo.ui.theme.OpenTopoTheme
3536
import org.nitri.opentopo.util.Utils
@@ -127,6 +128,12 @@ fun AboutDialogContent() {
127128
html = stringResource(R.string.app_product_page),
128129
modifier = Modifier.padding(top = 16.dp)
129130
)
131+
if (BuildConfig.SHOW_DONATE_LINK) {
132+
HtmlText(
133+
html = stringResource(R.string.donate_kofi),
134+
modifier = Modifier.padding(top = 8.dp)
135+
)
136+
}
130137
HtmlText(
131138
html = stringResource(R.string.issue_tracker),
132139
modifier = Modifier.padding(top = 8.dp)

‎app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import androidx.compose.foundation.layout.heightIn
1616
import androidx.compose.foundation.layout.padding
1717
import androidx.compose.foundation.layout.widthIn
1818
import androidx.compose.foundation.lazy.LazyColumn
19-
import androidx.compose.foundation.lazy.items
19+
import androidx.compose.foundation.lazy.itemsIndexed
2020
import androidx.compose.foundation.text.KeyboardActions
2121
import androidx.compose.foundation.text.KeyboardOptions
2222
import androidx.compose.material.icons.Icons
@@ -53,13 +53,17 @@ import androidx.lifecycle.setViewTreeViewModelStoreOwner
5353
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
5454
import org.nitri.opentopo.MapFragment
5555
import org.nitri.opentopo.R
56+
import org.nitri.opentopo.analytics.AnalyticsProvider
57+
import org.nitri.opentopo.analytics.resultPositionBucket
5658
import org.nitri.opentopo.model.PlaceSearchResult
5759
import org.nitri.opentopo.ui.theme.OpenTopoTheme
5860
import org.nitri.opentopo.viewmodel.PlaceSearchUiState
5961
import org.nitri.opentopo.viewmodel.PlaceSearchViewModel
6062

6163
class PlaceSearchDialogFragment : DialogFragment() {
6264

65+
private val analytics by lazy { AnalyticsProvider.get(requireContext()) }
66+
6367
private val viewModel: PlaceSearchViewModel by viewModels {
6468
val focusLon = arguments?.getDouble(ARG_FOCUS_LON)
6569
val focusLat = arguments?.getDouble(ARG_FOCUS_LAT)
@@ -72,10 +76,20 @@ class PlaceSearchDialogFragment : DialogFragment() {
7276
}
7377
},
7478
focusLon = focusLon,
75-
focusLat = focusLat
79+
focusLat = focusLat,
80+
analytics = analytics
7681
)
7782
}
7883

84+
override fun onCreate(savedInstanceState: Bundle?) {
85+
super.onCreate(savedInstanceState)
86+
if (savedInstanceState == null) {
87+
analytics.trackOrsSearchOpened(
88+
arguments?.containsKey(ARG_FOCUS_LON) == true && arguments?.containsKey(ARG_FOCUS_LAT) == true
89+
)
90+
}
91+
}
92+
7993
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
8094
val composeView = ComposeView(requireContext()).apply {
8195
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
@@ -84,7 +98,8 @@ class PlaceSearchDialogFragment : DialogFragment() {
8498
PlaceSearchDialogContent(
8599
viewModel = viewModel,
86100
isInitialLaunch = savedInstanceState == null,
87-
onResultSelected = { result ->
101+
onResultSelected = { result, index, resultCount ->
102+
analytics.trackOrsSearchSelection(resultPositionBucket(index), resultCount.coerceAtMost(10))
88103
setFragmentResult(
89104
REQUEST_KEY,
90105
Bundle().apply {
@@ -146,7 +161,7 @@ class PlaceSearchDialogFragment : DialogFragment() {
146161
private fun PlaceSearchDialogContent(
147162
viewModel: PlaceSearchViewModel,
148163
isInitialLaunch: Boolean,
149-
onResultSelected: (PlaceSearchResult) -> Unit,
164+
onResultSelected: (PlaceSearchResult, Int, Int) -> Unit,
150165
onDismiss: () -> Unit
151166
) {
152167
val query by viewModel.query.collectAsState()
@@ -249,14 +264,14 @@ private fun PlaceSearchDialogContent(
249264
.heightIn(max = 280.dp)
250265
.padding(vertical = 8.dp)
251266
) {
252-
items(
267+
itemsIndexed(
253268
items = state.results,
254-
key = { it.stableId }
255-
) { result ->
269+
key = { _, item -> item.stableId }
270+
) { index, result ->
256271
Column(
257272
modifier = Modifier
258273
.fillMaxWidth()
259-
.clickable { onResultSelected(result) }
274+
.clickable { onResultSelected(result, index, state.results.size) }
260275
.padding(vertical = 12.dp, horizontal = 4.dp)
261276
) {
262277
Text(

0 commit comments

Comments
 (0)