Skip to content

Commit 32660bf

Browse files
pengdevgithub-actions[bot]
authored andcommitted
[compose] Expose experimental attribution state. (#8284)
Fix https://mapbox.atlassian.net/browse/MAPSAND-1773 This PR introduces a low-level, UI-less `AttributionControl` composable function. The function exposes the newly centralized `AttributionState`, which encapsulates: * The list of necessary map attributions. * The Mapbox Telemetry opt-in state. * The geofencing consent state. # Rationale & Compliance This change allows developers to take full control over building the attribution user experience (UX) within their application. This is crucial for maintaining compliance with Mapbox's public documentation on Telemetry: > "The default attribution control includes an opt out button. If you hide the [attribution control](https://docs.mapbox.com/help/dive-deeper/attribution/), you must provide an alternative opt out method your users can use. You are responsible for allowing your users to opt out of Mapbox Telemetry." By providing access to the raw `AttributionState`, we ensure that users who opt to build a custom UI can easily access and implement the necessary Telemetry opt-out and geofencing consent mechanisms, satisfying the legal requirement. This PR also adds an example to showcase the Attribution Customisation: cc @mapbox/maps-android cc @mapbox/sdk-ci GitOrigin-RevId: 79eeb4515577432782d464d3e02789aedc67a249
1 parent 3fd3582 commit 32660bf

File tree

9 files changed

+888
-118
lines changed

9 files changed

+888
-118
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ Mapbox welcomes participation and contributions from everyone.
99
## Breaking changes ⚠️
1010
* Remove line-cutout-width and change line-cutout-opacity default to 1.0
1111

12+
## Features ✨ and improvements 🏁
1213
* Add Standard Style color and 3D configuration options: `colorBuildings`, `colorCommercial`, `colorEducation`, `colorIndustrial`, `colorLand`, `colorMedical`, `colorSnow`, `show3dBuildings`, `show3dFacades`, `show3dLandmarks`, and `show3dTrees`.
14+
* Introduce experimental `AttributionControl` composable function that exposes `AttributionState` programmatically, enabling developers to build custom Attribution UI outside of the map while maintaining compliance with [Mapbox ToS](https://www.mapbox.com/legal/tos) requirements.
15+
1316
# 11.17.0 December 04, 2025
1417
## Dependencies
1518
* Update gl-native to [v11.17.0](https://github.com/mapbox/mapbox-maps-android/releases/tag/v11.17.0), common to [v24.17.0](https://github.com/mapbox/mapbox-maps-android/releases/tag/v11.17.0).
1619

17-
1820
# 11.17.0-rc.3 November 28, 2025
1921
## Features ✨ and improvements 🏁
2022
* Promote Geofencing APIs to stable, remove `MapboxExperimental` annotations from Geofencing APIs.

compose-app/src/main/AndroidManifest.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,17 @@
163163
android:name="@string/category"
164164
android:value="@string/category_ornaments" />
165165
</activity>
166+
<activity
167+
android:name=".examples.ornaments.CustomAttributionActivity"
168+
android:configChanges="orientation|screenSize|screenLayout"
169+
android:description="@string/description_custom_attribution"
170+
android:exported="true"
171+
android:label="@string/activity_custom_attribution"
172+
android:parentActivityName=".ExampleOverviewActivity">
173+
<meta-data
174+
android:name="@string/category"
175+
android:value="@string/category_ornaments" />
176+
</activity>
166177
<activity
167178
android:name=".examples.location.LocationComponentActivity"
168179
android:configChanges="orientation|screenSize|screenLayout"
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
package com.mapbox.maps.compose.testapp.examples.ornaments
2+
3+
import android.os.Bundle
4+
import androidx.activity.ComponentActivity
5+
import androidx.activity.compose.setContent
6+
import androidx.compose.foundation.layout.Arrangement
7+
import androidx.compose.foundation.layout.Column
8+
import androidx.compose.foundation.layout.Row
9+
import androidx.compose.foundation.layout.fillMaxSize
10+
import androidx.compose.foundation.layout.fillMaxWidth
11+
import androidx.compose.foundation.layout.padding
12+
import androidx.compose.foundation.layout.wrapContentHeight
13+
import androidx.compose.foundation.shape.RoundedCornerShape
14+
import androidx.compose.material.AlertDialog
15+
import androidx.compose.material.Button
16+
import androidx.compose.material.Divider
17+
import androidx.compose.material.ExperimentalMaterialApi
18+
import androidx.compose.material.FabPosition
19+
import androidx.compose.material.FloatingActionButton
20+
import androidx.compose.material.MaterialTheme
21+
import androidx.compose.material.ModalBottomSheetLayout
22+
import androidx.compose.material.ModalBottomSheetValue
23+
import androidx.compose.material.Text
24+
import androidx.compose.material.primarySurface
25+
import androidx.compose.material.rememberModalBottomSheetState
26+
import androidx.compose.runtime.Composable
27+
import androidx.compose.runtime.getValue
28+
import androidx.compose.runtime.mutableStateOf
29+
import androidx.compose.runtime.remember
30+
import androidx.compose.runtime.rememberCoroutineScope
31+
import androidx.compose.runtime.setValue
32+
import androidx.compose.ui.Alignment
33+
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
34+
import androidx.compose.ui.Modifier
35+
import androidx.compose.ui.platform.LocalUriHandler
36+
import androidx.compose.ui.res.stringResource
37+
import androidx.compose.ui.text.style.TextAlign
38+
import androidx.compose.ui.unit.dp
39+
import com.mapbox.annotation.MapboxDelicateApi
40+
import com.mapbox.annotation.MapboxExperimental
41+
import com.mapbox.maps.compose.testapp.ExampleScaffold
42+
import com.mapbox.maps.compose.testapp.R
43+
import com.mapbox.maps.compose.testapp.examples.utils.CityLocations
44+
import com.mapbox.maps.compose.testapp.ui.theme.MapboxMapComposeTheme
45+
import com.mapbox.maps.extension.compose.MapboxMap
46+
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState
47+
import com.mapbox.maps.extension.compose.ornaments.attribution.MapAttributionScope
48+
import com.mapbox.maps.extension.compose.ornaments.attribution.isMapboxFeedback
49+
import com.mapbox.maps.extension.compose.style.standard.MapboxStandardSatelliteStyle
50+
import com.mapbox.maps.extension.compose.style.standard.MapboxStandardStyle
51+
import com.mapbox.maps.plugin.attribution.Attribution
52+
import kotlinx.coroutines.launch
53+
54+
/**
55+
* Example Activity to showcase custom UI for map attributions using Jetpack Compose.
56+
*
57+
* This activity demonstrates how to:
58+
* - Implement a custom attribution display using a modal bottom sheet
59+
* - Handle user consent dialogs for telemetry and geofencing
60+
* - Customize the appearance and behavior of attribution controls
61+
* - Integrate with MapboxMap's attribution system
62+
*/
63+
@MapboxExperimental
64+
public class CustomAttributionActivity : ComponentActivity() {
65+
66+
@OptIn(ExperimentalMaterialApi::class, MapboxDelicateApi::class)
67+
override fun onCreate(savedInstanceState: Bundle?) {
68+
super.onCreate(savedInstanceState)
69+
setContent {
70+
val sheetState = rememberModalBottomSheetState(
71+
initialValue = ModalBottomSheetValue.Hidden
72+
)
73+
var showSatelliteStyle: Boolean by remember {
74+
mutableStateOf(false)
75+
}
76+
val coroutineScope = rememberCoroutineScope()
77+
78+
var userConsentState: MapAttributionScope.UserConsentState? by remember {
79+
mutableStateOf(null)
80+
}
81+
val currentAttributionState = remember {
82+
MapAttributionScope.AttributionState()
83+
}
84+
85+
MapboxMapComposeTheme {
86+
ExampleScaffold(
87+
floatingActionButton = {
88+
Column(
89+
verticalArrangement = Arrangement.Top,
90+
horizontalAlignment = Alignment.End
91+
) {
92+
FloatingActionButton(
93+
modifier = Modifier
94+
.padding(bottom = 10.dp),
95+
onClick = {
96+
coroutineScope.launch {
97+
if (!sheetState.isVisible) {
98+
sheetState.show()
99+
} else {
100+
sheetState.hide()
101+
}
102+
}
103+
},
104+
shape = RoundedCornerShape(16.dp),
105+
) {
106+
Text(
107+
modifier = Modifier.padding(10.dp),
108+
text = if (!sheetState.isVisible) "Show Attribution" else "Hide Attribution"
109+
)
110+
}
111+
FloatingActionButton(
112+
modifier = Modifier
113+
.padding(bottom = 10.dp),
114+
onClick = {
115+
showSatelliteStyle = !showSatelliteStyle
116+
},
117+
shape = RoundedCornerShape(16.dp),
118+
) {
119+
Text(
120+
modifier = Modifier.padding(10.dp),
121+
text = if (showSatelliteStyle) "Show Satellite Style" else "Show Standard Style"
122+
)
123+
}
124+
}
125+
},
126+
floatingActionButtonPosition = FabPosition.End
127+
) { scaffoldPadding ->
128+
ModalBottomSheetLayout(
129+
sheetContent = {
130+
CustomAttributionBottomSheetContent(currentAttributionState) {
131+
userConsentState = it
132+
}
133+
},
134+
sheetState = sheetState,
135+
) {
136+
137+
MapboxMap(
138+
modifier = Modifier.fillMaxSize(),
139+
attribution = {
140+
// Keep original built-in attribution control UI
141+
Attribution()
142+
// Add custom attribution control through [AttributionControl] and [AttributionState].
143+
AttributionControl(
144+
userConsentState = userConsentState,
145+
attributionState = currentAttributionState
146+
)
147+
},
148+
mapViewportState = rememberMapViewportState {
149+
setCameraOptions {
150+
zoom(ZOOM)
151+
center(CityLocations.HELSINKI)
152+
}
153+
},
154+
style = {
155+
if (showSatelliteStyle) {
156+
MapboxStandardSatelliteStyle()
157+
} else {
158+
MapboxStandardStyle()
159+
}
160+
}
161+
)
162+
}
163+
}
164+
}
165+
}
166+
}
167+
168+
/**
169+
* Composable function that renders the content of the attribution bottom sheet.
170+
*
171+
* This function displays:
172+
* - A title for the custom attribution interface
173+
* - A list of attribution buttons for each attribution source
174+
* - User consent dialogs for telemetry and geofencing when triggered
175+
*
176+
* @param attributionState The current attribution state containing available attributions
177+
* and user consent information.
178+
* @param onUserConsentStateChanged Callback invoked when user consent state changes,
179+
* passing the updated consent state to the parent component.
180+
*/
181+
@OptIn(ExperimentalMaterialApi::class)
182+
@Composable
183+
public fun CustomAttributionBottomSheetContent(
184+
attributionState: MapAttributionScope.AttributionState,
185+
onUserConsentStateChanged: (MapAttributionScope.UserConsentState) -> Unit
186+
) {
187+
var showTelemetryDialog by remember {
188+
mutableStateOf(false)
189+
}
190+
var showGeofencingConsentDialog by remember {
191+
mutableStateOf(false)
192+
}
193+
val coroutineScope = rememberCoroutineScope()
194+
val uriHandler = LocalUriHandler.current
195+
Column(modifier = Modifier.wrapContentHeight(), horizontalAlignment = CenterHorizontally) {
196+
Text(
197+
modifier = Modifier.fillMaxWidth(),
198+
text = "Custom Attribution",
199+
textAlign = TextAlign.Center,
200+
style = MaterialTheme.typography.h5,
201+
color = MaterialTheme.colors.primarySurface
202+
)
203+
attributionState.userConsentState?.let { userConsentState ->
204+
Divider()
205+
Text("Telemetry Enable State: ${userConsentState.telemetryEnableState}")
206+
Text("Geofencing Consent State: ${userConsentState.geofencingUserConsentState}")
207+
if (showTelemetryDialog) {
208+
UserConsentDialog(
209+
title = stringResource(id = R.string.mapbox_attributionTelemetryTitle),
210+
body = stringResource(id = R.string.mapbox_attributionTelemetryMessage),
211+
onDismissRequest = { showTelemetryDialog = false },
212+
onConsent = { agree ->
213+
onUserConsentStateChanged(
214+
userConsentState.toBuilder()
215+
.setTelemetryEnableState(agree)
216+
.build()
217+
)
218+
}
219+
)
220+
}
221+
222+
if (showGeofencingConsentDialog) {
223+
UserConsentDialog(
224+
title = stringResource(id = R.string.mapbox_attributionGeofencingTitle),
225+
body = stringResource(id = R.string.mapbox_attributionGeofencingMessage),
226+
onDismissRequest = { showGeofencingConsentDialog = false },
227+
onConsent = { agree ->
228+
onUserConsentStateChanged(
229+
userConsentState.toBuilder()
230+
.setGeofencingUserConsentState(agree)
231+
.build()
232+
)
233+
}
234+
)
235+
}
236+
}
237+
Divider()
238+
attributionState.attributions.forEach { attribution ->
239+
Button(
240+
modifier = Modifier.fillMaxWidth(0.8f),
241+
onClick = {
242+
when (attribution.url) {
243+
Attribution.ABOUT_TELEMETRY_URL -> showTelemetryDialog = true
244+
Attribution.GEOFENCING_URL_MARKER -> showGeofencingConsentDialog = true
245+
246+
else -> {
247+
if (attribution.isMapboxFeedback()) {
248+
// For MapboxFeedback attribution, use AttributionState.buildMapboxFeedbackUrl() to build the URL with the current map state.
249+
coroutineScope.launch {
250+
uriHandler.openUri(attributionState.buildMapboxFeedbackUrl())
251+
}
252+
} else {
253+
uriHandler.openUri(attribution.url)
254+
}
255+
}
256+
}
257+
}
258+
) {
259+
Text(attribution.title)
260+
}
261+
}
262+
}
263+
}
264+
265+
/**
266+
* Composable function that displays a user consent dialog for attribution-related permissions.
267+
*
268+
* This dialog presents the user with information about a specific feature (like telemetry
269+
* or geofencing) and allows them to agree or disagree with the terms. The dialog uses
270+
* Material Design's AlertDialog with custom button layout.
271+
*
272+
* @param title The title text displayed at the top of the dialog
273+
* @param body The main message content explaining what the user is consenting to
274+
* @param onDismissRequest Callback invoked when the dialog should be dismissed
275+
* (e.g., when user taps outside the dialog or presses back)
276+
* @param onConsent Callback invoked when user makes a choice, passing true for
277+
* "Agree" and false for "Disagree"
278+
*/
279+
@Composable
280+
public fun UserConsentDialog(
281+
title: String,
282+
body: String,
283+
onDismissRequest: () -> Unit,
284+
onConsent: (Boolean) -> Unit
285+
) {
286+
AlertDialog(
287+
onDismissRequest = onDismissRequest,
288+
title = {
289+
Text(
290+
text = title,
291+
textAlign = TextAlign.Center,
292+
style = MaterialTheme.typography.h6
293+
)
294+
},
295+
text = {
296+
Text(
297+
text = body,
298+
textAlign = TextAlign.Start,
299+
style = MaterialTheme.typography.body1
300+
)
301+
},
302+
buttons = {
303+
Row(
304+
modifier = Modifier
305+
.fillMaxWidth()
306+
.padding(20.dp),
307+
horizontalArrangement = Arrangement.SpaceEvenly,
308+
verticalAlignment = Alignment.CenterVertically
309+
) {
310+
Button(
311+
onClick = {
312+
onConsent(true)
313+
onDismissRequest()
314+
}
315+
) {
316+
Text("Agree")
317+
}
318+
Button(
319+
onClick = {
320+
onConsent(false)
321+
onDismissRequest()
322+
}
323+
) {
324+
Text("Disagree")
325+
}
326+
}
327+
}
328+
)
329+
}
330+
331+
private companion object {
332+
const val ZOOM: Double = 9.0
333+
}
334+
}

compose-app/src/main/res/values/example_descriptions.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<string name="description_map_viewport_animation">Use map viewport animations</string>
1414
<string name="description_multiple_display">Display the map on a secondary display</string>
1515
<string name="description_ornaments_customisation">Customise ornaments of the Map</string>
16+
<string name="description_custom_attribution">Customise attribution of the Map</string>
1617
<string name="description_location_location_component">Show Location on the Map</string>
1718
<string name="description_style_composition">Runtime styling with style composition</string>
1819
<string name="description_navigation_simulation">Simulate navigation experience with compose</string>

compose-app/src/main/res/values/example_titles.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<string name="activity_map_viewport_animation">Map Viewport animation</string>
1414
<string name="activity_multiple_display">Multi display</string>
1515
<string name="activity_ornaments_customisation">Ornament customisation</string>
16+
<string name="activity_custom_attribution">Custom attribution</string>
1617
<string name="activity_location_location_component">Location Component</string>
1718
<string name="activity_style_composition">Style composition</string>
1819
<string name="activity_navigation_simulation">Simulate navigation</string>

0 commit comments

Comments
 (0)