From 8295e3d25e3cf7411cc9e458bdcdf4b5eb9d5bc8 Mon Sep 17 00:00:00 2001 From: klaus Date: Mon, 27 Apr 2026 15:22:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(character):=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=ED=83=AD=EC=97=90=20Yandex=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/character/CharacterTabFragment.kt | 31 +++ .../common/YandexInlineBannerHeaderAdapter.kt | 75 +++++++ .../res/layout/fragment_character_tab.xml | 8 + docs/20260427_채팅탭Yandex배너광고추가.md | 195 ++++++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/common/YandexInlineBannerHeaderAdapter.kt create mode 100644 docs/20260427_채팅탭Yandex배너광고추가.md diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt index 22b15cba..874283cf 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt @@ -14,9 +14,12 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.gson.Gson +import com.yandex.mobile.ads.banner.BannerAdSize +import com.yandex.mobile.ads.common.AdRequest import com.zhpan.bannerview.BaseBannerAdapter import com.zhpan.indicator.enums.IndicatorSlideMode import com.zhpan.indicator.enums.IndicatorStyle +import kr.co.vividnext.sodalive.BuildConfig import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.base.SodaDialog @@ -37,6 +40,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest import kr.co.vividnext.sodalive.settings.ContentSettingsActivity import kr.co.vividnext.sodalive.splash.SplashActivity import org.koin.android.ext.android.inject +import kotlin.math.roundToInt // 캐릭터 탭 프래그먼트 @OptIn(UnstableApi::class) @@ -61,16 +65,43 @@ class CharacterTabFragment : BaseFragment( viewModel.fetchData() } + override fun onDestroyView() { + binding.yandexInlineBannerView.destroy() + super.onDestroyView() + } + private fun setupView() { loadingDialog = LoadingDialog(requireActivity(), layoutInflater) setupBanner() setupRecentCharactersRecyclerView() + setupCharacterTabInlineBanner() setupPopularCharactersRecyclerView() setupNewCharactersRecyclerView() setupRecommendCharactersRecyclerView() } + private fun setupCharacterTabInlineBanner() { + binding.yandexInlineBannerView.post { + val density = resources.displayMetrics.density + val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth + val adWidthDp = (adWidthPixels / density).roundToInt() + val maxAdHeightDp = 90 + + binding.yandexInlineBannerView.apply { + setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_CHARACTER_TAB_AD_UNIT_ID) + setAdSize( + BannerAdSize.inlineSize( + requireContext(), + adWidthDp, + maxAdHeightDp + ) + ) + loadAd(AdRequest.Builder().build()) + } + } + } + private fun setupBanner() { val layoutParams = binding .bannerSlider diff --git a/app/src/main/java/kr/co/vividnext/sodalive/common/YandexInlineBannerHeaderAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/common/YandexInlineBannerHeaderAdapter.kt new file mode 100644 index 00000000..8e8cab36 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/common/YandexInlineBannerHeaderAdapter.kt @@ -0,0 +1,75 @@ +package kr.co.vividnext.sodalive.common + +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView +import com.yandex.mobile.ads.banner.BannerAdSize +import com.yandex.mobile.ads.banner.BannerAdView +import com.yandex.mobile.ads.common.AdRequest +import kr.co.vividnext.sodalive.extensions.dpToPx +import kotlin.math.roundToInt + +class YandexInlineBannerHeaderAdapter( + private val adUnitId: String, + private val screenWidth: Int +) : RecyclerView.Adapter() { + + private var bannerAdView: BannerAdView? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BannerViewHolder { + val horizontalPadding = 24f.dpToPx().toInt() + val verticalPadding = 24f.dpToPx().toInt() + val container = FrameLayout(parent.context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding) + } + val bannerView = BannerAdView(parent.context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + } + container.addView(bannerView) + return BannerViewHolder(container, bannerView).apply { + setIsRecyclable(false) + } + } + + override fun getItemCount(): Int = 1 + + override fun onBindViewHolder(holder: BannerViewHolder, position: Int) { + bannerAdView = holder.bannerView + holder.bannerView.post { + val density = holder.bannerView.resources.displayMetrics.density + val adWidthPixels = holder.bannerView.width.takeIf { it > 0 } + ?: (screenWidth - 48f.dpToPx().toInt()) + val adWidthDp = (adWidthPixels / density).roundToInt().coerceAtLeast(1) + val maxAdHeightDp = 90 + + holder.bannerView.apply { + setAdUnitId(adUnitId) + setAdSize( + BannerAdSize.inlineSize( + context, + adWidthDp, + maxAdHeightDp + ) + ) + loadAd(AdRequest.Builder().build()) + } + } + } + + fun destroy() { + bannerAdView?.destroy() + bannerAdView = null + } + + class BannerViewHolder( + container: FrameLayout, + val bannerView: BannerAdView + ) : RecyclerView.ViewHolder(container) +} diff --git a/app/src/main/res/layout/fragment_character_tab.xml b/app/src/main/res/layout/fragment_character_tab.xml index 153b731b..3a12660f 100644 --- a/app/src/main/res/layout/fragment_character_tab.xml +++ b/app/src/main/res/layout/fragment_character_tab.xml @@ -82,6 +82,14 @@ + + setAdUnitId -> setAdSize(BannerAdSize.inlineSize(...)) -> loadAd(...) }` 흐름을 우선 따른다. +- 배너 정리는 프래그먼트 뷰 생명주기에 맞춰 `onDestroyView()`에서 `destroy()`를 호출하는 방향으로 맞춘다. + +## 조사 근거 +- 대상 화면 + - `app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt` + - `app/src/main/res/layout/fragment_character_tab.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalTabFragment.kt` + - `app/src/main/res/layout/fragment_original_tab.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkTabFragment.kt` + - `app/src/main/res/layout/fragment_talk_tab.xml` +- 기존 배너 구현 참고 + - `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt` + - `app/src/main/res/layout/fragment_live.xml` +- 설정 참고 + - `app/build.gradle` +- 공식 문서 + - `https://ads.yandex.com/helpcenter/ko/dev/android/adaptive-inline-banner` + +## 구현 계획 + +### 1. AD_UNIT_ID 추가 +- 수정 대상: `app/build.gradle` +- 계획: + - `release`와 `debug` 각각에 아래 키를 추가한다. + - `YANDEX_INLINE_BANNER_CHARACTER_TAB_AD_UNIT_ID` + - `YANDEX_INLINE_BANNER_ORIGINAL_TAB_AD_UNIT_ID` + - `YANDEX_INLINE_BANNER_TALK_TAB_AD_UNIT_ID` + - 실제 값은 아직 제공되지 않았으므로 화면별·빌드타입별 placeholder 문자열을 서로 다르게 사용한다. +- 이유: + - 기존 Yandex 광고가 모두 `BuildConfig` 기반으로 주입되고 있고, 사용자도 페이지별 분리를 명시했기 때문이다. + +### 2. Character 탭 중간 배너 추가 +- 수정 대상: + - `app/src/main/res/layout/fragment_character_tab.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt` +- 위치: + - `ll_latest_characters` 다음, `ll_popular_characters` 이전 +- 계획: + - 섹션 사이에 `BannerAdView`를 추가하고 기존 24dp 간격 흐름을 유지한다. + - `setupView()` 흐름에 배너 로드 메서드를 추가한다. + - 측정된 너비 기준으로 adaptive inline banner를 로드하고, 최대 높이는 90dp 상한을 유지한다. + - `onDestroyView()`에서 배너를 정리한다. + +### 3. Original 탭 최상단 배너 추가 +- 수정 대상: + - `app/src/main/res/layout/fragment_original_tab.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalTabFragment.kt` +- 위치: + - 루트 최상단, `rv_original` 위 +- 계획: + - `ConstraintLayout` 상단에 `BannerAdView`를 추가하고, `rv_original`의 top constraint를 배너 아래로 조정한다. + - 프래그먼트 로드 시 배너를 설정하고 종료 시 정리한다. + +### 4. Talk 탭 최상단 배너 추가 +- 수정 대상: + - `app/src/main/res/layout/fragment_talk_tab.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkTabFragment.kt` +- 위치: + - 루트 최상단, `rv_talk` 위 +- 계획: + - 최상단에 `BannerAdView`를 추가하고, `rv_talk` 및 `tv_empty`가 배너 아래 레이아웃 기준을 따르도록 constraint를 조정한다. + - 빈 상태 문구는 배너와 겹치지 않게 유지한다. + - 프래그먼트 종료 시 배너를 정리한다. + +## 예상 수정 파일 +- `docs/20260427_채팅탭Yandex배너광고추가.md` +- `app/build.gradle` +- `app/src/main/res/layout/fragment_character_tab.xml` +- `app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt` +- `app/src/main/res/layout/fragment_original_tab.xml` +- `app/src/main/java/kr/co/vividnext/sodalive/chat/original/OriginalTabFragment.kt` +- `app/src/main/res/layout/fragment_talk_tab.xml` +- `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkTabFragment.kt` + +## 검증 계획 +- `./gradlew :app:assembleDebug` +- `./gradlew :app:testDebugUnitTest` +- `./gradlew :app:ktlintCheck` +- 수동 확인 + - Character 탭에서 최근 대화한 캐릭터와 인기 캐릭터 사이에 배너가 보이는지 확인한다. + - Original 탭에서 배너가 최상단에 보이고 그 아래부터 그리드가 시작하는지 확인한다. + - Talk 탭에서 배너가 최상단에 보이고, 빈 상태 문구가 배너와 겹치지 않는지 확인한다. + +## 구현 반영 기록 +- 2026-04-27 + - 무엇: Character/Original/Talk 탭에 Yandex adaptive inline banner 구현을 반영했다. + - 왜: 각 채팅 탭 화면에 별도 광고 단위 키를 사용하고, 지정 위치에 inline banner를 추가하며, 뷰 종료 시 광고 리소스를 정리해야 하기 때문이다. + - 어떻게: + - `app/build.gradle`의 `debug`/`release`에 화면별 placeholder `BuildConfig` 키 3개씩을 추가했다. + - `fragment_character_tab.xml`은 `ll_latest_characters`와 `ll_popular_characters` 사이에 `BannerAdView`를 추가했다. + - `fragment_original_tab.xml`과 `fragment_talk_tab.xml`은 최상단 `BannerAdView` 아래로 리스트가 시작되도록 조정했고, Talk 빈 상태 문구도 배너 아래 영역을 기준으로 배치했다. + - 각 Fragment는 `setAdUnitId(...)`, `BannerAdSize.inlineSize(...)`, `AdRequest.Builder().build()` 패턴으로 배너를 로드하고 `onDestroyView()`에서 `destroy()`를 호출하도록 연결했다. + - 결과: 구현 체크리스트 중 코드 반영 항목을 완료 상태로 갱신했다. 빌드/테스트/수동 확인 결과는 아직 기록하지 않았다. +- 2026-04-27 + - 무엇: Original/Talk 탭의 Yandex 배너를 고정 XML 자식에서 RecyclerView 헤더 아이템으로 이동하는 후속 변경을 반영했다. + - 왜: 배너가 리스트 바깥에 고정되어 있으면 Character 탭의 스크롤 콘텐츠 여백 흐름과 맞지 않고, 사용자가 요청한 “리스트와 함께 스크롤되는 배너” 조건을 만족하지 못하기 때문이다. + - 어떻게: + - `YandexInlineBannerHeaderAdapter`를 추가해 `BannerAdView`를 RecyclerView 첫 아이템으로 생성하고, 기존 Yandex SDK 7.18.5 방식인 `setAdUnitId(...)`, `BannerAdSize.inlineSize(...)`, `AdRequest.Builder().build()` 호출 흐름을 유지했다. + - `OriginalTabFragment`는 `ConcatAdapter`로 배너 헤더와 기존 그리드 어댑터를 연결하고, `GridLayoutManager.SpanSizeLookup` 및 `GridSpacingItemDecoration(headerCount = 1)`로 헤더가 전체 3열을 차지하게 했다. + - Original 그리드는 기존 16dp 아이템 하단 간격을 유지하면서 스크롤 콘텐츠 끝 여백이 24dp가 되도록 RecyclerView 하단 padding을 8dp로 보정했다. + - `TalkTabFragment`는 `ConcatAdapter`로 배너 헤더와 기존 Talk 어댑터를 연결하고, ItemDecoration이 헤더를 건너뛰도록 보정했다. + - `fragment_original_tab.xml`, `fragment_talk_tab.xml`에서는 고정 배너 뷰를 제거하고 RecyclerView를 부모 상단에 직접 연결했다. + - Talk 빈 상태 문구는 유지하되 RecyclerView는 배너 헤더를 표시할 수 있도록 빈 목록에서도 보이게 했다. + - 결과: 후속 변경의 코드 반영 항목을 완료 상태로 갱신했다. + +## 검증 기록 +- 2026-04-27 + - 무엇: 채팅 탭 Yandex 배너 광고 추가 작업의 계획 문서를 생성했다. + - 왜: 저장소 규칙에 따라 구현 전에 `docs` 아래 계획 문서를 먼저 만들고, 범위·광고 위치·검증 기준을 먼저 고정해야 하기 때문이다. + - 어떻게: + - 생성 파일: `docs/20260427_채팅탭Yandex배너광고추가.md` + - 근거 파일: `app/build.gradle`, `CharacterTabFragment.kt`, `OriginalTabFragment.kt`, `TalkTabFragment.kt`, 각 대응 XML, `LiveFragment.kt`, `MyPageFragment.kt` + - 근거 문서: `https://ads.yandex.com/helpcenter/ko/dev/android/adaptive-inline-banner` + - 결과: 화면별 위치, AD_UNIT_ID 분리, 예상 수정 파일, 검증 계획을 구현 전에 확정했다. +- 2026-04-27 + - 무엇: 채팅 탭 3개 화면의 Yandex 배너 추가 구현에 대해 빌드, 테스트, 린트, 수동 확인 가능 여부를 검증했다. + - 왜: 이번 변경은 `BuildConfig`, Kotlin, XML을 함께 수정하므로 실제 Android 리소스 병합과 컴파일, 테스트, 스타일 검사를 통과해야 안전하게 반영됐다고 볼 수 있기 때문이다. + - 어떻게: + - 진단 도구: `lsp_diagnostics` + - 진단 결과: `.kt`, `.xml` LSP 서버가 현재 환경에 설정되어 있지 않아 정적 진단은 수행하지 못했다. + - 실행 명령: `./gradlew :app:assembleDebug` + - 실행 결과: `BUILD SUCCESSFUL` + - 실행 명령: `./gradlew :app:testDebugUnitTest` + - 실행 결과: `BUILD SUCCESSFUL` + - 실행 명령: `./gradlew :app:ktlintCheck` + - 실행 결과: `BUILD SUCCESSFUL` + - 실행 명령: `adb devices` + - 실행 결과: 연결 기기 없음 + - 수동 확인 결과: ADB 연결 기기가 없어 앱 실행 기반 수동 QA는 수행하지 못했다. + - 비고: `app/build.gradle`의 Character/Original/Talk 탭 ad unit id는 현재 placeholder 값이므로, 실제 광고 응답 확인은 실 ad unit id 교체 후 추가 검증이 필요하다. +- 2026-04-27 + - 무엇: Original/Talk 탭 Yandex 배너 후속 변경의 정적 진단 가능 여부와 참조 정리를 확인했다. + - 왜: 이번 변경은 XML 바인딩 참조를 제거하고 RecyclerView 헤더 어댑터로 이동했으므로, 남은 고정 배너 참조와 문서 검증 상태를 확인해야 하기 때문이다. + - 어떻게: + - 진단 도구: `lsp_diagnostics` + - 진단 결과: Markdown 문서(`docs/20260427_채팅탭Yandex배너광고추가.md`)는 진단 없음. + - 진단 결과: `.kt`, `.xml` LSP 서버가 현재 환경에 설정되어 있지 않아 Kotlin/XML 정적 진단은 수행하지 못했다. + - 확인 도구: `grep` + - 확인 결과: `OriginalTabFragment`, `TalkTabFragment`, `fragment_original_tab.xml`, `fragment_talk_tab.xml`에는 기존 고정 배너 바인딩/ID 참조가 남아 있지 않다. + - 재실행 대기 명령: `./gradlew :app:assembleDebug`, `./gradlew :app:testDebugUnitTest`, `./gradlew :app:ktlintCheck` + - 결과: 이 시점에서는 Gradle 기반 빌드/테스트/린트를 아직 재실행하지 않았고, 기존 검증 명령은 재실행 준비 상태로 유지했다. +- 2026-04-27 + - 무엇: Original/Talk 탭 배너 헤더 후속 변경에 대해 빌드, 테스트, 린트, 기기 연결 상태를 다시 검증했다. + - 왜: 이번 후속 변경은 배너를 RecyclerView 헤더 어댑터로 옮기고 간격 계산을 변경했으므로, 실제 Android 컴파일과 테스트를 다시 통과해야 안전하게 반영됐다고 볼 수 있기 때문이다. + - 어떻게: + - 진단 도구: `lsp_diagnostics` + - 진단 결과: `.kt`, `.xml` LSP 서버가 현재 환경에 설정되어 있지 않아 Kotlin/XML 정적 진단은 수행하지 못했다. + - 실행 명령: `./gradlew :app:assembleDebug` + - 실행 결과: `BUILD SUCCESSFUL` + - 실행 명령: `./gradlew :app:testDebugUnitTest` + - 실행 결과: `BUILD SUCCESSFUL` + - 실행 명령: `./gradlew :app:ktlintCheck` + - 실행 결과: `BUILD SUCCESSFUL` + - 실행 명령: `adb devices` + - 실행 결과: 한때 `2cec640c34017ece` 기기가 표시되었으나, 이후 설치/실행 시점에는 연결이 끊어졌다. + - 실행 명령: `./gradlew :app:installDebug` + - 실행 결과: `No connected devices!` + - 실행 명령: `adb shell am start -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.splash.SplashActivity` + - 실행 결과: `adb: no devices/emulators found` + - Oracle 검토 결과: 후속 변경은 요청한 스크롤 배너 요구사항을 충족하며, 즉시 수정이 필요한 correctness/layout/pagination 결함은 확인되지 않았다. + - 결과: 빌드/테스트/린트는 모두 통과했고, 수동 QA는 기기 연결 해제 때문에 완료하지 못했다. 남은 검증 공백은 Original/Talk 화면에서 배너가 첫 아이템처럼 함께 스크롤되는지 실제 기기에서 확인하는 것이다.