diff --git a/.github/workflows/testBranch.yml b/.github/workflows/testBranch.yml index 86eb842..e2aac81 100644 --- a/.github/workflows/testBranch.yml +++ b/.github/workflows/testBranch.yml @@ -31,6 +31,9 @@ jobs: uses: android-actions/setup-android@v3 - name: Generate kover coverage report + env: + ADMOB_APP_ID: ${{ secrets.ADMOB_APP_ID }} + ADMOB_BANNER_ID: ${{ secrets.ADMOB_BANNER_ID }} run: | chmod +x ./gradlew ./gradlew koverXmlReport diff --git a/app/.gitignore b/app/.gitignore index 42afabf..626c8a5 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +.env \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bcc8a7a..c2fee4c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,9 @@ android { targetSdk = 35 versionCode = 1 versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + resValue("string", "admob_app_id", System.getenv("ADMOB_APP_ID") ?: "") + resValue("string", "admob_banner_id", System.getenv("ADMOB_BANNER_ID") ?: "") } buildTypes { @@ -160,4 +161,7 @@ dependencies { kspTest(libs.hilt.android.compiler) androidTestImplementation(libs.hilt.android.testing) kspAndroidTest(libs.hilt.android.compiler) + + // 구글 광고 + implementation(libs.play.services.ads) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5428f7d..3145652 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,10 @@ android:theme="@style/Theme.NotiManager" android:enableOnBackInvokedCallback="true" tools:targetApi="35"> + + Unit, onAdFailedToLoad: (LoadAdError) -> Unit): AdLoader { + return AdLoader.Builder(context, "ca-app-pub-3940256099942544/2247696110") + .forNativeAd { ad: NativeAd -> + onAdLoaded(ad) // 광고가 로드되었을 때 호출 + } + .withAdListener(object : AdListener() { + override fun onAdFailedToLoad(adError: LoadAdError) { + onAdFailedToLoad(adError) // 광고 로드 실패 시 호출 + } + }) + .withNativeAdOptions(NativeAdOptions.Builder().build()) + .build() +} diff --git a/app/src/main/java/com/example/notimanager/presentation/ui/ads/AdSize.kt b/app/src/main/java/com/example/notimanager/presentation/ui/ads/AdSize.kt new file mode 100644 index 0000000..8c9b6ab --- /dev/null +++ b/app/src/main/java/com/example/notimanager/presentation/ui/ads/AdSize.kt @@ -0,0 +1,38 @@ +package com.example.notimanager.presentation.ui.ads + +import android.app.Activity +import android.content.Context +import android.os.Build +import android.view.WindowMetrics +import com.example.notimanager.R +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.AdSize +import com.google.android.gms.ads.AdView + +object AdsUtil{ + // Get the ad size with screen width. + private fun getAdSize(context: Context): AdSize { + val displayMetrics = context.resources.displayMetrics + val adWidthPixels = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics: WindowMetrics = (context as? Activity)?.windowManager!!.currentWindowMetrics + windowMetrics.bounds.width() + } else { + displayMetrics.widthPixels + } + val density = displayMetrics.density + val adWidth = (adWidthPixels / density).toInt() + return AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, adWidth) + } + + fun getAdView(context: Context): AdView{ + val unitId = context.getString(R.string.admob_banner_id) + val adView = AdView(context) + adView.adUnitId = unitId + adView.setAdSize(getAdSize(context)) + + val adRequest = AdRequest.Builder().build() + adView.loadAd(adRequest) + return adView + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/notimanager/presentation/ui/ads/NativeAdView.kt b/app/src/main/java/com/example/notimanager/presentation/ui/ads/NativeAdView.kt new file mode 100644 index 0000000..33b7290 --- /dev/null +++ b/app/src/main/java/com/example/notimanager/presentation/ui/ads/NativeAdView.kt @@ -0,0 +1,39 @@ +package com.example.notimanager.presentation.ui.ads + +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.viewinterop.AndroidView +import com.google.android.gms.ads.nativead.NativeAd +import com.google.android.gms.ads.nativead.NativeAdView + +@Composable +fun NotiNativeAdView( + ad: NativeAd, + adContent: @Composable (ad: NativeAd, contentView: View) -> Unit, +) { + val contentViewId by remember { mutableIntStateOf(View.generateViewId()) } + val adViewId by remember { mutableIntStateOf(View.generateViewId()) } + AndroidView( + factory = { context -> + val contentView = ComposeView(context).apply { + id = contentViewId + } + NativeAdView(context).apply { + id = adViewId + addView(contentView) + } + }, + update = { view -> + val adView = view.findViewById(adViewId) + val contentView = view.findViewById(contentViewId) + + adView.setNativeAd(ad) + adView.callToActionView = contentView + contentView.setContent { adContent(ad, contentView) } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/notimanager/presentation/ui/ads/NotiNativeAds.kt b/app/src/main/java/com/example/notimanager/presentation/ui/ads/NotiNativeAds.kt new file mode 100644 index 0000000..1a409cc --- /dev/null +++ b/app/src/main/java/com/example/notimanager/presentation/ui/ads/NotiNativeAds.kt @@ -0,0 +1,65 @@ +package com.example.notimanager.presentation.ui.ads + +import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Badge +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.notimanager.R +import com.example.notimanager.common.objects.DateFormatter.toBitmap +import com.example.notimanager.presentation.ui.component.common.AppIconView +import com.google.android.gms.ads.nativead.NativeAd + +@Composable +fun NotiNativeAds(nativeAd: NativeAd) { + NotiNativeAdView(ad = nativeAd) { ad, view -> + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ){ + AppIconView(ad.icon?.drawable?.toBitmap()) + Column { + // 광고 뱃지와 함께 광고 제목 + Row(){ + Image( + painter = painterResource(id = R.drawable.ad_badge), + contentDescription = "ad badge", + modifier = Modifier.size(16.dp) + ) + ad.headline?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold), + ) + } + } + ad.body?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + TextButton( + content = { ad.callToAction?.let { Text(text = it) } }, + onClick = { view.performClick() }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/notimanager/presentation/ui/component/DateFormatterView.kt b/app/src/main/java/com/example/notimanager/presentation/ui/component/DateFormatterView.kt index 031551d..223e103 100644 --- a/app/src/main/java/com/example/notimanager/presentation/ui/component/DateFormatterView.kt +++ b/app/src/main/java/com/example/notimanager/presentation/ui/component/DateFormatterView.kt @@ -113,6 +113,5 @@ fun DateFormatterView( Text(apply) } } - } } \ No newline at end of file diff --git a/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationAppListView.kt b/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationAppListView.kt index fa049f6..472c5a2 100644 --- a/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationAppListView.kt +++ b/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationAppListView.kt @@ -1,6 +1,11 @@ package com.example.notimanager.presentation.ui.component.list +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider @@ -10,13 +15,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.navigation.NavController import com.example.notimanager.presentation.stateholder.state.NotificationAppPriorityState import com.example.notimanager.presentation.stateholder.state.NotificationAppState import com.example.notimanager.presentation.stateholder.viewmodel.NotificationAppPriorityViewModel import com.example.notimanager.presentation.stateholder.viewmodel.NotificationAppViewModel +import com.example.notimanager.presentation.ui.ads.AdsUtil.getAdView import com.example.notimanager.presentation.ui.component.item.NotificationAppItemView @Composable @@ -30,6 +41,10 @@ fun NotificationAppListView( var currentNotiPriority by remember { mutableStateOf(priorityState.notificationAppList) } var currentNoti by remember { mutableStateOf(notificationAppState.notificationAppList) } + // 광고 + val context = LocalContext.current + val adView = getAdView(context) + LaunchedEffect(priorityState.notificationAppList) { if (!priorityState.isLoading) { currentNotiPriority = priorityState.notificationAppList @@ -42,41 +57,48 @@ fun NotificationAppListView( } } - LazyColumn( - Modifier.fillMaxSize() - ) { - items(currentNotiPriority) { notification -> - NotificationAppItemView( - notification = notification, - onClick = { - navController - .navigate( - "titleScreen/${notification.appName}" - ) - }, - viewModel = viewModel, - priorityViewModel = priorityViewModel - ) - } - if (currentNotiPriority.isNotEmpty()){ - item { - HorizontalDivider() + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 56.dp) // AndroidView의 높이만큼 패딩 추가 + ) { + items(currentNotiPriority) { notification -> + NotificationAppItemView( + notification = notification, + onClick = { + navController.navigate("titleScreen/${notification.appName}") + }, + viewModel = viewModel, + priorityViewModel = priorityViewModel + ) + } + if (currentNotiPriority.isNotEmpty()) { + item { + HorizontalDivider() + } } - } - - items(currentNoti) { notification -> - NotificationAppItemView( - notification = notification, - onClick = { - navController - .navigate( - "titleScreen/${notification.appName}" - ) - }, - viewModel = viewModel, - priorityViewModel = priorityViewModel - ) + items(currentNoti) { notification -> + NotificationAppItemView( + notification = notification, + onClick = { + navController.navigate("titleScreen/${notification.appName}") + }, + viewModel = viewModel, + priorityViewModel = priorityViewModel + ) + } } + + AndroidView( + factory = { adView }, + update = {}, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(56.dp) + ) } + } \ No newline at end of file diff --git a/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationListView.kt b/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationListView.kt index 39b5a31..b130aef 100644 --- a/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationListView.kt +++ b/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationListView.kt @@ -1,6 +1,10 @@ package com.example.notimanager.presentation.ui.component.list +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable @@ -9,9 +13,13 @@ 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.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import com.example.notimanager.presentation.stateholder.state.NotificationState +import com.example.notimanager.presentation.ui.ads.AdsUtil.getAdView import com.example.notimanager.presentation.ui.component.item.NotificationItemView @@ -24,6 +32,8 @@ fun NotificationListView( val context = LocalContext.current var currentNoti by remember { mutableStateOf(notificationState.notificationList) } + val adView = getAdView(context) + LaunchedEffect(notificationState.notificationList) { if (!notificationState.isLoading) { currentNoti = notificationState.notificationList @@ -33,15 +43,27 @@ fun NotificationListView( } } - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - items(currentNoti) { notification -> - NotificationItemView (notification = notification, onClick = { - if (notification.intent?.action != null) - context.startActivity(notification.intent) }, - onDelete = onDelete - ) + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(bottom = 56.dp) + ) { + items(currentNoti) { notification -> + NotificationItemView (notification = notification, onClick = { + if (notification.intent?.action != null) + context.startActivity(notification.intent) }, + onDelete = onDelete + ) + } + } + + AndroidView( + factory = { adView }, + update = {}, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(56.dp) + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationTitleListView.kt b/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationTitleListView.kt index 54a3e29..eaf7074 100644 --- a/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationTitleListView.kt +++ b/app/src/main/java/com/example/notimanager/presentation/ui/component/list/NotificationTitleListView.kt @@ -1,6 +1,10 @@ package com.example.notimanager.presentation.ui.component.list +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider @@ -11,13 +15,18 @@ import androidx.compose.runtime.livedata.observeAsState 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.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.navigation.NavController import com.example.notimanager.common.objects.Encoder.getEncodedString import com.example.notimanager.presentation.stateholder.state.NotificationTitlePriorityState import com.example.notimanager.presentation.stateholder.state.NotificationTitleState import com.example.notimanager.presentation.stateholder.viewmodel.NotificationTitlePriorityViewModel import com.example.notimanager.presentation.stateholder.viewmodel.NotificationTitleViewModel +import com.example.notimanager.presentation.ui.ads.AdsUtil.getAdView import com.example.notimanager.presentation.ui.component.item.NotificationTitleItemView @Composable @@ -35,6 +44,9 @@ fun NotificationTitleListView( var currentNotiPriority by remember { mutableStateOf(priorityState.notificationTitleList) } var currentNoti by remember { mutableStateOf(notificationTitleState.notificationTitleList) } + val context = LocalContext.current + val adView = getAdView(context) + LaunchedEffect(priorityState.notificationTitleList) { if (!priorityState.isLoading) { currentNotiPriority = priorityState.notificationTitleList @@ -47,44 +59,56 @@ fun NotificationTitleListView( } } - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - items(currentNotiPriority) { notification -> - NotificationTitleItemView(notification = notification, onClick = { - if (notification.subText == "") navController.navigate("notificationScreen/${viewModel.getAppName()}/${getEncodedString(notification.title)}/False") - else navController.navigate("notificationScreen/${viewModel.getAppName()}/${getEncodedString(notification.subText)}/True") - }, viewModel = viewModel, priorityViewModel = priorityViewModel) - } - - if (currentNotiPriority.isNotEmpty()){ - item { - HorizontalDivider() + Box(){ + LazyColumn( + modifier = Modifier.fillMaxSize().padding(bottom = 56.dp) + ) { + items(currentNotiPriority) { notification -> + NotificationTitleItemView(notification = notification, onClick = { + if (notification.subText == "") navController.navigate("notificationScreen/${viewModel.getAppName()}/${getEncodedString(notification.title)}/False") + else navController.navigate("notificationScreen/${viewModel.getAppName()}/${getEncodedString(notification.subText)}/True") + }, viewModel = viewModel, priorityViewModel = priorityViewModel) } - } - items(currentNoti) { notification -> - NotificationTitleItemView(notification = notification, onClick = { - if (notification.subText == "") { - viewModel.updateAsRead(notification.title) - navController.navigate( - "notificationScreen/${viewModel.getAppName()}/${ - getEncodedString( - notification.title - ) - }/False" - ) + if (currentNotiPriority.isNotEmpty()){ + item { + HorizontalDivider() } - else { - viewModel.updateAsSubText(notification.subText) - navController.navigate( - "notificationScreen/${viewModel.getAppName()}/${ - getEncodedString( - notification.subText - ) - }/True" - ) - }}, viewModel = viewModel, priorityViewModel = priorityViewModel) + } + + items(currentNoti) { notification -> + NotificationTitleItemView(notification = notification, onClick = { + if (notification.subText == "") { + viewModel.updateAsRead(notification.title) + navController.navigate( + "notificationScreen/${viewModel.getAppName()}/${ + getEncodedString( + notification.title + ) + }/False" + ) + } + else { + viewModel.updateAsSubText(notification.subText) + navController.navigate( + "notificationScreen/${viewModel.getAppName()}/${ + getEncodedString( + notification.subText + ) + }/True" + ) + }}, viewModel = viewModel, priorityViewModel = priorityViewModel) + } } + + AndroidView( + factory = { adView }, + update = {}, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(56.dp) + ) } + } \ No newline at end of file diff --git a/app/src/main/res/drawable/ad_badge.xml b/app/src/main/res/drawable/ad_badge.xml new file mode 100644 index 0000000..9cd3608 --- /dev/null +++ b/app/src/main/res/drawable/ad_badge.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e3d1df0..1f52c41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.8.2" +agp = "8.9.0" byteBuddy = "1.17.1" byteBuddyAgent = "1.17.1" coilCompose = "3.1.0" @@ -29,6 +29,7 @@ composeBom = "2025.02.00" mockk = "1.13.16" mockkAndroid = "1.13.16" navigationCompose = "2.8.7" +playServicesAds = "24.0.0" protoliteWellKnownTypes = "18.0.0" roomCompiler = "2.6.1" roomVersion = "2.6.1" @@ -81,6 +82,7 @@ mockk-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-mockk-android = { module = "io.mockk:mockk-android" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } +play-services-ads = { module = "com.google.android.gms:play-services-ads", version.ref = "playServicesAds" } protolite-well-known-types = { module = "com.google.firebase:protolite-well-known-types", version.ref = "protoliteWellKnownTypes" } [plugins] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d922a38..fffb202 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Feb 18 09:46:15 KST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists