From 9225f812c9bcedfe28e1b8d0c45e549eea96c604 Mon Sep 17 00:00:00 2001 From: unam98 Date: Tue, 25 Aug 2026 21:14:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=A7=88=EC=BB=A4=20=EC=9E=AC?= =?UTF-8?q?=ED=99=94=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=95=88=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=EC=9D=B4=EB=93=9C=20=EC=97=B0=EB=8F=99=20(=EC=86=8C?= =?UTF-8?q?=EB=B9=84/=EA=B4=91=EA=B3=A0=20=EB=A6=AC=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=81=EB=A6=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코스를 그릴 때 찍는 마커 개수를 계정 단위 재화로 관리한다. 마커를 찍을 때마다 서버를 호출하지 않고 로컬에서 실시간으로 잔량을 표시/차감하다가, 코스 저장 시점에 실제 사용한 마커 수만큼 서버에 소비 요청을 보낸다. 잔량이 0인데 지도를 탭하면 팝업으로 광고 시청을 유도하고, 리워드 광고 시청 완료 시 서버에 지급 요청을 보내 잔량을 채운다. - data/domain/repository/DI: MarkerQuotaService·RemoteMarkerQuotaDataSource· MarkerQuotaRepository(Impl) 3계층, 기존 CourseRepository 패턴과 동일하게 구성 - RewardedAdManager: AdMob 리워드 광고 로드/노출을 감싸고, 시청 완료 시 rewardTransactionId(UUID)를 발급해 서버의 멱등성 처리에 사용 - DrawViewModel: markerQuotaBalance/consumeMarkerQuota/grantMarkerRewardFromAd 추가 - DrawActivity: 마커 잔량 칩 + 충전 버튼 UI, 마커 배치/해제 시 로컬 잔량 증감, 코스 저장 시 consumeMarkerQuota 호출 후 성공 시에만 업로드 진행, 잔량 소진 시 광고 유도 바텀시트 노출 AdMob App ID/광고 단위 ID는 Google 공식 테스트용 값으로 넣어둠 (local.properties: ADMOB_APP_ID, ADMOB_REWARDED_AD_UNIT_ID) — 실제 배포 전 본인 AdMob 계정의 값으로 교체 필요. Claude-Session: https://claude.ai/code/session_01SPpjaoQ3Pxfd4624hWZsik --- app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 5 + .../runnect/application/ApplicationClass.kt | 2 + .../dto/request/RequestConsumeMarkerQuota.kt | 10 ++ .../dto/request/RequestGrantMarkerReward.kt | 12 ++ .../dto/response/ResponseGrantMarkerReward.kt | 17 +++ .../data/dto/response/ResponseMarkerQuota.kt | 15 +++ .../repository/MarkerQuotaRepositoryImpl.kt | 35 ++++++ .../data/service/MarkerQuotaService.kt | 25 +++++ .../remote/RemoteMarkerQuotaDataSource.kt | 20 ++++ .../runnect/runnect/di/RepositoryModule.kt | 5 + .../com/runnect/runnect/di/ServiceModule.kt | 6 + .../runnect/domain/entity/MarkerQuota.kt | 5 + .../domain/entity/MarkerRewardResult.kt | 6 + .../repository/MarkerQuotaRepository.kt | 14 +++ .../runnect/presentation/draw/DrawActivity.kt | 103 +++++++++++++++++- .../presentation/draw/DrawViewModel.kt | 71 +++++++++++- .../runnect/util/ad/RewardedAdManager.kt | 80 ++++++++++++++ app/src/main/res/drawable/circle_m3_bg.xml | 5 + app/src/main/res/drawable/ic_marker_quota.xml | 12 ++ .../res/drawable/ic_marker_quota_charge.xml | 9 ++ app/src/main/res/drawable/ic_play_filled.xml | 9 ++ app/src/main/res/layout/activity_draw.xml | 53 +++++++++ .../layout/bottomsheet_marker_quota_empty.xml | 102 +++++++++++++++++ gradle/libs.versions.toml | 2 + 25 files changed, 619 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/runnect/runnect/data/dto/request/RequestConsumeMarkerQuota.kt create mode 100644 app/src/main/java/com/runnect/runnect/data/dto/request/RequestGrantMarkerReward.kt create mode 100644 app/src/main/java/com/runnect/runnect/data/dto/response/ResponseGrantMarkerReward.kt create mode 100644 app/src/main/java/com/runnect/runnect/data/dto/response/ResponseMarkerQuota.kt create mode 100644 app/src/main/java/com/runnect/runnect/data/repository/MarkerQuotaRepositoryImpl.kt create mode 100644 app/src/main/java/com/runnect/runnect/data/service/MarkerQuotaService.kt create mode 100644 app/src/main/java/com/runnect/runnect/data/source/remote/RemoteMarkerQuotaDataSource.kt create mode 100644 app/src/main/java/com/runnect/runnect/domain/entity/MarkerQuota.kt create mode 100644 app/src/main/java/com/runnect/runnect/domain/entity/MarkerRewardResult.kt create mode 100644 app/src/main/java/com/runnect/runnect/domain/repository/MarkerQuotaRepository.kt create mode 100644 app/src/main/java/com/runnect/runnect/util/ad/RewardedAdManager.kt create mode 100644 app/src/main/res/drawable/circle_m3_bg.xml create mode 100644 app/src/main/res/drawable/ic_marker_quota.xml create mode 100644 app/src/main/res/drawable/ic_marker_quota_charge.xml create mode 100644 app/src/main/res/drawable/ic_play_filled.xml create mode 100644 app/src/main/res/layout/bottomsheet_marker_quota_empty.xml diff --git a/app/build.gradle b/app/build.gradle index bd9df773d..a563d07b1 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 2008ace76..623260ca0 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 000000000..ac5449309 --- /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 000000000..4d08081f8 --- /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 b257cb682..0cae0797a 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 8de7ec357..0ec589179 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 000000000..49ab38442 --- /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 000000000..0928f8b6f --- /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 000000000..ac6143906 --- /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 f75df8824..e457ef99b 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,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw private var distanceSum: Float = 0.0f private var sumList = mutableListOf() private var isMarkerAvailable: Boolean = false + private var markerQuotaBalance: Int = 0 val isVisitorMode: Boolean get() = visitorModeManager.isVisitorMode var isFirstInit: Boolean = true @@ -118,6 +124,9 @@ class DrawActivity : BindingActivity(R.layout.activity_draw addObserver() backButton() courseFinish() + viewModel.fetchMarkerQuota() + rewardedAdManager.load(this) + setupMarkerQuotaChargeButton() } private fun getSearchIntent() { @@ -406,6 +415,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw frameCourseDistance.isVisible = true tvCourseDistanceRecord.isVisible = true tvCourseDistanceKm.isVisible = true + frameMarkerQuota.isVisible = true } } @@ -438,6 +448,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 +491,78 @@ class DrawActivity : BindingActivity(R.layout.activity_draw } } + private fun observeMarkerQuota() { + viewModel.markerQuotaBalance.observe(this) { balance -> + markerQuotaBalance = balance + binding.tvMarkerQuotaBalance.text = balance.toString() + } + + viewModel.markerQuotaConsumeState.observe(this) { + when (it) { + UiState.Success -> viewModel.uploadCourse() + UiState.Failure -> { + hideLoadingBar() + showToast(NOTIFY_MARKER_QUOTA_INSUFFICIENT) + showMarkerQuotaEmptyBottomSheet() + } + + else -> Unit + } + } + + 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() { + markerQuotaBalance-- + binding.tvMarkerQuotaBalance.text = markerQuotaBalance.toString() + } + + private fun incrementMarkerQuotaBalance() { + markerQuotaBalance++ + binding.tvMarkerQuotaBalance.text = markerQuotaBalance.toString() + } + private fun showLoadingBar() { binding.indeterminateBar.isVisible = true } @@ -665,10 +748,15 @@ class DrawActivity : BindingActivity(R.layout.activity_draw viewModel.isBtnAvailable.value = true if (touchList.size < MAX_MARKER_NUM) { - addCoordsToTouchList(coord) - setRouteMarker(coord) - generateRouteLine(coord) - calcDistance() + if (markerQuotaBalance > 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 +817,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 +892,8 @@ class DrawActivity : BindingActivity(R.layout.activity_draw ) ) //Uri -> RequestBody setViewModelValue(calcDistanceList) - viewModel.uploadCourse() + showLoadingBar() + viewModel.consumeMarkerQuota(touchList.size) } } @@ -863,6 +953,9 @@ 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_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 c0bfc51b4..3c15ea679 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 @@ -20,7 +21,8 @@ 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 +54,73 @@ class DrawViewModel @Inject constructor( _image.value = requestBody } + private val _markerQuotaBalance = MutableLiveData() + val markerQuotaBalance: LiveData + get() = _markerQuotaBalance + + private val _markerQuotaConsumeState = MutableLiveData(UiState.Empty) + val markerQuotaConsumeState: LiveData + get() = _markerQuotaConsumeState + + private val _markerRewardGrantState = MutableLiveData(UiState.Empty) + val markerRewardGrantState: LiveData + get() = _markerRewardGrantState + + fun fetchMarkerQuota() = launchWithHandler { + markerQuotaRepository.getMarkerQuota().collectResult( + onSuccess = { + _markerQuotaBalance.value = it.balance + }, + onFailure = { + Timber.e(it.toLog()) + } + ) + } + + /** + * 코스 저장 시점에 이번 코스에서 실제로 사용한 마커 개수만큼 서버 잔액을 차감한다. + * 확인과 차감이 서버의 조건부 UPDATE 한 문장으로 원자적으로 처리되므로, 다른 기기에서 + * 동시에 잔액을 소비했더라도 잔액이 실제보다 잘못 계산되는 일 없이 정확히 반영된다. + */ + fun consumeMarkerQuota(amount: Int) { + _markerQuotaConsumeState.value = UiState.Loading + launchWithHandler { + markerQuotaRepository.consumeMarkerQuota(amount).collectResult( + onSuccess = { + _markerQuotaBalance.value = it.balance + _markerQuotaConsumeState.value = UiState.Success + }, + onFailure = { + _markerQuotaConsumeState.value = UiState.Failure + 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 { 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 000000000..019e55967 --- /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 000000000..9131ca97f --- /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 000000000..3983ed48f --- /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 000000000..01827810d --- /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 000000000..9ce7a25f5 --- /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 c6903bc28..8e8fc1e21 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 3a0f2fa4c..70f5533f0 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" } From a8d3127e8d2ca687b8c3816a57f59e7b22984749 Mon Sep 17 00:00:00 2001 From: unam98 Date: Tue, 25 Aug 2026 22:17:35 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20CodeRabbit=20=EC=A7=80=EC=A0=81=203?= =?UTF-8?q?=EA=B1=B4=20=EB=B0=98=EC=98=81=20-=20=EC=9E=94=EB=9F=89=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A0=84=20=EB=B0=B0=EC=B9=98=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80,=20=EC=86=8C=EB=B9=84/=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9B=90=EC=9E=90=EC=A0=81=20=EC=B2=B4=EC=9D=B4=EB=8B=9D,=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=ED=99=98=EB=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. markerQuotaBalance를 Int? = null로 바꿔, 서버 조회가 아직 안 끝난 상태와 실제 잔량 0을 구분. 조회 전에는 마커 배치를 막고 안내 토스트를 띄운다. 2. consumeMarkerQuota 성공 -> uploadCourse() 트리거를 액티비티 쪽 별도 LiveData 관찰로 나누지 않고, DrawViewModel.saveCourse() 하나의 코루틴 안에서 순차 체이닝하도록 변경. 기존 방식은 액티비티가 재생성되면 markerQuotaConsumeState의 sticky한 Success 값이 다시 흘러 uploadCourse()가 중복 호출될 수 있었다. 3. 업로드가 실패하면 이미 차감한 마커 재화를 grantMarkerReward API로 되돌려준다(광고 리워드 API의 멱등 처리를 재사용). 이전에는 업로드 실패 시 저장되지 않은 코스 때문에 마커만 잃는 문제가 있었다. Claude-Session: https://claude.ai/code/session_01SPpjaoQ3Pxfd4624hWZsik --- .../runnect/presentation/draw/DrawActivity.kt | 47 +++++--- .../presentation/draw/DrawViewModel.kt | 109 ++++++++++++------ 2 files changed, 101 insertions(+), 55 deletions(-) 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 e457ef99b..0675348df 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 @@ -107,7 +107,13 @@ class DrawActivity : BindingActivity(R.layout.activity_draw private var distanceSum: Float = 0.0f private var sumList = mutableListOf() private var isMarkerAvailable: Boolean = false - private var markerQuotaBalance: Int = 0 + + /** + * null: 서버 조회가 아직 끝나지 않은 상태(진짜 0과 구분해야 함). + * 이 값이 null인 동안은 마커 배치를 막아, 잔량 조회가 끝나기 전에 "재화가 + * 부족하다"고 잘못 판단해 팝업을 띄우는 일이 없게 한다. + */ + private var markerQuotaBalance: Int? = null val isVisitorMode: Boolean get() = visitorModeManager.isVisitorMode var isFirstInit: Boolean = true @@ -497,16 +503,14 @@ class DrawActivity : BindingActivity(R.layout.activity_draw binding.tvMarkerQuotaBalance.text = balance.toString() } - viewModel.markerQuotaConsumeState.observe(this) { - when (it) { - UiState.Success -> viewModel.uploadCourse() - UiState.Failure -> { - hideLoadingBar() - showToast(NOTIFY_MARKER_QUOTA_INSUFFICIENT) - showMarkerQuotaEmptyBottomSheet() - } - - else -> Unit + // drawState(UiState)의 Failure는 업로드 실패/잔량 부족 둘 다에서 발생하는데, + // loading bar 숨김은 이미 observeDrawState()가 처리한다. 여기서는 "잔량 + // 부족이라 실패했다"는 사실만 별도 이벤트로 받아 팝업을 띄운다. + viewModel.markerQuotaInsufficientEvent.observe(this) { isInsufficient -> + if (isInsufficient) { + showToast(NOTIFY_MARKER_QUOTA_INSUFFICIENT) + showMarkerQuotaEmptyBottomSheet() + viewModel.consumeMarkerQuotaInsufficientEventHandled() } } @@ -554,13 +558,15 @@ class DrawActivity : BindingActivity(R.layout.activity_draw } private fun decrementMarkerQuotaBalance() { - markerQuotaBalance-- - binding.tvMarkerQuotaBalance.text = markerQuotaBalance.toString() + val updated = (markerQuotaBalance ?: 0) - 1 + markerQuotaBalance = updated + binding.tvMarkerQuotaBalance.text = updated.toString() } private fun incrementMarkerQuotaBalance() { - markerQuotaBalance++ - binding.tvMarkerQuotaBalance.text = markerQuotaBalance.toString() + val updated = (markerQuotaBalance ?: 0) + 1 + markerQuotaBalance = updated + binding.tvMarkerQuotaBalance.text = updated.toString() } private fun showLoadingBar() { @@ -747,8 +753,14 @@ 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) { - if (markerQuotaBalance > 0) { + if (balance > 0) { addCoordsToTouchList(coord) setRouteMarker(coord) generateRouteLine(coord) @@ -893,7 +905,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw ) //Uri -> RequestBody setViewModelValue(calcDistanceList) showLoadingBar() - viewModel.consumeMarkerQuota(touchList.size) + viewModel.saveCourse(touchList.size) } } @@ -954,6 +966,7 @@ class DrawActivity : BindingActivity(R.layout.activity_draw 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 = "광고를 불러오는 중입니다. 잠시 후 다시 시도해주세요" 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 3c15ea679..cb4d15d13 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 @@ -15,6 +15,7 @@ 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 @@ -58,14 +59,24 @@ class DrawViewModel @Inject constructor( val markerQuotaBalance: LiveData get() = _markerQuotaBalance - private val _markerQuotaConsumeState = MutableLiveData(UiState.Empty) - val markerQuotaConsumeState: LiveData - get() = _markerQuotaConsumeState - 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 = { @@ -78,26 +89,74 @@ class DrawViewModel @Inject constructor( } /** - * 코스 저장 시점에 이번 코스에서 실제로 사용한 마커 개수만큼 서버 잔액을 차감한다. - * 확인과 차감이 서버의 조건부 UPDATE 한 문장으로 원자적으로 처리되므로, 다른 기기에서 - * 동시에 잔액을 소비했더라도 잔액이 실제보다 잘못 계산되는 일 없이 정확히 반영된다. + * 코스 저장. 이번 코스에서 실제로 사용한 마커 개수만큼 서버 잔액을 먼저 차감하고, + * 성공했을 때만 실제 업로드를 진행한다. "차감 성공"과 "업로드 시작"을 액티비티 쪽의 + * 서로 다른 LiveData 관찰로 나누면, 액티비티가 재생성될 때 살아있는 ViewModel의 + * LiveData가 이전 성공 값을 다시 흘려보내(sticky) 업로드가 중복 호출될 수 있다 — + * 그래서 이 흐름 전체를 하나의 코루틴 안에서 순차 실행해 그럴 여지를 없앤다. + * 업로드가 실패하면 이미 차감한 마커를 되돌려줘야, 저장되지 않은 코스 때문에 + * 사용자가 마커만 잃는 일이 없다. */ - fun consumeMarkerQuota(amount: Int) { - _markerQuotaConsumeState.value = UiState.Loading + fun saveCourse(markerCount: Int) { + _drawState.value = UiState.Loading launchWithHandler { - markerQuotaRepository.consumeMarkerQuota(amount).collectResult( + markerQuotaRepository.consumeMarkerQuota(markerCount).collectResult( onSuccess = { _markerQuotaBalance.value = it.balance - _markerQuotaConsumeState.value = UiState.Success + uploadCourse(consumedMarkerCount = markerCount) }, onFailure = { - _markerQuotaConsumeState.value = UiState.Failure + _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 { @@ -152,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