diff --git a/kotlin/advanced/JetpackComposeDemo/app/build.gradle.kts b/kotlin/advanced/JetpackComposeDemo/app/build.gradle.kts index 0dba44d4d..51a36b0b3 100644 --- a/kotlin/advanced/JetpackComposeDemo/app/build.gradle.kts +++ b/kotlin/advanced/JetpackComposeDemo/app/build.gradle.kts @@ -36,15 +36,18 @@ android { dependencies { implementation("androidx.activity:activity") implementation("androidx.activity:activity-ktx") - implementation("androidx.activity:activity-compose:1.9.0") + implementation("androidx.activity:activity-compose:1.9.1") implementation("androidx.core:core-ktx:1.13.1") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4") implementation(platform("androidx.compose:compose-bom:2024.06.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.material3:material3") implementation("androidx.compose.foundation:foundation") + implementation("androidx.navigation:navigation-compose:2.7.7") implementation("com.google.android.gms:play-services-ads:23.2.0") implementation("com.google.android.ump:user-messaging-platform:3.0.0") + implementation(project(":compose-util")) + implementation("androidx.navigation:navigation-runtime-ktx:2.7.7") debugImplementation("androidx.compose.ui:ui-tooling") } diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/AndroidManifest.xml b/kotlin/advanced/JetpackComposeDemo/app/src/main/AndroidManifest.xml index 87da63cac..189cc7250 100644 --- a/kotlin/advanced/JetpackComposeDemo/app/src/main/AndroidManifest.xml +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + // Consent has been gathered. + onConsentGatheringCompleteListener.consentGatheringComplete(formError) + } + }, + { requestConsentError -> + onConsentGatheringCompleteListener.consentGatheringComplete(requestConsentError) + }, + ) + } + + /** Calls the UMP SDK method to show the privacy options form. */ + fun showPrivacyOptionsForm( + activity: Activity, + onConsentFormDismissedListener: OnConsentFormDismissedListener, + ) { + UserMessagingPlatform.showPrivacyOptionsForm(activity, onConsentFormDismissedListener) + } + + companion object { + @Volatile private var instance: GoogleMobileAdsConsentManager? = null + + fun getInstance(context: Context) = + instance + ?: synchronized(this) { + instance ?: GoogleMobileAdsConsentManager(context).also { instance = it } + } + } +} diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/HomeScreen.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/HomeScreen.kt new file mode 100644 index 000000000..43d315366 --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/HomeScreen.kt @@ -0,0 +1,50 @@ +package com.google.android.gms.example.jetpackcomposedemo + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.example.jetpackcomposedemo.R +import com.google.android.gms.example.jetpackcomposedemo.ui.theme.JetpackComposeDemoTheme + +@Composable +fun HomeScreen( + uiState: MainUiState, + navController: NavHostController, + modifier: Modifier = Modifier, +) { + Column { + Button( + onClick = { navController.navigate(NavDestinations.Banner.name) }, + enabled = uiState.canRequestAds, + modifier = modifier.fillMaxWidth(), + ) { + Text(LocalContext.current.getString(R.string.nav_banner)) + } + Button( + onClick = { navController.navigate(NavDestinations.LazyBanner.name) }, + enabled = uiState.canRequestAds, + modifier = modifier.fillMaxWidth(), + ) { + Text(LocalContext.current.getString(R.string.nav_lazy_banner)) + } + } +} + +@Preview +@Composable +private fun HomeScreenPreview() { + JetpackComposeDemoTheme { + Surface(color = MaterialTheme.colorScheme.background) { + HomeScreen(MainUiState(), rememberNavController()) + } + } +} diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/LazyBannerScreen.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/LazyBannerScreen.kt new file mode 100644 index 000000000..5b11bca66 --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/LazyBannerScreen.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2024 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.android.gms.example.jetpackcomposedemo + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetpackcomposedemo.R +import com.google.android.gms.ads.AdListener +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.AdSize +import com.google.android.gms.ads.AdView +import com.google.android.gms.ads.LoadAdError +import com.google.android.gms.compose_util.BannerAd +import com.google.android.gms.example.jetpackcomposedemo.GoogleMobileAdsApplication.Companion.BANNER_ADUNIT_ID +import com.google.android.gms.example.jetpackcomposedemo.GoogleMobileAdsApplication.Companion.TAG +import com.google.android.gms.example.jetpackcomposedemo.ui.theme.JetpackComposeDemoTheme +import com.google.android.gms.example.jetpackcomposedemo.ui.theme.LightBlue +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +@Composable +fun LazyBannerScreen(modifier: Modifier = Modifier) { + val context = LocalContext.current + val isPreviewMode = LocalInspectionMode.current + val deviceCurrentWidth = LocalConfiguration.current.screenWidthDp + val adsToLoad = 5 + + // State for loading and completed. + var isLoading by remember { mutableStateOf(true) } + var loadedAds by remember { mutableStateOf>(emptyList()) } + val tips by remember { mutableStateOf(loadTips(context)) } + + // Load ads when on launch of the composition. + LaunchedEffect(Unit) { + if (!isPreviewMode) { + loadedAds = loadBannerAds(context, adsToLoad, deviceCurrentWidth) + } + isLoading = false + } + + Column { + // Display page subtitle. + Box(modifier.fillMaxWidth().background(Color.LightGray)) { + Text( + text = context.getString(R.string.text_best_practices), + modifier.padding(8.dp), + style = MaterialTheme.typography.titleMedium, + ) + } + // Display a loading indicator if ads are still being fetched. + if (isLoading) { + CircularProgressIndicator(modifier = modifier.width(100.dp).height(100.dp)) + } + // Display a Lazy list of loaded ads. + LazyColumn { + items(loadedAds) { adView -> + Column { + Spacer(modifier = modifier.height(8.dp)) + BannerAd(adView, modifier.fillMaxWidth()) + // Display the loaded content. + tips.forEach { content -> + Spacer(modifier = modifier.height(8.dp)) + Box(modifier.fillMaxWidth().background(LightBlue).padding(8.dp)) { + Text( + text = content, + modifier.padding(8.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } + } + + // Clean up the AdViews after use. + DisposableEffect(Unit) { onDispose { loadedAds.forEach { adView -> adView.destroy() } } } +} + +@Preview +@Composable +private fun LazyBannerScreenPreview() { + JetpackComposeDemoTheme { + Surface(color = MaterialTheme.colorScheme.background) { LazyBannerScreen() } + } +} + +private suspend fun loadBannerAd(context: Context, width: Int): AdView { + return suspendCoroutine { continuation -> + val adView = AdView(context) + adView.adUnitId = BANNER_ADUNIT_ID + val adSize = AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, width) + adView.setAdSize(adSize) + adView.adListener = + object : AdListener() { + override fun onAdLoaded() { + super.onAdLoaded() + Log.i(TAG, context.getString(R.string.banner_loaded)) + continuation.resume(adView) + } + + override fun onAdFailedToLoad(error: LoadAdError) { + Log.e(TAG, context.getString(R.string.banner_failedToLoad)) + continuation.resume(adView) + } + + override fun onAdImpression() { + super.onAdImpression() + Log.i(TAG, context.getString(R.string.banner_impression)) + } + + override fun onAdClicked() { + super.onAdClicked() + Log.i(TAG, context.getString(R.string.banner_clicked)) + } + } + val adRequest = AdRequest.Builder().build() + adView.loadAd(adRequest) + } +} + +private suspend fun loadBannerAds(context: Context, count: Int, width: Int): List = + coroutineScope { + val deferredAds = mutableListOf>() + // Concurrently load the specified number of ads. + (1..count).forEach { _ -> deferredAds.add(async { loadBannerAd(context, width) }) } + return@coroutineScope deferredAds.awaitAll() + } + +fun loadTips(context: Context): List { + val tipsArray = context.resources.getStringArray(R.array.tips) + return tipsArray.toList() +} diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainActivity.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainActivity.kt index b5d3ce953..32c66087b 100644 --- a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainActivity.kt +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainActivity.kt @@ -17,69 +17,35 @@ package com.google.android.gms.example.jetpackcomposedemo import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.lifecycleScope import com.example.jetpackcomposedemo.R -import com.google.android.gms.example.jetpackcomposedemo.ui.theme.JetpackComposeDemoTheme +import com.google.android.gms.ads.MobileAds +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { + private lateinit var mainViewModel: MainViewModel + override fun onCreate(savedInstanceState: Bundle?) { // Display content edge-to-edge. enableEdgeToEdge() super.onCreate(savedInstanceState) - setContent { - JetpackComposeDemoTheme { - Surface(modifier = Modifier.fillMaxHeight(), color = MaterialTheme.colorScheme.background) { - MainScreen() - } - } - } - } + // Log the Mobile Ads SDK version. + Log.d( + GoogleMobileAdsApplication.TAG, + getString(R.string.version_format, MobileAds.getVersion()), + ) - @Composable - @Preview - fun MainScreenPreview() { - JetpackComposeDemoTheme { - Surface(modifier = Modifier.fillMaxHeight(), color = MaterialTheme.colorScheme.background) { - MainScreen() - } + // Initialize the view model. This will gather consent and initialize Google Mobile Ads. + mainViewModel = MainViewModel.getInstance() + if (!mainViewModel.isInitCalled) { + lifecycleScope.launch { mainViewModel.init(this@MainActivity) } } + setContent { MainScreen(mainViewModel) } } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun MainScreen() = - Scaffold( - topBar = { TopAppBar(title = { Text(resources.getString(R.string.main_title)) }) }, - contentWindowInsets = - WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), - ) { innerPadding -> - Column(Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) { - Text(resources.getString(R.string.main_title)) - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) - } - } } diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainScreen.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainScreen.kt new file mode 100644 index 000000000..f8ad0307e --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainScreen.kt @@ -0,0 +1,154 @@ +package com.google.android.gms.example.jetpackcomposedemo + +import android.content.Context +import android.content.ContextWrapper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.example.jetpackcomposedemo.R +import com.google.android.gms.example.jetpackcomposedemo.ui.theme.JetpackComposeDemoTheme + +@Composable +fun MainScreen(googleMobileAdsViewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = LocalContext.current + val activity = context.getActivity() + val navController = rememberNavController() + val uiState by googleMobileAdsViewModel.uiState.collectAsState() + var showNavigationIcon by remember { mutableStateOf(false) } + + LaunchedEffect(navController) { + navController.addOnDestinationChangedListener { _, destination, _ -> + showNavigationIcon = destination.route != NavDestinations.Home.name + } + } + + Scaffold( + topBar = { + MainTopBar( + isMobileAdsInitialized = uiState.isMobileAdsInitialized, + isPrivacyOptionsRequired = uiState.isPrivacyOptionsRequired, + isNavigationEnabled = showNavigationIcon, + navigateBack = { navController.popBackStack() }, + onOpenAdInspector = { googleMobileAdsViewModel.openAdInspector(context) {} }, + onShowPrivacyOptionsForm = { + if (activity != null) { + googleMobileAdsViewModel.showPrivacyOptionsForm(activity) {} + } + }, + modifier, + ) + }, + contentWindowInsets = + WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + ) { innerPadding -> + Column(modifier.padding(innerPadding)) { + NavHost(navController = navController, startDestination = NavDestinations.Home.name) { + composable(NavDestinations.Home.name) { + val uiState by googleMobileAdsViewModel.uiState.collectAsState() + HomeScreen(uiState, navController) + } + composable(NavDestinations.Banner.name) { BannerScreen() } + composable(NavDestinations.LazyBanner.name) { LazyBannerScreen() } + } + Spacer(modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainTopBar( + isMobileAdsInitialized: Boolean, + isPrivacyOptionsRequired: Boolean, + isNavigationEnabled: Boolean, + navigateBack: () -> Unit, + onOpenAdInspector: () -> Unit, + onShowPrivacyOptionsForm: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var menuExpanded by remember { mutableStateOf(false) } + + TopAppBar( + modifier = modifier, + title = { Text(context.getString(R.string.main_title)) }, + navigationIcon = { + if (isNavigationEnabled) { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = Icons.AutoMirrored.Filled.ArrowBack.name, + ) + } + } + }, + actions = { + IconButton(onClick = { menuExpanded = true }) { + Icon(imageVector = Icons.Filled.MoreVert, contentDescription = Icons.Filled.MoreVert.name) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text(context.getString(R.string.adinspector_open_button)) }, + enabled = isMobileAdsInitialized, + onClick = onOpenAdInspector, + ) + if (isPrivacyOptionsRequired) { + DropdownMenuItem( + text = { Text(context.getString(R.string.privacy_options_open_button)) }, + onClick = onShowPrivacyOptionsForm, + ) + } + } + }, + ) +} + +private fun Context.getActivity(): ComponentActivity? = + when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + +@Preview +@Composable +private fun MainScreenPreview() { + JetpackComposeDemoTheme { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + MainScreen(MainViewModel.getInstance()) + } + } +} diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainUiState.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainUiState.kt new file mode 100644 index 000000000..3ab98b2dd --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainUiState.kt @@ -0,0 +1,11 @@ +package com.google.android.gms.example.jetpackcomposedemo + +/** UiState for the MainViewModel. */ +data class MainUiState( + /** Represents current initialization states for the Google Mobile Ads SDK. */ + val isMobileAdsInitialized: Boolean = false, + /** Indicates whether the app has completed the steps for gathering updated user consent. */ + val canRequestAds: Boolean = false, + /** Indicates whether a privacy options form is required. */ + val isPrivacyOptionsRequired: Boolean = false, +) diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainViewModel.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainViewModel.kt new file mode 100644 index 000000000..36af2e8be --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/MainViewModel.kt @@ -0,0 +1,145 @@ +package com.google.android.gms.example.jetpackcomposedemo + +import android.app.Activity +import android.content.Context +import android.util.Log +import android.widget.Toast +import com.google.android.gms.ads.MobileAds +import com.google.android.gms.ads.OnAdInspectorClosedListener +import com.google.android.gms.ads.RequestConfiguration +import com.google.android.ump.ConsentForm +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +/** State holder for the Google Mobile Ads initialization process. */ +class MainViewModel { + + val isInitCalled: Boolean + get() = isInitCalledInternal.get() + + private val isInitCalledInternal = AtomicBoolean(false) + private val isMobileAdsInitializeCalled = AtomicBoolean(false) + private lateinit var googleMobileAdsConsentManager: GoogleMobileAdsConsentManager + private val _uiState = MutableStateFlow(MainUiState()) + + /** UIState for the ViewModel. */ + val uiState: StateFlow = _uiState.asStateFlow() + + /** Sets initial UIState for the ViewModel. */ + suspend fun init(activity: Activity) { + isInitCalledInternal.set(true) + googleMobileAdsConsentManager = GoogleMobileAdsConsentManager.getInstance(activity) + + // Initializes the consent manager and calls the UMP SDK methods to request consent information + // and load/show a consent form if necessary. + gatherConsent(activity) { error -> + if (error != null) { + // Consent not obtained in current session. + Log.d(GoogleMobileAdsApplication.TAG, "${error.errorCode}: ${error.message}") + } + // canRequestAds can be updated when gatherConsent is completed. + _uiState.update { it.copy(canRequestAds = googleMobileAdsConsentManager.canRequestAds) } + } + // canRequestAds can be updated when gatherConsent is called. + _uiState.update { it.copy(canRequestAds = googleMobileAdsConsentManager.canRequestAds) } + + // when canRequestAds is true initializeMobileAdsSdk + uiState.collect { state -> + if (state.canRequestAds) { + initializeMobileAdsSdk(activity) + } + } + } + + /** Opens the ad inspector. */ + fun openAdInspector(context: Context, listener: OnAdInspectorClosedListener?) { + MobileAds.openAdInspector(context) { error -> + if (error != null) { + val errorMessage = error.message + Log.e(GoogleMobileAdsApplication.TAG, errorMessage) + Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show() + } + // Notify listener of ad inspector closed. + listener?.onAdInspectorClosed(error) + } + } + + /** Calls the UMP SDK method to show the privacy options form. */ + fun showPrivacyOptionsForm( + activity: Activity, + onConsentFormDismissedListener: ConsentForm.OnConsentFormDismissedListener?, + ) { + googleMobileAdsConsentManager.showPrivacyOptionsForm(activity) { error -> + if (error != null) { + val errorMessage = error.message + Log.e(GoogleMobileAdsApplication.TAG, errorMessage) + Toast.makeText(activity, errorMessage, Toast.LENGTH_SHORT).show() + } + // Notify listener of consent form dismissal. + onConsentFormDismissedListener?.onConsentFormDismissed(error) + } + } + + /** Calls the UMP SDK methods to gatherConsent. */ + private fun gatherConsent( + activity: Activity, + onConsentGatheringCompleteListener: + GoogleMobileAdsConsentManager.OnConsentGatheringCompleteListener, + ) { + googleMobileAdsConsentManager.gatherConsent(activity) { error -> + // Update UIState and notify listener of updated consent status. + _uiState.update { + it.copy( + canRequestAds = googleMobileAdsConsentManager.canRequestAds, + isPrivacyOptionsRequired = googleMobileAdsConsentManager.isPrivacyOptionsRequired, + ) + } + onConsentGatheringCompleteListener.consentGatheringComplete(error) + } + // Update UIState based on consent obtained in the previous session. + _uiState.update { + it.copy( + canRequestAds = googleMobileAdsConsentManager.canRequestAds, + isPrivacyOptionsRequired = googleMobileAdsConsentManager.isPrivacyOptionsRequired, + ) + } + } + + /** Initializes the Mobile Ads SDK. */ + private suspend fun initializeMobileAdsSdk(context: Context) { + + // Ensure that MobileAdsInitialize is called only once. + if (isMobileAdsInitializeCalled.getAndSet(true)) { + return + } + + // Set your test devices. + MobileAds.setRequestConfiguration( + RequestConfiguration.Builder().setTestDeviceIds(listOf(TEST_DEVICE_HASHED_ID)).build() + ) + + // Initialize the Google Mobile Ads SDK on a background thread. + withContext(Dispatchers.IO) { + MobileAds.initialize(context) { _uiState.update { it.copy(isMobileAdsInitialized = true) } } + } + } + + companion object { + // Check your logcat output for the test device hashed ID e.g. + // "Use RequestConfiguration.Builder().setTestDeviceIds(Arrays.asList("ABCDEF012345")) + // to get test ads on this device" or + // "Use new ConsentDebugSettings.Builder().addTestDeviceHashedId("ABCDEF012345") to set this as + // a debug device". + const val TEST_DEVICE_HASHED_ID = "ABCDEF012345" + + @Volatile private var instance: MainViewModel? = null + + fun getInstance() = + instance ?: synchronized(this) { instance ?: MainViewModel().also { instance = it } } + } +} diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/NavDestinations.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/NavDestinations.kt new file mode 100644 index 000000000..851b7703c --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/NavDestinations.kt @@ -0,0 +1,7 @@ +package com.google.android.gms.example.jetpackcomposedemo + +enum class NavDestinations { + Home, + Banner, + LazyBanner, +} diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/composables/StatusText.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/composables/StatusText.kt deleted file mode 100644 index b6cb9b671..000000000 --- a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/composables/StatusText.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2024 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.android.gms.example.jetpackcomposedemo.composables - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color - -/** - * A composable function to create a status text box. - * - * @param messageColor The background color of the box. - * @param messageText The text to be displayed in the box. - * @param modifier The Modifier to be applied to this button. - */ -@Composable -fun StatusText(messageColor: Color, messageText: String, modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxWidth().background(messageColor)) { - Text(text = messageText, style = MaterialTheme.typography.bodyLarge) - } -} diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/composables/TextButton.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/composables/TextButton.kt deleted file mode 100644 index 2f71f2aef..000000000 --- a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/composables/TextButton.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2024 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.android.gms.example.jetpackcomposedemo.composables - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -/** - * A composable function to create a standard button with text. - * - * @param name The text to be displayed on the button. - * @param enabled Controls whether the button is enabled or disabled (defaults to true). - * @param modifier The Modifier to be applied to this button. - * @param onClick The lambda function to be executed when the button is clicked. - */ -@Composable -fun TextButton( - name: String, - modifier: Modifier = Modifier, - enabled: Boolean = true, - onClick: () -> Unit, -) { - Button(onClick = { onClick() }, enabled = enabled, modifier = modifier.fillMaxWidth()) { - Text(name) - } -} diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/ui/theme/Color.kt b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/ui/theme/Color.kt index cd67390b2..a9f515bc9 100644 --- a/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/ui/theme/Color.kt +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/java/com/google/android/gms/example/jetpackcomposedemo/ui/theme/Color.kt @@ -26,6 +26,4 @@ val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) -val ColorStateLoaded = Color(0xFF009900) -val ColorStateUnloaded = Color(0xFFcc6600) -val ColorStateError = Color(0xFFcc0000) +val LightBlue = Color(0xFFADD8E6) diff --git a/kotlin/advanced/JetpackComposeDemo/app/src/main/res/values/strings.xml b/kotlin/advanced/JetpackComposeDemo/app/src/main/res/values/strings.xml index 393ceb0cc..a20b03bea 100644 --- a/kotlin/advanced/JetpackComposeDemo/app/src/main/res/values/strings.xml +++ b/kotlin/advanced/JetpackComposeDemo/app/src/main/res/values/strings.xml @@ -1,5 +1,25 @@ - Jetpack Compose Demo - Google Mobile Ads Sample - Google Mobile Ads SDK is not initialized. + Google Mobile Ads Jetpack Compose Demo + Ad Inspector + Banner ad was loaded. + Banner ad failed to load. + Banner ad had an impression. + Banner ad was clicked. + Jetpack Compose Demo + Home + Banner + Lazy Banner + Show Privacy Options Form + Google Mobile Ads best practices. + Reload Ad + Google Mobile Ads SDK Version: %s + + Always use test ads when in development. + Use adaptive banners for optimized ad sizes across devices. + Respect user privacy by obtaining consent for personalized ads. + Mediate multiple ad networks for maximum revenue potential. + Prioritize user experience by minimizing ad load times. + Leverage Compose\'s declarative nature for clean ad integration. + Test ad placements thoroughly on various devices and screen sizes. + diff --git a/kotlin/advanced/JetpackComposeDemo/build.gradle.kts b/kotlin/advanced/JetpackComposeDemo/build.gradle.kts index 09709da45..00c8b6116 100644 --- a/kotlin/advanced/JetpackComposeDemo/build.gradle.kts +++ b/kotlin/advanced/JetpackComposeDemo/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") version "8.2.2" apply false id("org.jetbrains.kotlin.android") version "1.9.0" apply false + id("com.android.library") version "8.2.2" apply false } tasks.register("clean", Delete::class) { delete(rootProject.buildDir) } diff --git a/kotlin/advanced/JetpackComposeDemo/compose-util/build.gradle.kts b/kotlin/advanced/JetpackComposeDemo/compose-util/build.gradle.kts new file mode 100644 index 000000000..51cd70415 --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/compose-util/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.google.android.gms.example" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } + buildFeatures { compose = true } + composeOptions { kotlinCompilerExtensionVersion = "1.5.1" } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4") + implementation(platform("androidx.compose:compose-bom:2024.06.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.foundation:foundation") + implementation("com.google.android.gms:play-services-ads:23.2.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose-android:2.8.4") + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/kotlin/advanced/JetpackComposeDemo/compose-util/consumer-rules.pro b/kotlin/advanced/JetpackComposeDemo/compose-util/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/kotlin/advanced/JetpackComposeDemo/compose-util/proguard-rules.pro b/kotlin/advanced/JetpackComposeDemo/compose-util/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/compose-util/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/kotlin/advanced/JetpackComposeDemo/compose-util/src/main/AndroidManifest.xml b/kotlin/advanced/JetpackComposeDemo/compose-util/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8bdb7e14b --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/compose-util/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/kotlin/advanced/JetpackComposeDemo/compose-util/src/main/java/com/google/android/gms/compose_util/BannerAd.kt b/kotlin/advanced/JetpackComposeDemo/compose-util/src/main/java/com/google/android/gms/compose_util/BannerAd.kt new file mode 100644 index 000000000..d90d119ae --- /dev/null +++ b/kotlin/advanced/JetpackComposeDemo/compose-util/src/main/java/com/google/android/gms/compose_util/BannerAd.kt @@ -0,0 +1,79 @@ +package com.google.android.gms.compose_util + +/* + * Copyright 2024 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. + */ + +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LifecycleResumeEffect +import com.google.android.gms.ads.AdView + +/** + * A composable function to display a banner advertisement. + * + * @param adView The Banner AdView. + * @param modifier The modifier to apply to the banner ad. + */ +@Composable +fun BannerAd(adView: AdView, modifier: Modifier = Modifier) { + var parent by remember { mutableStateOf(null) } + if (LocalInspectionMode.current) { + Box { Text(text = "Google Mobile Ads preview banner.", modifier.align(Alignment.Center)) } + return + } + + AndroidView( + modifier = modifier, + factory = { context -> FrameLayout(context).also { parent = it } }, + update = { layout -> + disposeLayout(adView, layout) + layout.addView(adView) + }, + ) + + // Pause and resume the AdView when the lifecycle is paused and resumed. + LifecycleResumeEffect(adView) { + adView.resume() + onPauseOrDispose { adView.pause() } + } + + // Clean up the AdView after use. + DisposableEffect(Unit) { onDispose { disposeLayout(adView, parent) } } +} + +/** Clean up the AdView after use. */ +private fun disposeLayout(adView: AdView, layout: FrameLayout?) { + // Ensure AdViews and Composable references are up to date. + if (adView.parent != null) { + val parent = (adView.parent as ViewGroup) + parent.removeView(adView) + } + if (layout != null && layout.childCount > 0) { + layout.removeAllViews() + } +} diff --git a/kotlin/advanced/JetpackComposeDemo/settings.gradle.kts b/kotlin/advanced/JetpackComposeDemo/settings.gradle.kts index f296a6be8..492a496c3 100644 --- a/kotlin/advanced/JetpackComposeDemo/settings.gradle.kts +++ b/kotlin/advanced/JetpackComposeDemo/settings.gradle.kts @@ -17,3 +17,5 @@ dependencyResolutionManagement { rootProject.name = "Jetpack" include(":app") + +include(":compose-util")