Skip to content

Commit 0b5b5c7

Browse files
adsamcikCopilot
andcommitted
feat(map): tile provider catalog + DataStore preferences for online mode
Adds the foundation for opt-in online vector tiles (Phase 3 of the online map tiles rollout). No behaviour changes yet — the catalog and preferences are wired but no consumer reads them. * TileProvider sealed interface in :map with OpenFreeMap, Protomaps and Custom variants. Each provider exposes styleUrl(isDark) plus the allowedHosts set the NetworkGateway must accept. * OnlineMapTilesProto (DataStore) + OnlineMapTilesState + repository interface + DataStore-backed default in :spreferences. Defaults are enabled=false, providerId=openfreemap, customUrl=empty — fully opt-out. * Hilt provider for OnlineMapTilesRepository registered in :app's RepositoryModule. Also fixes a pre-existing compile failure in FakeTrackingParamsRepository (missing setVehicleSpeedLimitBaselineMps override) that was blocking spreferences:testDebugUnitTest. Tests: :map TileProviderTest 25/25 green, :spreferences DefaultOnlineMapTilesRepositoryTest 7/7 green, TrackingParamsRepositoryTest 16/16 green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e7ba62e commit 0b5b5c7

9 files changed

Lines changed: 624 additions & 0 deletions

File tree

app/src/main/java/com/adsamcik/tracker/app/di/RepositoryModule.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import com.adsamcik.tracker.game.goals.settings.DefaultGoalsSettingsRepository
77
import com.adsamcik.tracker.game.goals.settings.GoalsSettingsRepository
88
import com.adsamcik.tracker.shared.base.concurrency.DispatchersProvider
99
import com.adsamcik.tracker.shared.preferences.map.DefaultMapSettingsRepository
10+
import com.adsamcik.tracker.shared.preferences.map.DefaultOnlineMapTilesRepository
1011
import com.adsamcik.tracker.shared.preferences.map.MapSettingsRepository
12+
import com.adsamcik.tracker.shared.preferences.map.OnlineMapTilesRepository
1113
import com.adsamcik.tracker.shared.preferences.onboarding.DefaultOnboardingRepository
1214
import com.adsamcik.tracker.shared.preferences.onboarding.OnboardingRepository
1315
import com.adsamcik.tracker.shared.preferences.retention.RetentionConfigStore
@@ -71,6 +73,21 @@ abstract class RepositoryModule {
7173
io = dispatchers.io,
7274
)
7375

76+
/**
77+
* Provides the online-map-tiles preference repository. Defaults are
78+
* fully opt-out (enabled=false, OpenFreeMap as the would-be provider
79+
* when enabled). See `com.adsamcik.tracker.shared.preferences.map.OnlineMapTilesRepository`.
80+
*/
81+
@Provides
82+
@Singleton
83+
fun provideOnlineMapTilesRepository(
84+
@ApplicationContext context: Context,
85+
dispatchers: DispatchersProvider,
86+
): OnlineMapTilesRepository = DefaultOnlineMapTilesRepository(
87+
context = context,
88+
io = dispatchers.io,
89+
)
90+
7491
@Provides
7592
@Singleton
7693
fun provideGoalsSettingsRepository(
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package com.adsamcik.tracker.map.online
2+
3+
import java.net.URI
4+
5+
/**
6+
* A named provider of MapLibre style URLs for online vector tiles.
7+
*
8+
* Each provider serves two URLs (light + dark) returned by [styleUrl]. The
9+
* dark/light selection mirrors the existing offline PMTiles theming.
10+
*
11+
* The [allowedHosts] set must list every host the provider's style.json
12+
* references (style.json + sprite + glyph + tile hosts can all differ).
13+
* These get added to the gateway's NetworkPolicy when the user enables this
14+
* provider.
15+
*
16+
* # Privacy
17+
*
18+
* Online tile providers see the user's IP address (round-trip TCP) and the
19+
* tile coordinates being fetched — i.e. roughly which part of the map is
20+
* being viewed. They do NOT see any other data (no installation id, no
21+
* account, no auth token). Tracker does not append identifying parameters.
22+
*
23+
* The provider catalog is **opt-in** and OFF by default. With no online
24+
* provider selected the app continues to render only the bundled offline
25+
* PMTiles basemap — zero network traffic flows.
26+
*/
27+
sealed interface TileProvider {
28+
/** Stable identifier persisted into preferences. */
29+
val id: String
30+
31+
/** Human-facing name shown in pickers. */
32+
val displayName: String
33+
34+
/** Single-line attribution string rendered next to the map and in settings. */
35+
val attribution: String
36+
37+
/**
38+
* Hosts the gateway must accept for this provider to function. Includes
39+
* the style.json host and every host that style.json's sprite/glyph/tile
40+
* URLs resolve to. Subdomain wildcards are NOT supported — list every
41+
* concrete host.
42+
*/
43+
val allowedHosts: Set<String>
44+
45+
/**
46+
* Returns the MapLibre `style.json` URL for the requested theme. The
47+
* returned URL is passed verbatim to MapLibre's `BaseStyle.Uri`.
48+
*/
49+
fun styleUrl(isDarkTheme: Boolean): String
50+
51+
/**
52+
* OpenFreeMap — free OpenMapTiles-schema vector tiles, no API key, no
53+
* tracking. Preferred default because its terms of service explicitly
54+
* cover free anonymous use.
55+
*/
56+
data object OpenFreeMap : TileProvider {
57+
override val id: String = ID
58+
override val displayName: String = "OpenFreeMap"
59+
override val attribution: String =
60+
"\u00A9 OpenStreetMap contributors, OpenMapTiles, OpenFreeMap"
61+
override val allowedHosts: Set<String> = setOf("tiles.openfreemap.org")
62+
override fun styleUrl(isDarkTheme: Boolean): String = if (isDarkTheme) {
63+
"https://tiles.openfreemap.org/styles/dark"
64+
} else {
65+
"https://tiles.openfreemap.org/styles/liberty"
66+
}
67+
68+
const val ID: String = "openfreemap"
69+
}
70+
71+
/**
72+
* Protomaps API — matches the offline PMTiles schema the app already uses
73+
* for the bundled basemap, so layer behaviour is consistent across modes.
74+
*/
75+
data object Protomaps : TileProvider {
76+
override val id: String = ID
77+
override val displayName: String = "Protomaps"
78+
override val attribution: String =
79+
"\u00A9 OpenStreetMap contributors, Protomaps"
80+
override val allowedHosts: Set<String> = setOf("api.protomaps.com")
81+
override fun styleUrl(isDarkTheme: Boolean): String = if (isDarkTheme) {
82+
"https://api.protomaps.com/styles/v5/dark.json"
83+
} else {
84+
"https://api.protomaps.com/styles/v5/light.json"
85+
}
86+
87+
const val ID: String = "protomaps"
88+
}
89+
90+
/**
91+
* User-supplied style URL. A single URL is used for both light and dark
92+
* themes — the provider URL must support theme switching client-side if
93+
* desired (e.g. a `?theme=` query parameter the user includes themselves).
94+
*
95+
* [allowedHosts] is inferred from [url]; malformed URLs collapse to an
96+
* empty set, which causes the gateway to reject every request from this
97+
* provider (intentional fail-closed behaviour).
98+
*/
99+
data class Custom(val url: String) : TileProvider {
100+
override val id: String = ID
101+
override val displayName: String = "Custom URL"
102+
override val attribution: String = "Custom provider"
103+
override val allowedHosts: Set<String> = inferHost(url)
104+
override fun styleUrl(isDarkTheme: Boolean): String = url
105+
106+
companion object {
107+
const val ID: String = "custom"
108+
109+
private fun inferHost(raw: String): Set<String> {
110+
if (raw.isBlank()) return emptySet()
111+
return runCatching {
112+
val host = URI.create(raw.trim()).host
113+
if (host.isNullOrBlank()) emptySet() else setOf(host.lowercase())
114+
}.getOrDefault(emptySet())
115+
}
116+
}
117+
}
118+
119+
companion object {
120+
/** Provider list shown in the settings picker (does NOT include [Custom]). */
121+
val builtIn: List<TileProvider> = listOf(OpenFreeMap, Protomaps)
122+
123+
/** Provider id used when none has been explicitly chosen. */
124+
const val DEFAULT_ID: String = OpenFreeMap.ID
125+
126+
/**
127+
* Resolve a stored preference (id + optional custom URL) into a
128+
* concrete [TileProvider]. Unknown ids fall back to the default
129+
* built-in provider.
130+
*/
131+
fun resolve(id: String?, customUrl: String = ""): TileProvider = when (id) {
132+
Protomaps.ID -> Protomaps
133+
Custom.ID -> Custom(customUrl)
134+
OpenFreeMap.ID, null, "" -> OpenFreeMap
135+
else -> OpenFreeMap
136+
}
137+
}
138+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package com.adsamcik.tracker.map.online
2+
3+
import io.kotest.matchers.collections.shouldContain
4+
import io.kotest.matchers.collections.shouldHaveSize
5+
import io.kotest.matchers.shouldBe
6+
import io.kotest.matchers.string.shouldContain
7+
import io.kotest.matchers.string.shouldEndWith
8+
import io.kotest.matchers.string.shouldStartWith
9+
import org.junit.jupiter.api.DisplayName
10+
import org.junit.jupiter.api.Nested
11+
import org.junit.jupiter.api.Test
12+
13+
@DisplayName("TileProvider")
14+
class TileProviderTest {
15+
16+
@Nested
17+
@DisplayName("OpenFreeMap")
18+
inner class OpenFreeMapTests {
19+
20+
@Test
21+
fun `light theme returns liberty style`() {
22+
TileProvider.OpenFreeMap.styleUrl(isDarkTheme = false) shouldBe
23+
"https://tiles.openfreemap.org/styles/liberty"
24+
}
25+
26+
@Test
27+
fun `dark theme returns dark style`() {
28+
TileProvider.OpenFreeMap.styleUrl(isDarkTheme = true) shouldBe
29+
"https://tiles.openfreemap.org/styles/dark"
30+
}
31+
32+
@Test
33+
fun `id is stable`() {
34+
TileProvider.OpenFreeMap.id shouldBe "openfreemap"
35+
}
36+
37+
@Test
38+
fun `allowed hosts include tiles subdomain`() {
39+
TileProvider.OpenFreeMap.allowedHosts shouldContain "tiles.openfreemap.org"
40+
}
41+
42+
@Test
43+
fun `attribution mentions OpenStreetMap and OpenFreeMap`() {
44+
val attribution = TileProvider.OpenFreeMap.attribution
45+
attribution shouldContain "OpenStreetMap"
46+
attribution shouldContain "OpenFreeMap"
47+
}
48+
}
49+
50+
@Nested
51+
@DisplayName("Protomaps")
52+
inner class ProtomapsTests {
53+
54+
@Test
55+
fun `light theme returns light style json`() {
56+
TileProvider.Protomaps.styleUrl(isDarkTheme = false) shouldBe
57+
"https://api.protomaps.com/styles/v5/light.json"
58+
}
59+
60+
@Test
61+
fun `dark theme returns dark style json`() {
62+
TileProvider.Protomaps.styleUrl(isDarkTheme = true) shouldBe
63+
"https://api.protomaps.com/styles/v5/dark.json"
64+
}
65+
66+
@Test
67+
fun `id is stable`() {
68+
TileProvider.Protomaps.id shouldBe "protomaps"
69+
}
70+
71+
@Test
72+
fun `allowed hosts include api subdomain`() {
73+
TileProvider.Protomaps.allowedHosts shouldContain "api.protomaps.com"
74+
}
75+
76+
@Test
77+
fun `attribution mentions OpenStreetMap and Protomaps`() {
78+
val attribution = TileProvider.Protomaps.attribution
79+
attribution shouldContain "OpenStreetMap"
80+
attribution shouldContain "Protomaps"
81+
}
82+
}
83+
84+
@Nested
85+
@DisplayName("Custom")
86+
inner class CustomTests {
87+
88+
@Test
89+
fun `id is custom regardless of url`() {
90+
TileProvider.Custom("https://example.com/style.json").id shouldBe "custom"
91+
}
92+
93+
@Test
94+
fun `style url returns the configured url for both themes`() {
95+
val url = "https://maps.example.org/v1/style.json"
96+
val provider = TileProvider.Custom(url)
97+
provider.styleUrl(isDarkTheme = false) shouldBe url
98+
provider.styleUrl(isDarkTheme = true) shouldBe url
99+
}
100+
101+
@Test
102+
fun `allowed hosts inferred from url`() {
103+
TileProvider.Custom("https://Tiles.Example.com/foo/bar.json")
104+
.allowedHosts shouldBe setOf("tiles.example.com")
105+
}
106+
107+
@Test
108+
fun `blank url yields empty allowed hosts`() {
109+
TileProvider.Custom("").allowedHosts shouldHaveSize 0
110+
}
111+
112+
@Test
113+
fun `malformed url yields empty allowed hosts`() {
114+
TileProvider.Custom(":::not a url:::").allowedHosts shouldHaveSize 0
115+
}
116+
117+
@Test
118+
fun `relative url yields empty allowed hosts`() {
119+
TileProvider.Custom("/no/host/here.json").allowedHosts shouldHaveSize 0
120+
}
121+
}
122+
123+
@Nested
124+
@DisplayName("companion")
125+
inner class CompanionTests {
126+
127+
@Test
128+
fun `built-in list exposes OpenFreeMap then Protomaps`() {
129+
TileProvider.builtIn shouldBe listOf(
130+
TileProvider.OpenFreeMap,
131+
TileProvider.Protomaps,
132+
)
133+
}
134+
135+
@Test
136+
fun `built-in list does NOT include Custom`() {
137+
TileProvider.builtIn.map { it.id } shouldBe listOf("openfreemap", "protomaps")
138+
}
139+
140+
@Test
141+
fun `DEFAULT_ID matches OpenFreeMap`() {
142+
TileProvider.DEFAULT_ID shouldBe TileProvider.OpenFreeMap.id
143+
}
144+
145+
@Test
146+
fun `resolve maps known ids to providers`() {
147+
TileProvider.resolve("openfreemap") shouldBe TileProvider.OpenFreeMap
148+
TileProvider.resolve("protomaps") shouldBe TileProvider.Protomaps
149+
}
150+
151+
@Test
152+
fun `resolve maps custom id with url`() {
153+
val resolved = TileProvider.resolve("custom", "https://example.com/s.json")
154+
(resolved is TileProvider.Custom) shouldBe true
155+
(resolved as TileProvider.Custom).url shouldBe "https://example.com/s.json"
156+
}
157+
158+
@Test
159+
fun `resolve falls back to default for unknown ids`() {
160+
TileProvider.resolve("does-not-exist") shouldBe TileProvider.OpenFreeMap
161+
TileProvider.resolve(null) shouldBe TileProvider.OpenFreeMap
162+
TileProvider.resolve("") shouldBe TileProvider.OpenFreeMap
163+
}
164+
}
165+
166+
@Test
167+
fun `all built-in style URLs are HTTPS`() {
168+
TileProvider.builtIn.forEach { provider ->
169+
provider.styleUrl(isDarkTheme = false).shouldStartWith("https://")
170+
provider.styleUrl(isDarkTheme = true).shouldStartWith("https://")
171+
}
172+
}
173+
174+
@Test
175+
fun `OpenFreeMap dark and light URLs differ`() {
176+
val light = TileProvider.OpenFreeMap.styleUrl(isDarkTheme = false)
177+
val dark = TileProvider.OpenFreeMap.styleUrl(isDarkTheme = true)
178+
(light == dark) shouldBe false
179+
}
180+
181+
@Test
182+
fun `Protomaps URL endings are sane`() {
183+
TileProvider.Protomaps.styleUrl(isDarkTheme = false).shouldEndWith(".json")
184+
TileProvider.Protomaps.styleUrl(isDarkTheme = true).shouldEndWith(".json")
185+
}
186+
}

0 commit comments

Comments
 (0)