diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d61d190f2..7c5e4b808 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -79,6 +79,7 @@ maps-utils-core = { module = "com.google.maps.android:android-maps-utils-core", compose-multiplatform-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-multiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-multiplatform-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +maps-utils-kmp-core = { module = "com.google.maps.android:android-maps-utils-core", version.ref = "mapsutilsKmp" } maps-utils-kmp-clustering = { module = "com.google.maps.android:android-maps-utils-clustering", version.ref = "mapsutilsKmp" } maps-utils-kmp-maps-model = { module = "com.google.maps.android:android-maps-utils-maps-model", version.ref = "mapsutilsKmp" } maps-utils = { module = "com.google.maps.android:android-maps-utils", version.ref = "mapsutils" } diff --git a/iosApp/create_project.rb b/iosApp/create_project.rb index b116dc228..71a224e0f 100644 --- a/iosApp/create_project.rb +++ b/iosApp/create_project.rb @@ -45,7 +45,10 @@ API_KEY="YOUR_API_KEY" if [ -f "$SECRETS_PATH" ]; then - EXTRACTED_KEY=$(grep -E "^MAPS_API_KEY=" "$SECRETS_PATH" | cut -d'=' -f2 | tr -d '"' | tr -d "'") + EXTRACTED_KEY=$(grep -E "^IOS_MAPS_API_KEY=" "$SECRETS_PATH" | cut -d'=' -f2 | tr -d '"' | tr -d "'") + if [ -z "$EXTRACTED_KEY" ]; then + EXTRACTED_KEY=$(grep -E "^MAPS_API_KEY=" "$SECRETS_PATH" | cut -d'=' -f2 | tr -d '"' | tr -d "'") + fi if [ ! -z "$EXTRACTED_KEY" ]; then API_KEY="$EXTRACTED_KEY" fi diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 37d0d4e6c..cd135cb65 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -18,6 +18,8 @@ 1.0 CFBundleVersion 1 + NSLocationWhenInUseUsageDescription + Demonstrates multiplatform LocationSource Flow on Google Maps. LSRequiresIPhoneOS UILaunchScreen diff --git a/maps-compose-multiplatform-demo/build.gradle.kts b/maps-compose-multiplatform-demo/build.gradle.kts index 549f03992..be4450b07 100644 --- a/maps-compose-multiplatform-demo/build.gradle.kts +++ b/maps-compose-multiplatform-demo/build.gradle.kts @@ -56,5 +56,10 @@ kotlin { implementation(libs.compose.multiplatform.ui) } } + androidMain { + dependencies { + implementation(libs.androidx.activity.compose) + } + } } } diff --git a/maps-compose-multiplatform-demo/src/androidMain/AndroidManifest.xml b/maps-compose-multiplatform-demo/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..c6f8711f0 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/androidMain/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/maps-compose-multiplatform-demo/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.android.kt b/maps-compose-multiplatform-demo/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.android.kt new file mode 100644 index 000000000..1cfd9ec2a --- /dev/null +++ b/maps-compose-multiplatform-demo/src/androidMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.android.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.multiplatform.demo + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.Uri +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.google.maps.android.location.LocationPriority +import com.google.maps.android.location.LocationSource +import com.google.maps.android.location.asLocationSource +import com.google.maps.android.location.coarseLocationEvents +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.merge + +private val LOCATION_PERMISSIONS = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, +) + +private fun Context.hasAnyLocationPermission(): Boolean = + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + +private fun Context.hasFineLocationPermission(): Boolean = + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + +private fun Activity.shouldShowLocationRationale(): Boolean = + shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) || + shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_COARSE_LOCATION) + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +@SuppressLint("MissingPermission") +@Composable +internal actual fun rememberPlatformLocationController(): PlatformLocationController { + val context = LocalContext.current + val appContext = context.applicationContext + val activity = remember(context) { context.findActivity() } + val lifecycleOwner = LocalLifecycleOwner.current + var hasPermission by remember { mutableStateOf(appContext.hasAnyLocationPermission()) } + + // Re-check location permission whenever the host lifecycle resumes (covering both the + // system permission dialog closing and returning from Android App Settings). + DisposableEffect(lifecycleOwner, appContext) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + hasPermission = appContext.hasAnyLocationPermission() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + // TODO(https://github.com/googlemaps/android-maps-utils/issues/1818): Once + // LocationManager.asLocationSource() handles cached lastKnownLocation emission and + // coarse-permission/indoor fallback directly in android-maps-utils, simplify this to + // `remember(manager) { manager.asLocationSource() }` matching PlatformLocationSource.ios.kt. + val locationSource = remember(appContext) { + val manager = appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager + LocationSource { intervalMs, minUpdateDistanceM, priority -> + channelFlow { + if (!appContext.hasAnyLocationPermission()) { + return@channelFlow + } + + // Safely query cached locations across providers; wrap each call in runCatching + // so that coarse-only ("Approximate") permission or a mid-flight revocation + // never throws an unhandled SecurityException. + val lastKnown = runCatching { + manager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + }.getOrNull() + ?: runCatching { + manager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + }.getOrNull() + ?: runCatching { + manager.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER) + }.getOrNull() + + if (lastKnown != null) { + send(lastKnown) + } + + val stream = if (appContext.hasFineLocationPermission() && + priority == LocationPriority.HIGH_ACCURACY + ) { + merge( + manager.asLocationSource().locationEvents( + intervalMs = intervalMs, + minUpdateDistanceM = minUpdateDistanceM, + priority = LocationPriority.HIGH_ACCURACY, + ).catch { if (it !is SecurityException) throw it }, + manager.coarseLocationEvents( + minTimeMs = intervalMs, + minDistanceM = minUpdateDistanceM, + ).catch { if (it !is SecurityException) throw it }, + ) + } else { + manager.coarseLocationEvents( + minTimeMs = intervalMs, + minDistanceM = minUpdateDistanceM, + ).catch { if (it !is SecurityException) throw it } + } + + stream.collect { send(it) } + } + } + } + + val openAppSettings: () -> Unit = remember(appContext) { + { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", appContext.packageName, null), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(intent) + } + } + + var rationaleBeforeRequest by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + val granted = permissions.values.any { it } || appContext.hasAnyLocationPermission() + hasPermission = granted + if (!granted) { + val rationaleAfterRequest = activity?.shouldShowLocationRationale() ?: false + // If shouldShowRequestPermissionRationale was false before the request and remains + // false after the request was denied, Android suppressed the permission dialog + // because the permission is permanently denied ("Don't ask again" / USER_FIXED). + if (!rationaleBeforeRequest && !rationaleAfterRequest) { + openAppSettings() + } + } + } + + val requestPermission: () -> Unit = remember(activity, permissionLauncher, openAppSettings) { + { + if (activity == null) { + openAppSettings() + } else { + rationaleBeforeRequest = activity.shouldShowLocationRationale() + permissionLauncher.launch(LOCATION_PERMISSIONS) + } + } + } + + return remember(locationSource, hasPermission, requestPermission, openAppSettings) { + PlatformLocationController( + locationSource = locationSource, + hasPermission = hasPermission, + requestPermission = requestPermission, + openAppSettings = openAppSettings, + ) + } +} diff --git a/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/DemoApp.kt b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/DemoApp.kt index 625e1f956..80d365b74 100644 --- a/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/DemoApp.kt +++ b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/DemoApp.kt @@ -73,6 +73,9 @@ public val multiplatformDemos: List = listOf( MultiplatformDemo("Map clicks", "Tap to drop a marker, long press to clear them") { MapClicksDemo() }, + MultiplatformDemo("Location Flow", "Live device GPS streamed via KMP LocationSource Flow") { + LocationFlowDemo() + }, ) /** diff --git a/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/Demos.kt b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/Demos.kt index 2345a4320..ef2450782 100644 --- a/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/Demos.kt +++ b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/Demos.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf @@ -47,6 +48,13 @@ import com.google.maps.android.compose.multiplatform.Polyline import com.google.maps.android.compose.multiplatform.cameraPosition import com.google.maps.android.compose.multiplatform.rememberCameraPositionState import com.google.maps.android.compose.multiplatform.rememberMarkerState +import com.google.maps.android.location.Location +import com.google.maps.android.location.LocationPriority +import com.google.maps.android.location.accuracyMeters +import com.google.maps.android.location.latLng +import com.google.maps.android.location.latitude +import com.google.maps.android.location.locationEvents +import com.google.maps.android.location.longitude import com.google.maps.android.model.LatLng import com.google.maps.android.model.latitude import com.google.maps.android.model.longitude @@ -272,3 +280,97 @@ internal fun MapClicksDemo() { private fun Double.format(): String = (round(this * 10_000) / 10_000).toString() private fun Boolean.onOff(): String = if (this) "on" else "off" + + +private const val MAX_TRAIL_POINTS = 500 + +@Composable +internal fun LocationFlowDemo() { + val controller = rememberPlatformLocationController() + val camera = rememberCameraPositionState { position = cameraPosition(SanFrancisco, 14f) } + val markerState = rememberMarkerState(SanFrancisco) + var latestLocation by remember { mutableStateOf(null) } + var fixCount by remember { mutableIntStateOf(0) } + var priority by remember { mutableStateOf(LocationPriority.HIGH_ACCURACY) } + val trail = remember { mutableStateListOf() } + + LaunchedEffect(controller.locationSource, priority, controller.hasPermission) { + if (!controller.hasPermission) { + latestLocation = null + return@LaunchedEffect + } + controller.locationSource.locationEvents( + intervalMs = 1_000L, + minUpdateDistanceM = 0f, + priority = priority, + ).collect { location -> + latestLocation = location + fixCount++ + val target = location.latLng + if (trail.lastOrNull() != target) { + if (trail.size >= MAX_TRAIL_POINTS) { + trail.removeAt(0) + } + trail += target + } + markerState.position = target + camera.animate(cameraPosition(target, 15f)) + } + } + + Box(Modifier.fillMaxSize()) { + GoogleMap(Modifier.fillMaxSize(), cameraPositionState = camera) { + val loc = latestLocation + if (loc != null && controller.hasPermission) { + Marker( + state = markerState, + title = "Live Location (KMP Flow)", + snippet = "Fix #$fixCount (${loc.latitude.format()}, ${loc.longitude.format()})", + ) + val accuracyRadius = loc.accuracyMeters.toDouble().coerceAtLeast(25.0) + Circle( + center = loc.latLng, + radius = accuracyRadius, + fillColor = Color(0x441A73E8), + strokeColor = Accent, + strokeWidth = 3f, + ) + if (trail.size >= 2) { + Polyline( + points = trail.toList(), + color = Accent, + width = 8f, + ) + } + } + } + DemoControls(Modifier.align(Alignment.TopStart)) { + if (!controller.hasPermission) { + DemoLabel( + "Location permission required.\n" + + "Grant permission to stream live LocationSource updates.", + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DemoButton("Grant Permission") { controller.requestPermission() } + DemoButton("App Settings") { controller.openAppSettings() } + } + } else { + val loc = latestLocation + if (loc == null) { + DemoLabel("Waiting for LocationSource.locationEvents()...") + } else { + DemoLabel( + "Flow Fix #$fixCount: ${loc.latitude.format()}, ${loc.longitude.format()}\n" + + "Accuracy: ${loc.accuracyMeters.toDouble().format()}m ยท Mode: ${priority.name}", + ) + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DemoButton("High Accuracy") { priority = LocationPriority.HIGH_ACCURACY } + DemoButton("Balanced") { priority = LocationPriority.BALANCED_POWER_ACCURACY } + } + } + } + } +} diff --git a/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.kt b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.kt new file mode 100644 index 000000000..7f24694f5 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/commonMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.multiplatform.demo + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.google.maps.android.location.LocationSource + +/** + * Holds a multiplatform [LocationSource] alongside observable runtime permission state and + * platform callbacks to request permission or open system application settings. + * + * Exposing permission state alongside [locationSource] allows shared `commonMain` UI to react + * gracefully when the user denies or revokes location permission at runtime, rather than crashing + * or hanging indefinitely. + */ +@Stable +internal class PlatformLocationController( + val locationSource: LocationSource, + val hasPermission: Boolean, + val requestPermission: () -> Unit, + val openAppSettings: () -> Unit, +) + +/** + * Remembers a platform-backed [PlatformLocationController] (`LocationManager` on Android, + * `CLLocationManager` on iOS) that tracks runtime location permissions and streams device GPS + * fixes into shared `commonMain` UI via [LocationSource.locationEvents]. + */ +@Composable +internal expect fun rememberPlatformLocationController(): PlatformLocationController diff --git a/maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.ios.kt b/maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.ios.kt new file mode 100644 index 000000000..db969be90 --- /dev/null +++ b/maps-compose-multiplatform-demo/src/iosMain/kotlin/com/google/maps/android/compose/multiplatform/demo/PlatformLocationSource.ios.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.multiplatform.demo + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.google.maps.android.location.asLocationSource +import platform.CoreLocation.CLAuthorizationStatus +import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.CLLocationManagerDelegateProtocol +import platform.CoreLocation.kCLAuthorizationStatusAuthorizedAlways +import platform.CoreLocation.kCLAuthorizationStatusAuthorizedWhenInUse +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationDidBecomeActiveNotification +import platform.UIKit.UIApplicationOpenSettingsURLString +import platform.darwin.NSObject + +private fun CLAuthorizationStatus.isAuthorized(): Boolean = + this == kCLAuthorizationStatusAuthorizedWhenInUse || + this == kCLAuthorizationStatusAuthorizedAlways + +@Composable +internal actual fun rememberPlatformLocationController(): PlatformLocationController { + val manager = remember { CLLocationManager() } + var hasPermission by remember { mutableStateOf(manager.authorizationStatus.isAuthorized()) } + + // Observe CoreLocation authorization changes and app foreground activation so returning + // from the system permission dialog or iOS Settings updates hasPermission without polling. + DisposableEffect(manager) { + val authObserver = CLLocationManager() + val authDelegate = object : NSObject(), CLLocationManagerDelegateProtocol { + override fun locationManagerDidChangeAuthorization(manager: CLLocationManager) { + hasPermission = manager.authorizationStatus.isAuthorized() + } + } + authObserver.delegate = authDelegate + val notificationObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = UIApplicationDidBecomeActiveNotification, + `object` = null, + queue = null, + ) { _ -> + hasPermission = manager.authorizationStatus.isAuthorized() + } + onDispose { + // Keep authDelegate referenced until onDispose because CLLocationManager.delegate is weak. + if (authObserver.delegate === authDelegate) { + authObserver.delegate = null + } + NSNotificationCenter.defaultCenter.removeObserver(notificationObserver) + } + } + + val locationSource = remember(manager) { manager.asLocationSource() } + + val requestPermission: () -> Unit = remember(manager) { + { manager.requestWhenInUseAuthorization() } + } + + val openAppSettings: () -> Unit = remember { + { + val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString) + if (url != null) { + UIApplication.sharedApplication.openURL( + url, + options = emptyMap(), + completionHandler = null, + ) + } + } + } + + return remember(locationSource, hasPermission, requestPermission, openAppSettings) { + PlatformLocationController( + locationSource = locationSource, + hasPermission = hasPermission, + requestPermission = requestPermission, + openAppSettings = openAppSettings, + ) + } +} diff --git a/maps-compose-multiplatform/build.gradle.kts b/maps-compose-multiplatform/build.gradle.kts index b8b5ebc2b..98063e37a 100644 --- a/maps-compose-multiplatform/build.gradle.kts +++ b/maps-compose-multiplatform/build.gradle.kts @@ -72,6 +72,7 @@ kotlin { // a snapshot (see settings.gradle.kts). It shares its artifactId with the AAR that // maps-compose-utils pulls from Maven Central, so the two resolve to one module. api(libs.maps.utils.kmp.maps.model) + api(libs.maps.utils.kmp.core) api(libs.maps.utils.kmp.clustering) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 172204816..c498fd014 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,7 +19,11 @@ dependencyResolutionManagement { repositories { if (providers.gradleProperty("useMavenLocal").orNull == "true" || providers.environmentVariable("USE_MAVEN_LOCAL").orNull == "true") { - mavenLocal() + mavenLocal { + mavenContent { + includeGroup("com.google.maps.android") + } + } } google() mavenCentral()