diff --git a/app/build.gradle b/app/build.gradle index bd9df773..a563d07b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -41,6 +41,7 @@ android { buildConfigField "String", "REMOTE_KEY_APP_VERSION", properties["REMOTE_KEY_APP_VERSION"] buildConfigField "String", "REMOTE_KEY_FORCE_UPDATE", properties["REMOTE_KEY_FORCE_UPDATE"] buildConfigField "String", "KAKAO_CHANNEL_ID", properties["KAKAO_CHANNEL_ID"] + buildConfigField "String", "ADMOB_REWARDED_AD_UNIT_ID", properties["ADMOB_REWARDED_AD_UNIT_ID"] } signingConfigs { @@ -184,6 +185,7 @@ dependencies { // Google implementation libs.play.services.auth + implementation libs.play.services.ads // Logging implementation libs.timber diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2008ace7..623260ca 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -133,6 +133,11 @@ android:name="com.naver.maps.map.NCP_KEY_ID" android:value="${NAVER_CLIENT_ID}" /> + + + > = + remoteMarkerQuotaDataSource.getMarkerQuota().mapToFlowResult { + it.toMarkerQuota() + } + + override suspend fun consumeMarkerQuota(amount: Int): Flow> = + remoteMarkerQuotaDataSource.consumeMarkerQuota(amount = amount).mapToFlowResult { + it.toMarkerQuota() + } + + override suspend fun grantMarkerReward( + amount: Int, + rewardTransactionId: String + ): Flow> = + remoteMarkerQuotaDataSource.grantMarkerReward( + amount = amount, + rewardTransactionId = rewardTransactionId + ).mapToFlowResult { + it.toMarkerRewardResult() + } +} diff --git a/app/src/main/java/com/runnect/runnect/data/service/MarkerQuotaService.kt b/app/src/main/java/com/runnect/runnect/data/service/MarkerQuotaService.kt new file mode 100644 index 00000000..ac544930 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/data/service/MarkerQuotaService.kt @@ -0,0 +1,25 @@ +package com.runnect.runnect.data.service + +import com.runnect.runnect.data.dto.request.RequestConsumeMarkerQuota +import com.runnect.runnect.data.dto.request.RequestGrantMarkerReward +import com.runnect.runnect.data.dto.response.ResponseGrantMarkerReward +import com.runnect.runnect.data.dto.response.ResponseMarkerQuota +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface MarkerQuotaService { + + @GET("/api/marker-quota") + suspend fun getMarkerQuota(): Result + + @POST("/api/marker-quota/consume") + suspend fun consumeMarkerQuota( + @Body requestConsumeMarkerQuota: RequestConsumeMarkerQuota, + ): Result + + @POST("/api/marker-quota/reward") + suspend fun grantMarkerReward( + @Body requestGrantMarkerReward: RequestGrantMarkerReward, + ): Result +} diff --git a/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteMarkerQuotaDataSource.kt b/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteMarkerQuotaDataSource.kt new file mode 100644 index 00000000..4d08081f --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteMarkerQuotaDataSource.kt @@ -0,0 +1,20 @@ +package com.runnect.runnect.data.source.remote + +import com.runnect.runnect.data.dto.request.RequestConsumeMarkerQuota +import com.runnect.runnect.data.dto.request.RequestGrantMarkerReward +import com.runnect.runnect.data.service.MarkerQuotaService +import javax.inject.Inject + +class RemoteMarkerQuotaDataSource @Inject constructor( + private val markerQuotaService: MarkerQuotaService, +) { + suspend fun getMarkerQuota() = markerQuotaService.getMarkerQuota() + + suspend fun consumeMarkerQuota(amount: Int) = + markerQuotaService.consumeMarkerQuota(RequestConsumeMarkerQuota(amount = amount)) + + suspend fun grantMarkerReward(amount: Int, rewardTransactionId: String) = + markerQuotaService.grantMarkerReward( + RequestGrantMarkerReward(amount = amount, rewardTransactionId = rewardTransactionId) + ) +} diff --git a/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt b/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt index b257cb68..0cae0797 100644 --- a/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt +++ b/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt @@ -8,6 +8,7 @@ import com.runnect.runnect.domain.repository.BannerRepository import com.runnect.runnect.domain.repository.CourseRepository import com.runnect.runnect.domain.repository.DepartureSearchRepository import com.runnect.runnect.domain.repository.LoginRepository +import com.runnect.runnect.domain.repository.MarkerQuotaRepository import com.runnect.runnect.domain.repository.ReverseGeocodingRepository import com.runnect.runnect.domain.repository.StorageRepository import com.runnect.runnect.domain.repository.UserRepository @@ -47,4 +48,8 @@ interface RepositoryModule { @Singleton @Binds fun bindBannerRepository(bannerRepositoryImpl: BannerRepositoryImpl): BannerRepository + + @Singleton + @Binds + fun bindMarkerQuotaRepository(markerQuotaRepositoryImpl: MarkerQuotaRepositoryImpl): MarkerQuotaRepository } \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt b/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt index 8de7ec35..0ec58917 100644 --- a/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt +++ b/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt @@ -3,6 +3,7 @@ package com.runnect.runnect.di import com.runnect.runnect.data.service.BannerService import com.runnect.runnect.data.service.CourseService import com.runnect.runnect.data.service.LoginService +import com.runnect.runnect.data.service.MarkerQuotaService import com.runnect.runnect.data.service.ReverseGeocodingService import com.runnect.runnect.data.service.SearchService import com.runnect.runnect.data.service.UserService @@ -46,4 +47,9 @@ object ServiceModule { @Provides fun providePBannerService(@RetrofitModule.RetrofitV2 retrofitV2: Retrofit) = retrofitV2.create(BannerService::class.java) + + @Singleton + @Provides + fun provideMarkerQuotaService(@RetrofitModule.RetrofitV2 retrofitV2: Retrofit) = + retrofitV2.create(MarkerQuotaService::class.java) } \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/domain/entity/MarkerQuota.kt b/app/src/main/java/com/runnect/runnect/domain/entity/MarkerQuota.kt new file mode 100644 index 00000000..49ab3844 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/domain/entity/MarkerQuota.kt @@ -0,0 +1,5 @@ +package com.runnect.runnect.domain.entity + +data class MarkerQuota( + val balance: Int +) diff --git a/app/src/main/java/com/runnect/runnect/domain/entity/MarkerRewardResult.kt b/app/src/main/java/com/runnect/runnect/domain/entity/MarkerRewardResult.kt new file mode 100644 index 00000000..0928f8b6 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/domain/entity/MarkerRewardResult.kt @@ -0,0 +1,6 @@ +package com.runnect.runnect.domain.entity + +data class MarkerRewardResult( + val granted: Boolean, + val balance: Int +) diff --git a/app/src/main/java/com/runnect/runnect/domain/repository/MarkerQuotaRepository.kt b/app/src/main/java/com/runnect/runnect/domain/repository/MarkerQuotaRepository.kt new file mode 100644 index 00000000..ac614390 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/domain/repository/MarkerQuotaRepository.kt @@ -0,0 +1,14 @@ +package com.runnect.runnect.domain.repository + +import com.runnect.runnect.domain.entity.MarkerQuota +import com.runnect.runnect.domain.entity.MarkerRewardResult +import kotlinx.coroutines.flow.Flow + +interface MarkerQuotaRepository { + + suspend fun getMarkerQuota(): Flow> + + suspend fun consumeMarkerQuota(amount: Int): Flow> + + suspend fun grantMarkerReward(amount: Int, rewardTransactionId: String): Flow> +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/draw/DrawActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/draw/DrawActivity.kt index f75df882..0675348d 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/draw/DrawActivity.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/draw/DrawActivity.kt @@ -41,6 +41,7 @@ import com.runnect.runnect.data.dto.CourseData import com.runnect.runnect.data.dto.SearchResultEntity import com.runnect.runnect.data.dto.UploadLatLng import com.runnect.runnect.databinding.ActivityDrawBinding +import com.runnect.runnect.databinding.BottomsheetMarkerQuotaEmptyBinding import com.runnect.runnect.databinding.BottomsheetRequireCourseNameBinding import com.runnect.runnect.databinding.CustomDialogMakeCourseBinding import com.runnect.runnect.presentation.MainActivity @@ -55,6 +56,7 @@ import com.runnect.runnect.util.custom.dialog.RequireLoginDialogFragment import com.runnect.runnect.util.extension.PermissionUtil import com.runnect.runnect.util.extension.hideKeyboard import com.runnect.runnect.util.extension.setActivityDialog +import com.runnect.runnect.util.ad.RewardedAdManager import com.runnect.runnect.util.extension.showToast import com.runnect.runnect.util.multipart.ContentUriRequestBody import dagger.hilt.android.AndroidEntryPoint @@ -74,6 +76,9 @@ class DrawActivity : BindingActivity(R.layout.activity_draw @Inject lateinit var visitorModeManager: VisitorModeManager + @Inject + lateinit var rewardedAdManager: RewardedAdManager + private lateinit var locationSource: FusedLocationSource private lateinit var currentLocation: LatLng private lateinit var fusedLocation: FusedLocationProviderClient // 현재 위치 반환 객체 변수 @@ -102,6 +107,13 @@ class DrawActivity : BindingActivity(R.layout.activity_draw private var distanceSum: Float = 0.0f private var sumList = mutableListOf() private var isMarkerAvailable: Boolean = false + + /** + * null: 서버 조회가 아직 끝나지 않은 상태(진짜 0과 구분해야 함). + * 이 값이 null인 동안은 마커 배치를 막아, 잔량 조회가 끝나기 전에 "재화가 + * 부족하다"고 잘못 판단해 팝업을 띄우는 일이 없게 한다. + */ + private var markerQuotaBalance: Int? = null val isVisitorMode: Boolean get() = visitorModeManager.isVisitorMode var isFirstInit: Boolean = true @@ -118,6 +130,9 @@ class DrawActivity : BindingActivity(R.layout.activity_draw addObserver() backButton() courseFinish() + viewModel.fetchMarkerQuota() + rewardedAdManager.load(this) + setupMarkerQuotaChargeButton() } private fun getSearchIntent() { @@ -406,6 +421,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw frameCourseDistance.isVisible = true tvCourseDistanceRecord.isVisible = true tvCourseDistanceKm.isVisible = true + frameMarkerQuota.isVisible = true } } @@ -438,6 +454,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw private fun addObserver() { observeIsBtnAvailable() observeDrawState() + observeMarkerQuota() viewModel.reverseGeocodingResult.observe(this) { val buildingName = viewModel.reverseGeocodingResult.value!!.buildingName @@ -480,6 +497,78 @@ class DrawActivity : BindingActivity(R.layout.activity_draw } } + private fun observeMarkerQuota() { + viewModel.markerQuotaBalance.observe(this) { balance -> + markerQuotaBalance = balance + binding.tvMarkerQuotaBalance.text = balance.toString() + } + + // drawState(UiState)의 Failure는 업로드 실패/잔량 부족 둘 다에서 발생하는데, + // loading bar 숨김은 이미 observeDrawState()가 처리한다. 여기서는 "잔량 + // 부족이라 실패했다"는 사실만 별도 이벤트로 받아 팝업을 띄운다. + viewModel.markerQuotaInsufficientEvent.observe(this) { isInsufficient -> + if (isInsufficient) { + showToast(NOTIFY_MARKER_QUOTA_INSUFFICIENT) + showMarkerQuotaEmptyBottomSheet() + viewModel.consumeMarkerQuotaInsufficientEventHandled() + } + } + + viewModel.markerRewardGrantState.observe(this) { + if (it == UiState.Success) { + showToast(NOTIFY_MARKER_QUOTA_CHARGED) + } + } + } + + private fun setupMarkerQuotaChargeButton() { + binding.btnMarkerQuotaCharge.setOnClickListener { + watchRewardedAdForMarkerQuota() + } + } + + private fun watchRewardedAdForMarkerQuota() { + rewardedAdManager.show( + activity = this, + onUserEarnedReward = { rewardTransactionId -> + viewModel.grantMarkerRewardFromAd(rewardTransactionId) + }, + onNotReady = { + showToast(NOTIFY_AD_NOT_READY) + } + ) + } + + /** + * 마커 잔량이 0인데 지도를 탭했을 때 뜨는 팝업. + */ + private fun showMarkerQuotaEmptyBottomSheet() { + val bottomSheetBinding = BottomsheetMarkerQuotaEmptyBinding.inflate(layoutInflater) + val bottomSheetDialog = BottomSheetDialog(this) + bottomSheetDialog.setContentView(bottomSheetBinding.root) + + bottomSheetBinding.btnMarkerQuotaWatchAd.setOnClickListener { + bottomSheetDialog.dismiss() + watchRewardedAdForMarkerQuota() + } + bottomSheetBinding.tvMarkerQuotaClose.setOnClickListener { + bottomSheetDialog.dismiss() + } + bottomSheetDialog.show() + } + + private fun decrementMarkerQuotaBalance() { + val updated = (markerQuotaBalance ?: 0) - 1 + markerQuotaBalance = updated + binding.tvMarkerQuotaBalance.text = updated.toString() + } + + private fun incrementMarkerQuotaBalance() { + val updated = (markerQuotaBalance ?: 0) + 1 + markerQuotaBalance = updated + binding.tvMarkerQuotaBalance.text = updated.toString() + } + private fun showLoadingBar() { binding.indeterminateBar.isVisible = true } @@ -664,11 +753,22 @@ class DrawActivity : BindingActivity(R.layout.activity_draw if (!isMarkerAvailable) return@setOnMapClickListener viewModel.isBtnAvailable.value = true + val balance = markerQuotaBalance + if (balance == null) { + showToast(NOTIFY_MARKER_QUOTA_LOADING) + return@setOnMapClickListener + } + if (touchList.size < MAX_MARKER_NUM) { - addCoordsToTouchList(coord) - setRouteMarker(coord) - generateRouteLine(coord) - calcDistance() + if (balance > 0) { + addCoordsToTouchList(coord) + setRouteMarker(coord) + generateRouteLine(coord) + calcDistance() + decrementMarkerQuotaBalance() + } else { + showMarkerQuotaEmptyBottomSheet() + } } else { Toast.makeText(this, NOTIFY_LIMIT_MARKER_NUM, Toast.LENGTH_SHORT).show() } @@ -729,6 +829,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw touchList.removeAt(touchList.lastIndex) markerList.last().map = null markerList.removeAt(markerList.lastIndex) + incrementMarkerQuotaBalance() } private fun updateRouteLineData() { @@ -803,7 +904,8 @@ class DrawActivity : BindingActivity(R.layout.activity_draw ) ) //Uri -> RequestBody setViewModelValue(calcDistanceList) - viewModel.uploadCourse() + showLoadingBar() + viewModel.saveCourse(touchList.size) } } @@ -863,6 +965,10 @@ class DrawActivity : BindingActivity(R.layout.activity_draw const val EXTRA_FRAGMENT_REPLACEMENT_DIRECTION = "fragmentReplacementDirection" const val CUSTOM_DEPARTURE = "내가 설정한 출발지" const val NOTIFY_LIMIT_MARKER_NUM = "마커는 20개까지 생성 가능합니다" + const val NOTIFY_MARKER_QUOTA_INSUFFICIENT = "마커 재화가 부족합니다. 광고를 보고 채워주세요" + const val NOTIFY_MARKER_QUOTA_LOADING = "마커 정보를 불러오는 중이에요. 잠시 후 다시 시도해주세요" + const val NOTIFY_MARKER_QUOTA_CHARGED = "마커가 충전되었습니다" + const val NOTIFY_AD_NOT_READY = "광고를 불러오는 중입니다. 잠시 후 다시 시도해주세요" const val ERROR_COURSE_NULL = "Error: Course data is incomplete" } diff --git a/app/src/main/java/com/runnect/runnect/presentation/draw/DrawViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/draw/DrawViewModel.kt index c0bfc51b..cb4d15d1 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/draw/DrawViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/draw/DrawViewModel.kt @@ -7,6 +7,7 @@ import com.runnect.runnect.data.dto.UploadLatLng import com.runnect.runnect.domain.common.toLog import com.runnect.runnect.domain.entity.LocationData import com.runnect.runnect.domain.repository.CourseRepository +import com.runnect.runnect.domain.repository.MarkerQuotaRepository import com.runnect.runnect.domain.repository.ReverseGeocodingRepository import com.runnect.runnect.presentation.base.BaseViewModel import com.runnect.runnect.presentation.state.UiState @@ -14,13 +15,15 @@ import com.runnect.runnect.util.extension.collectResult import com.runnect.runnect.util.multipart.ContentUriRequestBody import dagger.hilt.android.lifecycle.HiltViewModel import timber.log.Timber +import java.util.UUID import javax.inject.Inject import kotlin.math.acos @HiltViewModel class DrawViewModel @Inject constructor( val courseRepository: CourseRepository, - val reverseGeocodingRepository: ReverseGeocodingRepository + val reverseGeocodingRepository: ReverseGeocodingRepository, + val markerQuotaRepository: MarkerQuotaRepository ) : BaseViewModel() { private var _drawState = MutableLiveData(UiState.Empty) @@ -52,6 +55,131 @@ class DrawViewModel @Inject constructor( _image.value = requestBody } + private val _markerQuotaBalance = MutableLiveData() + val markerQuotaBalance: LiveData + get() = _markerQuotaBalance + + private val _markerRewardGrantState = MutableLiveData(UiState.Empty) + val markerRewardGrantState: LiveData + get() = _markerRewardGrantState + + /** + * 마커 재화가 부족해 코스 저장이 막혔을 때 1회성으로 쏘는 이벤트. + * drawState(UiState)는 ViewModel 생존 중 값이 유지되는 LiveData라, 액티비티가 + * 재생성되면 관찰을 다시 시작하는 순간 이전 값을 다시 흘려보낸다(sticky). 그래서 + * 부족 알림은 별도 LiveData로 분리하고, 처리한 즉시 false로 되돌려 재생을 막는다. + */ + private val _markerQuotaInsufficientEvent = MutableLiveData(false) + val markerQuotaInsufficientEvent: LiveData + get() = _markerQuotaInsufficientEvent + + fun consumeMarkerQuotaInsufficientEventHandled() { + _markerQuotaInsufficientEvent.value = false + } + + fun fetchMarkerQuota() = launchWithHandler { + markerQuotaRepository.getMarkerQuota().collectResult( + onSuccess = { + _markerQuotaBalance.value = it.balance + }, + onFailure = { + Timber.e(it.toLog()) + } + ) + } + + /** + * 코스 저장. 이번 코스에서 실제로 사용한 마커 개수만큼 서버 잔액을 먼저 차감하고, + * 성공했을 때만 실제 업로드를 진행한다. "차감 성공"과 "업로드 시작"을 액티비티 쪽의 + * 서로 다른 LiveData 관찰로 나누면, 액티비티가 재생성될 때 살아있는 ViewModel의 + * LiveData가 이전 성공 값을 다시 흘려보내(sticky) 업로드가 중복 호출될 수 있다 — + * 그래서 이 흐름 전체를 하나의 코루틴 안에서 순차 실행해 그럴 여지를 없앤다. + * 업로드가 실패하면 이미 차감한 마커를 되돌려줘야, 저장되지 않은 코스 때문에 + * 사용자가 마커만 잃는 일이 없다. + */ + fun saveCourse(markerCount: Int) { + _drawState.value = UiState.Loading + launchWithHandler { + markerQuotaRepository.consumeMarkerQuota(markerCount).collectResult( + onSuccess = { + _markerQuotaBalance.value = it.balance + uploadCourse(consumedMarkerCount = markerCount) + }, + onFailure = { + _drawState.value = UiState.Failure + _markerQuotaInsufficientEvent.value = true + Timber.e(it.toLog()) + } + ) + } + } + + private fun uploadCourse(consumedMarkerCount: Int) { + launchWithHandler { + courseRepository.uploadCourse( + image = _image.value!!.toFormData(), + data = CourseCreateRequestDto( + path = path, + title = courseTitle, + distance = distanceSum.value!!, + departureAddress = departureAddress, + departureName = departureName + ).toRequestBody() + ).collectResult( + onSuccess = { + uploadCourseId = it + _drawState.value = UiState.Success + }, + onFailure = { + _drawState.value = UiState.Failure + Timber.e(it.toLog()) + refundMarkerQuota(consumedMarkerCount) + } + ) + } + } + + /** + * 업로드 실패로 저장되지 않은 코스에 대해, 저장 시점에 미리 차감했던 마커를 되돌려준다. + * 전용 환불 API가 없어 광고 리워드 지급 API를 재사용한다 — 지급 성공/실패 판단이 + * rewardTransactionId 유니크 제약 기반 멱등 처리로 이미 구현돼 있어, 이 요청이 + * 네트워크 재시도로 중복 전송되어도 마커가 두 번 환불되지 않는다. + */ + private fun refundMarkerQuota(amount: Int) { + launchWithHandler { + markerQuotaRepository.grantMarkerReward( + amount = amount, + rewardTransactionId = "upload-fail-refund-${UUID.randomUUID()}" + ).collectResult( + onSuccess = { _markerQuotaBalance.value = it.balance }, + onFailure = { Timber.e(it.toLog()) } + ) + } + } + + fun grantMarkerRewardFromAd(rewardTransactionId: String) { + _markerRewardGrantState.value = UiState.Loading + launchWithHandler { + markerQuotaRepository.grantMarkerReward( + amount = MARKER_REWARD_AMOUNT, + rewardTransactionId = rewardTransactionId + ).collectResult( + onSuccess = { + _markerQuotaBalance.value = it.balance + _markerRewardGrantState.value = UiState.Success + }, + onFailure = { + _markerRewardGrantState.value = UiState.Failure + Timber.e(it.toLog()) + } + ) + } + } + + companion object { + const val MARKER_REWARD_AMOUNT = 5 + } + fun distance(lat1: Double, lon1: Double, lat2: Double, lon2: Double, unit: String): Double { @@ -83,32 +211,6 @@ class DrawViewModel @Inject constructor( return (rad * 180 / Math.PI) } - fun uploadCourse() { - _drawState.value = UiState.Loading - launchWithHandler { - courseRepository.uploadCourse( - image = _image.value!!.toFormData(), - data = CourseCreateRequestDto( - path = path, - title = courseTitle, - distance = distanceSum.value!!, - departureAddress = departureAddress, - departureName = departureName - ).toRequestBody() - ).collectResult( - onSuccess = { - uploadCourseId = it - _drawState.value = UiState.Success - }, - onFailure = { - _drawState.value = UiState.Failure - Timber.e(it.toLog()) - } - ) - } - - } - fun getLocationInfoUsingLatLng(lat: Double, lon: Double) = launchWithHandler { reverseGeocodingRepository.getLocationInfoUsingLatLng( lat = lat, lon = lon diff --git a/app/src/main/java/com/runnect/runnect/util/ad/RewardedAdManager.kt b/app/src/main/java/com/runnect/runnect/util/ad/RewardedAdManager.kt new file mode 100644 index 00000000..019e5596 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/util/ad/RewardedAdManager.kt @@ -0,0 +1,80 @@ +package com.runnect.runnect.util.ad + +import android.app.Activity +import android.content.Context +import com.google.android.gms.ads.AdError +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.FullScreenContentCallback +import com.google.android.gms.ads.LoadAdError +import com.google.android.gms.ads.rewarded.RewardedAd +import com.google.android.gms.ads.rewarded.RewardedAdLoadCallback +import com.runnect.runnect.BuildConfig +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +/** + * 마커 충전용 리워드 광고 로드/노출을 감싼다. 광고 시청 완료(onUserEarnedReward)는 + * AdMob이 정확히 한 번만 호출해주지만, 그 뒤 서버로 지급 요청을 보내는 과정에서 + * 네트워크 재시도가 붙을 수 있으므로 이 시청 1건을 식별하는 rewardTransactionId를 + * 여기서 발급해 호출부에 넘긴다 — 서버는 이 값으로 중복 지급을 막는다. + */ +class RewardedAdManager @Inject constructor() { + + private var rewardedAd: RewardedAd? = null + private var isLoading = false + + fun load(context: Context) { + if (isLoading || rewardedAd != null) return + isLoading = true + RewardedAd.load( + context, + BuildConfig.ADMOB_REWARDED_AD_UNIT_ID, + AdRequest.Builder().build(), + object : RewardedAdLoadCallback() { + override fun onAdLoaded(ad: RewardedAd) { + isLoading = false + rewardedAd = ad + } + + override fun onAdFailedToLoad(error: LoadAdError) { + isLoading = false + rewardedAd = null + Timber.e("리워드 광고 로드 실패: ${error.message}") + } + } + ) + } + + fun isReady(): Boolean = rewardedAd != null + + fun show( + activity: Activity, + onUserEarnedReward: (rewardTransactionId: String) -> Unit, + onNotReady: () -> Unit = {}, + ) { + val ad = rewardedAd + if (ad == null) { + onNotReady() + load(activity) + return + } + + ad.fullScreenContentCallback = object : FullScreenContentCallback() { + override fun onAdDismissedFullScreenContent() { + rewardedAd = null + load(activity) + } + + override fun onAdFailedToShowFullScreenContent(adError: AdError) { + rewardedAd = null + Timber.e("리워드 광고 노출 실패: ${adError.message}") + load(activity) + } + } + + ad.show(activity) { + onUserEarnedReward(UUID.randomUUID().toString()) + } + } +} diff --git a/app/src/main/res/drawable/circle_m3_bg.xml b/app/src/main/res/drawable/circle_m3_bg.xml new file mode 100644 index 00000000..9131ca97 --- /dev/null +++ b/app/src/main/res/drawable/circle_m3_bg.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_marker_quota.xml b/app/src/main/res/drawable/ic_marker_quota.xml new file mode 100644 index 00000000..3983ed48 --- /dev/null +++ b/app/src/main/res/drawable/ic_marker_quota.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_marker_quota_charge.xml b/app/src/main/res/drawable/ic_marker_quota_charge.xml new file mode 100644 index 00000000..01827810 --- /dev/null +++ b/app/src/main/res/drawable/ic_marker_quota_charge.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_filled.xml b/app/src/main/res/drawable/ic_play_filled.xml new file mode 100644 index 00000000..9ce7a25f --- /dev/null +++ b/app/src/main/res/drawable/ic_play_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_draw.xml b/app/src/main/res/layout/activity_draw.xml index c6903bc2..8e8fc1e2 100644 --- a/app/src/main/res/layout/activity_draw.xml +++ b/app/src/main/res/layout/activity_draw.xml @@ -190,6 +190,59 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a0f2fa4..70f5533f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,6 +65,7 @@ kakao-sdk = "2.23.3" # Google play-services-auth = "20.4.1" +play-services-ads = "23.6.0" # Logging timber = "5.0.1" @@ -169,6 +170,7 @@ kakao-navi = { group = "com.kakao.sdk", name = "v2-navi", version.ref = "kakao-s # Google play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "play-services-auth" } +play-services-ads = { group = "com.google.android.gms", name = "play-services-ads", version.ref = "play-services-ads" } # Logging timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }