feat(character): 캐릭터 탭에 Yandex 인라인 배너를 추가한다

This commit is contained in:
2026-04-27 15:22:11 +09:00
parent d0dd6c9224
commit 8295e3d25e
4 changed files with 309 additions and 0 deletions

View File

@@ -14,9 +14,12 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson 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.bannerview.BaseBannerAdapter
import com.zhpan.indicator.enums.IndicatorSlideMode import com.zhpan.indicator.enums.IndicatorSlideMode
import com.zhpan.indicator.enums.IndicatorStyle import com.zhpan.indicator.enums.IndicatorStyle
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.base.SodaDialog 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.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.math.roundToInt
// 캐릭터 탭 프래그먼트 // 캐릭터 탭 프래그먼트
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@@ -61,16 +65,43 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
viewModel.fetchData() viewModel.fetchData()
} }
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
private fun setupView() { private fun setupView() {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater) loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupBanner() setupBanner()
setupRecentCharactersRecyclerView() setupRecentCharactersRecyclerView()
setupCharacterTabInlineBanner()
setupPopularCharactersRecyclerView() setupPopularCharactersRecyclerView()
setupNewCharactersRecyclerView() setupNewCharactersRecyclerView()
setupRecommendCharactersRecyclerView() 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() { private fun setupBanner() {
val layoutParams = binding val layoutParams = binding
.bannerSlider .bannerSlider

View File

@@ -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<YandexInlineBannerHeaderAdapter.BannerViewHolder>() {
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)
}

View File

@@ -82,6 +82,14 @@
</LinearLayout> </LinearLayout>
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:maxHeight="90dp" />
<!-- 인기 캐릭터 섹션 --> <!-- 인기 캐릭터 섹션 -->
<LinearLayout <LinearLayout
android:id="@+id/ll_popular_characters" android:id="@+id/ll_popular_characters"

View File

@@ -0,0 +1,195 @@
# 20260427 채팅 탭 Yandex 배너 광고 추가
## 작업 체크리스트
- [x] 대상 화면 3곳의 구조와 기존 Yandex 광고 패턴을 조사한다.
QA: `CharacterTabFragment`, `OriginalTabFragment`, `TalkTabFragment`, `LiveFragment`, `MyPageFragment`, 공식 Yandex adaptive inline banner 문서를 근거로 삽입 위치와 구현 패턴을 설명할 수 있어야 한다.
- [x] 화면별 AD_UNIT_ID 분리 전략과 추가 대상 키를 확정한다.
QA: `app/build.gradle``debug`/`release`에 각 화면용 `BuildConfig` 필드가 필요하다는 판단 근거가 문서에 남아 있어야 한다.
- [x] `app/build.gradle`에 Character/Original/Talk 탭용 Yandex inline banner ad unit id를 추가한다.
QA: `debug`/`release` 모두에서 3개 화면용 `BuildConfig` 값이 생성되어야 한다.
- [x] `fragment_character_tab.xml``CharacterTabFragment.kt`에 최근 대화한 캐릭터와 인기 캐릭터 사이 배너를 추가한다.
QA: `ll_latest_characters` 다음, `ll_popular_characters` 이전에 배너가 배치되고 기존 상단 콘텐츠 배너는 유지되어야 하며, 배너 최대 높이는 90dp를 넘지 않아야 한다.
- [x] `fragment_original_tab.xml``OriginalTabFragment.kt`에 최상단 배너를 추가한다.
QA: 배너가 `rv_original` 위에 배치되고, 리스트가 배너 아래부터 시작해야 하며, 프래그먼트 종료 시 리소스가 정리되어야 한다.
- [x] `fragment_talk_tab.xml``TalkTabFragment.kt`에 최상단 배너를 추가한다.
QA: 배너가 `rv_talk` 위에 배치되고, 빈 상태 문구가 배너와 겹치지 않아야 하며, 프래그먼트 종료 시 리소스가 정리되어야 한다.
- [x] 변경 사항을 빌드/테스트/수동 확인 기준으로 검증한다.
QA: 최소 `:app:assembleDebug`, `:app:testDebugUnitTest`, `:app:ktlintCheck` 결과와 수동 확인 가능 여부가 기록되어야 한다.
- [x] 검증 기록과 체크리스트를 문서 하단에 누적 갱신한다.
QA: 무엇/왜/어떻게, 실행 명령, 결과가 한국어로 누적되어야 한다.
- [x] Original/Talk 탭 배너를 RecyclerView 헤더로 이동한다.
QA: 배너가 고정 영역이 아니라 RecyclerView 첫 아이템으로 스크롤되어야 하며, Original 그리드에서는 전체 span을 차지해야 한다.
- [x] Original/Talk 탭의 세로 여백을 Character 탭 기준 24dp 흐름에 맞춘다.
QA: 스크롤 콘텐츠가 상단 24dp에서 시작하고, Talk/Original의 하단 여백이 24dp 기준으로 유지되어야 한다.
- [x] 후속 변경 검증 상태를 재실행 가능 상태로 갱신한다.
QA: 이번 요청의 도구 제한 때문에 Gradle 명령은 실행하지 않고, 재실행 대상 명령과 제한 사유를 문서에 남겨야 한다.
## 범위 메모
- 이번 요청 범위는 채팅 영역의 3개 화면에 Yandex adaptive inline banner를 추가하는 작업으로 한정한다.
- `CharacterTabFragment`는 기존 상단 콘텐츠 캐러셀 배너를 제거하지 않고, 최근 대화한 캐릭터와 인기 캐릭터 사이에 Yandex 배너를 추가한다.
- `OriginalTabFragment`, `TalkTabFragment`는 화면 최상단에 Yandex 배너를 추가하되, 후속 변경에서는 RecyclerView 헤더 아이템으로 이동해 리스트와 함께 스크롤되도록 한다.
- AD_UNIT_ID는 사용자 요청대로 페이지별로 분리하며, 기존 저장소 관례에 맞춰 `app/build.gradle``debug`/`release` `buildConfigField`로 관리한다.
- Yandex SDK 의존성과 앱 초기화는 이미 존재하므로 이번 작업에서 SDK 추가나 `SodaLiveApp.kt` 수정은 제외한다.
- 배너 로드 패턴은 현재 저장소의 `LiveFragment`, `MyPageFragment`에서 사용하는 `post { -> 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 화면에서 배너가 첫 아이템처럼 함께 스크롤되는지 실제 기기에서 확인하는 것이다.