diff --git a/build-logic/convention/src/main/kotlin/com/hilingual/buildlogic/BuildType.kt b/build-logic/convention/src/main/kotlin/com/hilingual/buildlogic/BuildType.kt index 7fcb0e47..a208251f 100644 --- a/build-logic/convention/src/main/kotlin/com/hilingual/buildlogic/BuildType.kt +++ b/build-logic/convention/src/main/kotlin/com/hilingual/buildlogic/BuildType.kt @@ -49,6 +49,11 @@ fun Project.configureBuildTypes( "ADMOB_NATIVE_UNIT_ID", properties.getQuotedProperty("admob.native.$prefix.id") ) + buildConfigField( + "String", + "ADMOB_INTERSTITIAL_UNIT_ID", + properties.getQuotedProperty("admob.interstitial.$prefix.id") + ) } commonExtension.apply { diff --git a/core/ads/src/main/java/com/hilingual/core/ads/interstitial/HilingualInterstitialAd.kt b/core/ads/src/main/java/com/hilingual/core/ads/interstitial/HilingualInterstitialAd.kt new file mode 100644 index 00000000..6b5f6423 --- /dev/null +++ b/core/ads/src/main/java/com/hilingual/core/ads/interstitial/HilingualInterstitialAd.kt @@ -0,0 +1,59 @@ +package com.hilingual.core.ads.interstitial + +import android.app.Activity +import com.google.android.libraries.ads.mobile.sdk.common.AdLoadCallback +import com.google.android.libraries.ads.mobile.sdk.common.AdRequest +import com.google.android.libraries.ads.mobile.sdk.common.FullScreenContentError +import com.google.android.libraries.ads.mobile.sdk.common.LoadAdError +import com.google.android.libraries.ads.mobile.sdk.interstitial.InterstitialAd +import com.google.android.libraries.ads.mobile.sdk.interstitial.InterstitialAdEventCallback +import com.google.android.libraries.ads.mobile.sdk.interstitial.InterstitialAdPreloader +import timber.log.Timber + +fun showInterstitialAd( + activity: Activity, + adUnitId: String, + onAdDismissed: () -> Unit, +) { + val preloadedAd = InterstitialAdPreloader.pollAd(adUnitId) + + if (preloadedAd != null) { + Timber.tag("GMA").d("프리로드된 전면 광고를 표시합니다.") + preloadedAd.adEventCallback = createEventCallback(onAdDismissed) + preloadedAd.show(activity) + } else { + Timber.tag("GMA").d("프리로드된 광고 없음, 새로 로드 후 표시합니다.") + val adRequest = AdRequest.Builder(adUnitId).build() + InterstitialAd.load( + adRequest, + object : AdLoadCallback { + override fun onAdLoaded(ad: InterstitialAd) { + if (!activity.isFinishing && !activity.isDestroyed) { + ad.adEventCallback = createEventCallback(onAdDismissed) + ad.show(activity) + } else { + Timber.tag("GMA").w("Activity가 이미 종료 상태라 전면 광고를 표시하지 않습니다.") + onAdDismissed() + } + } + + override fun onAdFailedToLoad(adError: LoadAdError) { + Timber.tag("GMA").e("전면 광고 로드 실패: %s", adError) + onAdDismissed() + } + }, + ) + } +} + +private fun createEventCallback(onAdDismissed: () -> Unit) = object : InterstitialAdEventCallback { + override fun onAdDismissedFullScreenContent() { + Timber.tag("GMA").d("전면 광고 닫힘 → 피드백 화면으로 이동") + onAdDismissed() + } + + override fun onAdFailedToShowFullScreenContent(fullScreenContentError: FullScreenContentError) { + Timber.tag("GMA").e("전면 광고 표시 실패: %s", fullScreenContentError) + onAdDismissed() + } +} diff --git a/core/ads/src/main/java/com/hilingual/core/ads/manager/AdsPreloadManager.kt b/core/ads/src/main/java/com/hilingual/core/ads/manager/AdsPreloadManager.kt index f33ef222..21033d54 100644 --- a/core/ads/src/main/java/com/hilingual/core/ads/manager/AdsPreloadManager.kt +++ b/core/ads/src/main/java/com/hilingual/core/ads/manager/AdsPreloadManager.kt @@ -4,4 +4,5 @@ import com.hilingual.core.ads.banner.BannerAdType interface AdsPreloadManager { fun preloadBanner(type: BannerAdType) + fun preloadInterstitial(adUnitId: String) } diff --git a/core/ads/src/main/java/com/hilingual/core/ads/manager/AdsPreloadManagerImpl.kt b/core/ads/src/main/java/com/hilingual/core/ads/manager/AdsPreloadManagerImpl.kt index 5959c991..48bd3cd6 100644 --- a/core/ads/src/main/java/com/hilingual/core/ads/manager/AdsPreloadManagerImpl.kt +++ b/core/ads/src/main/java/com/hilingual/core/ads/manager/AdsPreloadManagerImpl.kt @@ -4,7 +4,9 @@ import android.content.Context import com.google.android.libraries.ads.mobile.sdk.banner.AdSize import com.google.android.libraries.ads.mobile.sdk.banner.BannerAdPreloader import com.google.android.libraries.ads.mobile.sdk.banner.BannerAdRequest +import com.google.android.libraries.ads.mobile.sdk.common.AdRequest import com.google.android.libraries.ads.mobile.sdk.common.PreloadConfiguration +import com.google.android.libraries.ads.mobile.sdk.interstitial.InterstitialAdPreloader import com.hilingual.core.ads.banner.BannerAdType import com.hilingual.core.ads.utils.screenWidthDp import dagger.hilt.android.qualifiers.ApplicationContext @@ -36,4 +38,16 @@ internal class AdsPreloadManagerImpl @Inject constructor( Timber.tag("GMA").e(e, "배너 프리로딩 시작 실패: %s", type.adUnitId) } } + + override fun preloadInterstitial(adUnitId: String) { + try { + val adRequest = AdRequest.Builder(adUnitId).build() + val preloadConfig = PreloadConfiguration(adRequest) + + InterstitialAdPreloader.start(adUnitId, preloadConfig) + Timber.tag("GMA").d("GMA Next Gen 전면 광고 프리로딩 시작: %s", adUnitId) + } catch (e: Exception) { + Timber.tag("GMA").e(e, "전면 광고 프리로딩 실패: %s", adUnitId) + } + } } diff --git a/data/diary/src/main/java/com/hilingual/data/diary/datasource/DiaryRemoteDataSource.kt b/data/diary/src/main/java/com/hilingual/data/diary/datasource/DiaryRemoteDataSource.kt index 498c96b7..ef797a1f 100644 --- a/data/diary/src/main/java/com/hilingual/data/diary/datasource/DiaryRemoteDataSource.kt +++ b/data/diary/src/main/java/com/hilingual/data/diary/datasource/DiaryRemoteDataSource.kt @@ -57,4 +57,8 @@ interface DiaryRemoteDataSource { suspend fun deleteDiary( diaryId: Long, ): BaseResponse + + suspend fun patchAdWatch( + diaryId: Long, + ): BaseResponse } diff --git a/data/diary/src/main/java/com/hilingual/data/diary/datasourceimpl/DiaryRemoteDataSourceImpl.kt b/data/diary/src/main/java/com/hilingual/data/diary/datasourceimpl/DiaryRemoteDataSourceImpl.kt index b593550f..35631720 100644 --- a/data/diary/src/main/java/com/hilingual/data/diary/datasourceimpl/DiaryRemoteDataSourceImpl.kt +++ b/data/diary/src/main/java/com/hilingual/data/diary/datasourceimpl/DiaryRemoteDataSourceImpl.kt @@ -81,4 +81,7 @@ internal class DiaryRemoteDataSourceImpl @Inject constructor( override suspend fun deleteDiary(diaryId: Long): BaseResponse = diaryService.deleteDiary(diaryId) + + override suspend fun patchAdWatch(diaryId: Long): BaseResponse = + diaryService.patchAdWatch(diaryId) } diff --git a/data/diary/src/main/java/com/hilingual/data/diary/dto/response/DiaryContentResponseDto.kt b/data/diary/src/main/java/com/hilingual/data/diary/dto/response/DiaryContentResponseDto.kt index 132cbf34..442b3cf9 100644 --- a/data/diary/src/main/java/com/hilingual/data/diary/dto/response/DiaryContentResponseDto.kt +++ b/data/diary/src/main/java/com/hilingual/data/diary/dto/response/DiaryContentResponseDto.kt @@ -32,6 +32,7 @@ data class DiaryContentResponseDto( val imageUrl: String?, @SerialName("isPublished") val isPublished: Boolean, + /*TODO:: isAdWatched — 서버 연결 시 추가*/ ) @Serializable diff --git a/data/diary/src/main/java/com/hilingual/data/diary/model/DiaryModel.kt b/data/diary/src/main/java/com/hilingual/data/diary/model/DiaryModel.kt index 84a6d5d5..df004807 100644 --- a/data/diary/src/main/java/com/hilingual/data/diary/model/DiaryModel.kt +++ b/data/diary/src/main/java/com/hilingual/data/diary/model/DiaryModel.kt @@ -24,6 +24,7 @@ data class DiaryContentModel( val diffRanges: List, val imageUrl: String?, val isPublished: Boolean, + val isAdWatched: Boolean, ) data class DiaryContentFeedback( @@ -41,4 +42,5 @@ internal fun DiaryContentResponseDto.toModel() = DiaryContentModel( ) }, isPublished = this.isPublished, + isAdWatched = false, // TODO:: 서버 연결 시 this.isAdWatched로 교체 ) diff --git a/data/diary/src/main/java/com/hilingual/data/diary/repository/DiaryRepository.kt b/data/diary/src/main/java/com/hilingual/data/diary/repository/DiaryRepository.kt index 3a1e4e83..1fbf1e7a 100644 --- a/data/diary/src/main/java/com/hilingual/data/diary/repository/DiaryRepository.kt +++ b/data/diary/src/main/java/com/hilingual/data/diary/repository/DiaryRepository.kt @@ -54,4 +54,8 @@ interface DiaryRepository { suspend fun deleteDiary( diaryId: Long, ): Result + + suspend fun patchAdWatch( + diaryId: Long, + ): Result } diff --git a/data/diary/src/main/java/com/hilingual/data/diary/repositoryimpl/DiaryRepositoryImpl.kt b/data/diary/src/main/java/com/hilingual/data/diary/repositoryimpl/DiaryRepositoryImpl.kt index eba78620..963b8b9b 100644 --- a/data/diary/src/main/java/com/hilingual/data/diary/repositoryimpl/DiaryRepositoryImpl.kt +++ b/data/diary/src/main/java/com/hilingual/data/diary/repositoryimpl/DiaryRepositoryImpl.kt @@ -99,4 +99,9 @@ internal class DiaryRepositoryImpl @Inject constructor( suspendRunCatching { diaryRemoteDataSource.deleteDiary(diaryId) } + + override suspend fun patchAdWatch(diaryId: Long): Result = + suspendRunCatching { + diaryRemoteDataSource.patchAdWatch(diaryId) + } } diff --git a/data/diary/src/main/java/com/hilingual/data/diary/service/DiaryService.kt b/data/diary/src/main/java/com/hilingual/data/diary/service/DiaryService.kt index ce950b81..2fcf972b 100644 --- a/data/diary/src/main/java/com/hilingual/data/diary/service/DiaryService.kt +++ b/data/diary/src/main/java/com/hilingual/data/diary/service/DiaryService.kt @@ -70,4 +70,9 @@ interface DiaryService { suspend fun deleteDiary( @Path("diaryId") diaryId: Long, ): BaseResponse + + @PATCH("/api/v1/diaries/{diaryId}/ad-watch") + suspend fun patchAdWatch( + @Path("diaryId") diaryId: Long, + ): BaseResponse } diff --git a/presentation/diaryfeedback/build.gradle.kts b/presentation/diaryfeedback/build.gradle.kts index 43b1ab5c..d35c65f5 100644 --- a/presentation/diaryfeedback/build.gradle.kts +++ b/presentation/diaryfeedback/build.gradle.kts @@ -24,5 +24,6 @@ android { } dependencies { + implementation(projects.core.ads) implementation(projects.data.diary) } diff --git a/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackScreen.kt b/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackScreen.kt index e5352b27..f9d371c3 100644 --- a/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackScreen.kt +++ b/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackScreen.kt @@ -16,6 +16,7 @@ package com.hilingual.presentation.diaryfeedback import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -41,6 +42,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hilingual.core.ads.BuildConfig +import com.hilingual.core.ads.interstitial.showInterstitialAd import com.hilingual.core.common.analytics.FakeTracker import com.hilingual.core.common.analytics.Page.FEEDBACK import com.hilingual.core.common.analytics.Tracker @@ -79,6 +82,7 @@ internal fun DiaryFeedbackRoute( viewModel: DiaryFeedbackViewModel = hiltViewModel(), ) { val context = LocalContext.current + val activity = LocalActivity.current val state by viewModel.uiState.collectAsStateWithLifecycle() var isImageDetailVisible by remember { mutableStateOf(false) } @@ -87,6 +91,10 @@ internal fun DiaryFeedbackRoute( val messageController = LocalMessageController.current val tracker = LocalTracker.current + LaunchedEffect(Unit) { + viewModel.loadInitialData() + } + BackHandler { if (isImageDetailVisible) { isImageDetailVisible = false @@ -97,7 +105,20 @@ internal fun DiaryFeedbackRoute( viewModel.sideEffect.collectSideEffect { when (it) { - is DiaryFeedbackSideEffect.ShowErrorDialog -> dialogTrigger.show(onClick = navigateUp) + is DiaryFeedbackSideEffect.ShowInterstitialAd -> { + if (activity != null) { + showInterstitialAd( + activity = activity, + adUnitId = BuildConfig.ADMOB_INTERSTITIAL_UNIT_ID, + onAdDismissed = viewModel::fetchAdWatched, + ) + } else { + viewModel.fetchAdWatched() + } + } + + is DiaryFeedbackSideEffect.ShowErrorDialog -> + dialogTrigger.show(onClick = navigateUp) is DiaryFeedbackSideEffect.ShowDiaryPublishSnackbar -> { messageController( @@ -142,40 +163,52 @@ internal fun DiaryFeedbackRoute( tracker.logEvent(trigger = TriggerType.VIEW, page = FEEDBACK, event = "page") } - DiaryFeedbackScreen( - paddingValues = paddingValues, - uiState = state, - diaryId = viewModel.diaryId, - onBackClick = { - tracker.logEvent( - trigger = TriggerType.CLICK, - page = FEEDBACK, - event = "back_feedback", - properties = mapOf( - "entry_id" to viewModel.diaryId, - "back_source" to "ui_button", - ), - ) - navigateUp() - }, - onReportClick = { context.launchCustomTabs(UrlConstant.FEEDBACK_REPORT) }, - isImageDetailVisible = isImageDetailVisible, - onChangeImageDetailVisible = { isImageDetailVisible = !isImageDetailVisible }, - onToggleIsPublished = { isPublished -> - if (isPublished) { - tracker.logEvent( - trigger = TriggerType.CLICK, - page = FEEDBACK, - event = "submitted_post_diary", - properties = mapOf("entry_id" to viewModel.diaryId), + when (val currentState = state) { + is UiState.Success -> { + if (!currentState.data.isAdWatched) { + HilingualLoadingIndicator() + } else { + DiaryFeedbackScreen( + paddingValues = paddingValues, + uiState = currentState, + diaryId = viewModel.diaryId, + onBackClick = { + tracker.logEvent( + trigger = TriggerType.CLICK, + page = FEEDBACK, + event = "back_feedback", + properties = mapOf( + "entry_id" to viewModel.diaryId, + "back_source" to "ui_button", + ), + ) + navigateUp() + }, + onReportClick = { context.launchCustomTabs(UrlConstant.FEEDBACK_REPORT) }, + isImageDetailVisible = isImageDetailVisible, + onChangeImageDetailVisible = { isImageDetailVisible = !isImageDetailVisible }, + onToggleIsPublished = { isPublished -> + if (isPublished) { + tracker.logEvent( + trigger = TriggerType.CLICK, + page = FEEDBACK, + event = "submitted_post_diary", + properties = mapOf("entry_id" to viewModel.diaryId), + ) + } + viewModel.toggleIsPublished(isPublished) + }, + onToggleBookmark = viewModel::toggleBookmark, + onDeleteDiary = { /* viewModel::deleteDiary 수정기능 도입까지 삭제 기능 지원중단 */ }, + tracker = tracker, ) } - viewModel.toggleIsPublished(isPublished) - }, - onToggleBookmark = viewModel::toggleBookmark, - onDeleteDiary = { /* viewModel::deleteDiary 수정기능 도입까지 삭제 기능 지원중단 */ }, - tracker = tracker, - ) + } + + is UiState.Loading -> HilingualLoadingIndicator() + + else -> {} + } } @Composable diff --git a/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackUiState.kt b/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackUiState.kt index 73a2787f..8befef9c 100644 --- a/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackUiState.kt +++ b/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackUiState.kt @@ -30,6 +30,7 @@ import kotlinx.collections.immutable.toImmutableList internal data class DiaryFeedbackUiState( val writtenDate: String = "", val isPublished: Boolean = false, + val isAdWatched: Boolean = false, val diaryContent: DiaryContent = DiaryContent(), val feedbackList: ImmutableList = persistentListOf(), val recommendExpressionList: ImmutableList = persistentListOf(), diff --git a/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackViewModel.kt b/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackViewModel.kt index 87c03903..57c15255 100644 --- a/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackViewModel.kt +++ b/presentation/diaryfeedback/src/main/java/com/hilingual/presentation/diaryfeedback/DiaryFeedbackViewModel.kt @@ -54,16 +54,15 @@ internal class DiaryFeedbackViewModel @Inject constructor( private val _sideEffect = MutableSharedFlow() val sideEffect: SharedFlow = _sideEffect.asSharedFlow() - init { - loadInitialData() - } - - private fun loadInitialData() { + fun loadInitialData() { viewModelScope.launch { suspendRunCatching { requestDiaryFeedbackData() }.onSuccess { newUiState -> _uiState.update { UiState.Success(newUiState) } + if (!newUiState.isAdWatched) { + _sideEffect.emit(DiaryFeedbackSideEffect.ShowInterstitialAd) + } }.onLogFailure { _uiState.update { UiState.Failure } _sideEffect.emit( @@ -85,6 +84,7 @@ internal class DiaryFeedbackViewModel @Inject constructor( DiaryFeedbackUiState( isPublished = diaryResult.isPublished, + isAdWatched = diaryResult.isAdWatched, writtenDate = diaryResult.writtenDate, diaryContent = diaryResult.toState(), feedbackList = feedbacksResult.map { it.toState() }.toImmutableList(), @@ -92,6 +92,22 @@ internal class DiaryFeedbackViewModel @Inject constructor( ) } + fun fetchAdWatched() { + _uiState.updateSuccess { it.copy(isAdWatched = true) } + + viewModelScope.launch { + diaryRepository.patchAdWatch(diaryId) + .onLogFailure { + _uiState.updateSuccess { it.copy(isAdWatched = true) } + /* + * TODO:: 서버 동기화 실패 처리 + * patchAdWatch API 실패 시 서버는 여전히 isAdWatched = false 상태입니다. + * WorkManager로 Sync 필요 + */ + } + } + } + fun toggleIsPublished(isPublished: Boolean) { val currentState = _uiState.value if (currentState !is UiState.Success) return @@ -191,6 +207,7 @@ internal class DiaryFeedbackViewModel @Inject constructor( sealed interface DiaryFeedbackSideEffect { data object NavigateToHome : DiaryFeedbackSideEffect data object ShowErrorDialog : DiaryFeedbackSideEffect + data object ShowInterstitialAd : DiaryFeedbackSideEffect data class ShowDiaryPublishSnackbar(val message: String, val actionLabel: String) : DiaryFeedbackSideEffect data class ShowVocaOverflowSnackbar(val message: String, val actionLabel: String) : DiaryFeedbackSideEffect data class ShowToast(val message: String) : DiaryFeedbackSideEffect diff --git a/presentation/diarywrite/build.gradle.kts b/presentation/diarywrite/build.gradle.kts index fdb00b0b..fc3fdc3f 100644 --- a/presentation/diarywrite/build.gradle.kts +++ b/presentation/diarywrite/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(libs.balloon.compose) implementation(projects.data.calendar) implementation(projects.data.diary) + implementation(projects.core.ads) implementation(libs.lottie) } diff --git a/presentation/diarywrite/src/main/java/com/hilingual/presentation/diarywrite/DiaryWriteViewModel.kt b/presentation/diarywrite/src/main/java/com/hilingual/presentation/diarywrite/DiaryWriteViewModel.kt index f0ade821..450e6c4b 100644 --- a/presentation/diarywrite/src/main/java/com/hilingual/presentation/diarywrite/DiaryWriteViewModel.kt +++ b/presentation/diarywrite/src/main/java/com/hilingual/presentation/diarywrite/DiaryWriteViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.hilingual.core.ads.manager.AdsPreloadManager import com.hilingual.core.common.extension.onLogFailure import com.hilingual.core.common.util.UiState import com.hilingual.core.navigation.DiaryWriteMode @@ -50,6 +51,7 @@ internal class DiaryWriteViewModel @Inject constructor( private val diaryRepository: DiaryRepository, private val diaryLocalRepository: DiaryLocalRepository, private val textRecognitionRepository: TextRecognitionRepository, + adsPreloadManager: AdsPreloadManager, ) : ViewModel() { private val route: DiaryWrite = savedStateHandle.toRoute() @@ -68,6 +70,7 @@ internal class DiaryWriteViewModel @Inject constructor( init { getTopic(route.selectedDate) + adsPreloadManager.preloadInterstitial(BuildConfig.ADMOB_INTERSTITIAL_UNIT_ID) when (route.mode) { DiaryWriteMode.DEFAULT -> loadDiaryTemp()