Compare commits

18 Commits

Author SHA1 Message Date
a6e3f71ca5 chore(version): versionCode 234, versionName 1.54.0 2026-03-29 04:25:40 +09:00
c78849e994 fix(live-room): 라이브룸 입장 직후 UI 미갱신을 방지한다 2026-03-29 04:23:57 +09:00
5609560b77 fix(live-room): 방장 캡쳐 녹화 허용 정책을 반영한다 2026-03-28 20:04:20 +09:00
4bb91c605a fix(mypage): 비한국 사용자 본인인증 버튼 숨김 시 정렬을 유지한다 2026-03-28 18:40:58 +09:00
9dfad913bc fix(member-info): 구서버 멤버정보 누락 필드 하위 호환을 보장한다 2026-03-28 18:28:09 +09:00
4815cac49b fix(api): 콘텐츠 설정 PATCH 제외 API 파라미터를 제거한다 2026-03-27 22:31:13 +09:00
25d549b06f docs(plan): 연령제한 표시 조건 수정 검증 기록을 추가한다 2026-03-27 18:25:10 +09:00
0c0801561e fix(content): 연령제한 노출 조건을 공통 정책으로 통일한다 2026-03-27 18:24:47 +09:00
0fcd929c6f fix(content): 국가별 성인 콘텐츠 접근 동기화를 정리한다 2026-03-27 17:33:52 +09:00
6aa7b9e98c fix(live-room): 스크린샷 dead path 제거로 녹화 음소거 정합을 맞춘다 2026-03-24 17:21:42 +09:00
8c0690b1e5 fix(live-room): 캡처/녹화 시 라이브룸 보안 음소거를 동기화한다 2026-03-24 16:16:14 +09:00
08524bd79a fix(live-room): 매니저 SNS 아이콘 노출 방식을 동적 렌더링으로 통일한다 2026-03-24 13:35:15 +09:00
a893d85632 fix(live-room): 채팅 얼림 문구 국제화와 버전 코드를 반영한다 2026-03-20 16:51:41 +09:00
41f6ddd61b fix(live-room): 채팅 얼림 버튼 배치와 경고 문구를 정리한다 2026-03-20 15:58:27 +09:00
3a14bad2a4 fix(live-room): 채팅 얼림 토글 UI와 안내 문구를 정렬한다 2026-03-20 14:27:24 +09:00
a4ba3088b0 feat(live-room): 라이브룸 채팅 삭제 기능 구현을 반영한다 2026-03-20 10:51:16 +09:00
b17a0dcc0e fix(live-room): 채팅 얼림 상태 터치 경고 동작을 복구한다 2026-03-19 19:03:05 +09:00
26522cea3f feat(live-room): 채팅창 얼리기 기능을 추가한다
채팅 입력 제어와 룸 상태 동기화를 통합해 지연 입장자도 동일 상태를 적용한다.
2026-03-19 18:00:43 +09:00
70 changed files with 2773 additions and 346 deletions

View File

@@ -63,8 +63,8 @@ android {
applicationId "kr.co.vividnext.sodalive" applicationId "kr.co.vividnext.sodalive"
minSdk 23 minSdk 23
targetSdk 35 targetSdk 35
versionCode 227 versionCode 234
versionName "1.52.1" versionName "1.54.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -14,6 +14,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Android 15+ 녹화 상태 콜백(addScreenRecordingCallback)에 필요한 권한 -->
<uses-permission android:name="android.permission.DETECT_SCREEN_RECORDING" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" android:name="android.permission.READ_EXTERNAL_STORAGE"

View File

@@ -22,7 +22,6 @@ import kr.co.vividnext.sodalive.audio_content.upload.theme.GetAudioContentThemeR
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.explorer.profile.GetAudioContentListResponse import kr.co.vividnext.sodalive.explorer.profile.GetAudioContentListResponse
import kr.co.vividnext.sodalive.home.AudioContentMainItem import kr.co.vividnext.sodalive.home.AudioContentMainItem
import kr.co.vividnext.sodalive.settings.ContentType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import retrofit2.http.Body import retrofit2.http.Body
@@ -39,8 +38,6 @@ import retrofit2.http.Query
interface AudioContentApi { interface AudioContentApi {
@GET("/audio-content/all") @GET("/audio-content/all")
fun getAllAudioContents( fun getAllAudioContents(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Query("isFree") isFree: Boolean?, @Query("isFree") isFree: Boolean?,
@@ -54,7 +51,6 @@ interface AudioContentApi {
fun getAudioContentList( fun getAudioContentList(
@Query("creator-id") id: Long, @Query("creator-id") id: Long,
@Query("category-id") categoryId: Long, @Query("category-id") categoryId: Long,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Query("sort-type") sort: AudioContentViewModel.Sort, @Query("sort-type") sort: AudioContentViewModel.Sort,
@@ -63,8 +59,6 @@ interface AudioContentApi {
@GET("/audio-content/replay-live") @GET("/audio-content/replay-live")
fun getAudioContentReplayLiveList( fun getAudioContentReplayLiveList(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Flowable<ApiResponse<List<GetAudioContentMainItem>>> ): Flowable<ApiResponse<List<GetAudioContentMainItem>>>
@@ -75,8 +69,6 @@ interface AudioContentApi {
@GET("/audio-content/theme/active") @GET("/audio-content/theme/active")
fun getAudioContentActiveThemeList( fun getAudioContentActiveThemeList(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("isFree") isFree: Boolean?, @Query("isFree") isFree: Boolean?,
@Query("isPointAvailableOnly") isPointAvailableOnly: Boolean?, @Query("isPointAvailableOnly") isPointAvailableOnly: Boolean?,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
@@ -85,8 +77,6 @@ interface AudioContentApi {
@GET("/audio-content/theme/{id}/content") @GET("/audio-content/theme/{id}/content")
fun getAudioContentByTheme( fun getAudioContentByTheme(
@Path("id") id: Long, @Path("id") id: Long,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Query("sort-type") sort: AudioContentViewModel.Sort, @Query("sort-type") sort: AudioContentViewModel.Sort,
@@ -175,8 +165,6 @@ interface AudioContentApi {
@GET("/audio-content/main/new") @GET("/audio-content/main/new")
fun getNewContentOfTheme( fun getNewContentOfTheme(
@Query("theme") theme: String, @Query("theme") theme: String,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetAudioContentMainItem>>> ): Single<ApiResponse<List<GetAudioContentMainItem>>>
@@ -184,8 +172,6 @@ interface AudioContentApi {
fun getNewContentAllOfTheme( fun getNewContentAllOfTheme(
@Query("isFree") isFree: Boolean, @Query("isFree") isFree: Boolean,
@Query("theme") theme: String, @Query("theme") theme: String,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
@@ -205,8 +191,6 @@ interface AudioContentApi {
@GET("/audio-content/main/theme") @GET("/audio-content/main/theme")
fun getNewContentThemeList( fun getNewContentThemeList(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<List<String>>> ): Single<ApiResponse<List<String>>>
@@ -225,8 +209,6 @@ interface AudioContentApi {
@GET("/audio-content/main/curation-list") @GET("/audio-content/main/curation-list")
fun getCurationList( fun getCurationList(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String

View File

@@ -6,9 +6,7 @@ import kr.co.vividnext.sodalive.audio_content.detail.PutAudioContentLikeRequest
import kr.co.vividnext.sodalive.audio_content.donation.AudioContentDonationRequest import kr.co.vividnext.sodalive.audio_content.donation.AudioContentDonationRequest
import kr.co.vividnext.sodalive.audio_content.order.OrderRequest import kr.co.vividnext.sodalive.audio_content.order.OrderRequest
import kr.co.vividnext.sodalive.audio_content.order.OrderType import kr.co.vividnext.sodalive.audio_content.order.OrderType
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.settings.ContentType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import java.util.TimeZone import java.util.TimeZone
@@ -27,7 +25,6 @@ class AudioContentRepository(
) = api.getAudioContentList( ) = api.getAudioContentList(
id = id, id = id,
categoryId = categoryId, categoryId = categoryId,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
page = page - 1, page = page - 1,
size = size, size = size,
sort = sort, sort = sort,
@@ -35,8 +32,6 @@ class AudioContentRepository(
) )
fun getAudioContentReplayLiveList(token: String) = api.getAudioContentReplayLiveList( fun getAudioContentReplayLiveList(token: String) = api.getAudioContentReplayLiveList(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
@@ -123,16 +118,12 @@ class AudioContentRepository(
) = api.getNewContentAllOfTheme( ) = api.getNewContentAllOfTheme(
isFree = isFree, isFree = isFree,
theme = theme, theme = theme,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
page = page - 1, page = page - 1,
size = size, size = size,
authHeader = token authHeader = token
) )
fun getNewContentThemeList(token: String) = api.getNewContentThemeList( fun getNewContentThemeList(token: String) = api.getNewContentThemeList(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
@@ -188,8 +179,6 @@ class AudioContentRepository(
token: String token: String
) = api.getAudioContentByTheme( ) = api.getAudioContentByTheme(
id = themeId, id = themeId,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
page = page - 1, page = page - 1,
size = size, size = size,
sort = sort, sort = sort,
@@ -205,8 +194,6 @@ class AudioContentRepository(
theme: String? = null, theme: String? = null,
token: String token: String
) = api.getAllAudioContents( ) = api.getAllAudioContents(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
page = page - 1, page = page - 1,
size = size, size = size,
isFree = isFree, isFree = isFree,
@@ -221,8 +208,6 @@ class AudioContentRepository(
isPointAvailableOnly: Boolean? = null, isPointAvailableOnly: Boolean? = null,
token: String token: String
) = api.getAudioContentActiveThemeList( ) = api.getAudioContentActiveThemeList(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
isFree = isFree, isFree = isFree,
isPointAvailableOnly = isPointAvailableOnly, isPointAvailableOnly = isPointAvailableOnly,
authHeader = token authHeader = token

View File

@@ -4,7 +4,6 @@ import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.audio_content.series.detail.GetSeriesContentListResponse import kr.co.vividnext.sodalive.audio_content.series.detail.GetSeriesContentListResponse
import kr.co.vividnext.sodalive.audio_content.series.detail.GetSeriesDetailResponse import kr.co.vividnext.sodalive.audio_content.series.detail.GetSeriesDetailResponse
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.settings.ContentType
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Path import retrofit2.http.Path
@@ -15,8 +14,6 @@ interface SeriesApi {
fun getSeriesList( fun getSeriesList(
@Query("creatorId") creatorId: Long?, @Query("creatorId") creatorId: Long?,
@Query("sortType") sortType: SeriesListAllViewModel.SeriesSortType, @Query("sortType") sortType: SeriesListAllViewModel.SeriesSortType,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("isOriginal") isOriginal: Boolean?, @Query("isOriginal") isOriginal: Boolean?,
@Query("isCompleted") isCompleted: Boolean?, @Query("isCompleted") isCompleted: Boolean?,
@Query("page") page: Int, @Query("page") page: Int,
@@ -27,14 +24,12 @@ interface SeriesApi {
@GET("/audio-content/series/{id}") @GET("/audio-content/series/{id}")
fun getSeriesDetail( fun getSeriesDetail(
@Path("id") seriesId: Long, @Path("id") seriesId: Long,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<GetSeriesDetailResponse>> ): Single<ApiResponse<GetSeriesDetailResponse>>
@GET("/audio-content/series/{id}/content") @GET("/audio-content/series/{id}/content")
fun getSeriesContentList( fun getSeriesContentList(
@Path("id") seriesId: Long, @Path("id") seriesId: Long,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Query("sortType") sortType: SeriesListAllViewModel.SeriesSortType, @Query("sortType") sortType: SeriesListAllViewModel.SeriesSortType,

View File

@@ -1,8 +1,5 @@
package kr.co.vividnext.sodalive.audio_content.series package kr.co.vividnext.sodalive.audio_content.series
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.settings.ContentType
class SeriesRepository(private val api: SeriesApi) { class SeriesRepository(private val api: SeriesApi) {
fun getSeriesList( fun getSeriesList(
creatorId: Long?, creatorId: Long?,
@@ -15,8 +12,6 @@ class SeriesRepository(private val api: SeriesApi) {
) = api.getSeriesList( ) = api.getSeriesList(
creatorId = creatorId, creatorId = creatorId,
sortType = sortType, sortType = sortType,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
isOriginal = isOriginal, isOriginal = isOriginal,
isCompleted = isCompleted, isCompleted = isCompleted,
page = page - 1, page = page - 1,
@@ -26,7 +21,6 @@ class SeriesRepository(private val api: SeriesApi) {
fun getSeriesDetail(seriesId: Long, token: String) = api.getSeriesDetail( fun getSeriesDetail(seriesId: Long, token: String) = api.getSeriesDetail(
seriesId = seriesId, seriesId = seriesId,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
authHeader = token authHeader = token
) )
@@ -38,7 +32,6 @@ class SeriesRepository(private val api: SeriesApi) {
token: String token: String
) = api.getSeriesContentList( ) = api.getSeriesContentList(
seriesId = seriesId, seriesId = seriesId,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
page = page - 1, page = page - 1,
size = size, size = size,
sortType = sortType, sortType = sortType,

View File

@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.audio_content.series.main.by_genre.GetSeriesGenr
import kr.co.vividnext.sodalive.audio_content.series.main.home.SeriesHomeResponse import kr.co.vividnext.sodalive.audio_content.series.main.home.SeriesHomeResponse
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.settings.ContentType
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Query import retrofit2.http.Query
@@ -14,23 +13,17 @@ import retrofit2.http.Query
interface SeriesMainApi { interface SeriesMainApi {
@GET("/audio-content/series/main") @GET("/audio-content/series/main")
fun fetchHome( fun fetchHome(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<SeriesHomeResponse>> ): Single<ApiResponse<SeriesHomeResponse>>
@GET("/audio-content/series/main/recommend") @GET("/audio-content/series/main/recommend")
fun getRecommendSeriesList( fun getRecommendSeriesList(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetSeriesListResponse.SeriesListItem>>> ): Single<ApiResponse<List<GetSeriesListResponse.SeriesListItem>>>
@GET("/audio-content/series/main/day-of-week") @GET("/audio-content/series/main/day-of-week")
fun getDayOfWeekSeriesList( fun getDayOfWeekSeriesList(
@Query("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek, @Query("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
@@ -38,16 +31,12 @@ interface SeriesMainApi {
@GET("/audio-content/series/main/genre-list") @GET("/audio-content/series/main/genre-list")
fun getGenreList( fun getGenreList(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetSeriesGenreListResponse>>> ): Single<ApiResponse<List<GetSeriesGenreListResponse>>>
@GET("/audio-content/series/main/list-by-genre") @GET("/audio-content/series/main/list-by-genre")
fun getSeriesListByGenre( fun getSeriesListByGenre(
@Query("genreId") genreId: Long, @Query("genreId") genreId: Long,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String

View File

@@ -1,21 +1,15 @@
package kr.co.vividnext.sodalive.audio_content.series.main package kr.co.vividnext.sodalive.audio_content.series.main
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.settings.ContentType
class SeriesMainRepository( class SeriesMainRepository(
private val api: SeriesMainApi private val api: SeriesMainApi
) { ) {
fun fetchData(token: String) = api.fetchHome( fun fetchData(token: String) = api.fetchHome(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
fun getRecommendSeriesList(token: String) = api.getRecommendSeriesList( fun getRecommendSeriesList(token: String) = api.getRecommendSeriesList(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
@@ -26,16 +20,12 @@ class SeriesMainRepository(
token: String token: String
) = api.getDayOfWeekSeriesList( ) = api.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek, dayOfWeek = dayOfWeek,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
page = page - 1, page = page - 1,
size = size, size = size,
authHeader = token authHeader = token
) )
fun getGenreList(token: String) = api.getGenreList( fun getGenreList(token: String) = api.getGenreList(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
@@ -46,8 +36,6 @@ class SeriesMainRepository(
token: String token: String
) = api.getSeriesListByGenre( ) = api.getSeriesListByGenre(
genreId = genreId, genreId = genreId,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
page = page - 1, page = page - 1,
size = size, size = size,
authHeader = token authHeader = token

View File

@@ -27,6 +27,7 @@ import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.PurchaseOption import kr.co.vividnext.sodalive.audio_content.PurchaseOption
import kr.co.vividnext.sodalive.audio_content.upload.theme.AudioContentThemeFragment import kr.co.vividnext.sodalive.audio_content.upload.theme.AudioContentThemeFragment
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.AdultContentVisibilityPolicy
import kr.co.vividnext.sodalive.common.ImagePickerCropper import kr.co.vividnext.sodalive.common.ImagePickerCropper
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.RealPathUtil import kr.co.vividnext.sodalive.common.RealPathUtil
@@ -206,7 +207,7 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
) )
} }
if (SharedPreferenceManager.isAuth) { if (shouldShowAdultRestrictionSetting()) {
binding.llSetAdult.visibility = View.VISIBLE binding.llSetAdult.visibility = View.VISIBLE
} else { } else {
binding.llSetAdult.visibility = View.GONE binding.llSetAdult.visibility = View.GONE
@@ -540,7 +541,7 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
} }
} }
if (SharedPreferenceManager.isAuth) { if (shouldShowAdultRestrictionSetting()) {
binding.llAgeAll.setOnClickListener { binding.llAgeAll.setOnClickListener {
viewModel.setAdult(false) viewModel.setAdult(false)
} }
@@ -955,4 +956,12 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
return fileName return fileName
} }
private fun shouldShowAdultRestrictionSetting(): Boolean {
return AdultContentVisibilityPolicy.shouldShowAdultRestrictionSetting(
countryCode = SharedPreferenceManager.countryCode,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
isAuth = SharedPreferenceManager.isAuth
)
}
} }

View File

@@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Co
import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllActivity import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllActivity
import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllAdapter import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllAdapter
import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacterAdapter import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacterAdapter
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterTabBinding import kr.co.vividnext.sodalive.databinding.FragmentCharacterTabBinding
@@ -33,6 +34,7 @@ import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.mypage.MyPageViewModel import kr.co.vividnext.sodalive.mypage.MyPageViewModel
import kr.co.vividnext.sodalive.mypage.auth.Auth import kr.co.vividnext.sodalive.mypage.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
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
@@ -375,7 +377,8 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
return return
} }
if (!SharedPreferenceManager.isAuth) { val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = requireActivity(), activity = requireActivity(),
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -390,6 +393,15 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
return return
} }
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
onAuthed() onAuthed()
} }

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.common
object AdultContentVisibilityPolicy {
private const val COUNTRY_CODE_KR = "KR"
fun shouldShowAdultRestrictionSetting(
countryCode: String,
isAdultContentVisible: Boolean,
isAuth: Boolean
): Boolean {
if (!isAdultContentVisible) {
return false
}
val isKoreanCountry = countryCode.ifBlank { COUNTRY_CODE_KR } == COUNTRY_CODE_KR
return !isKoreanCountry || isAuth
}
}

View File

@@ -7,6 +7,7 @@ object Constants {
const val PREF_EMAIL = "pref_email" const val PREF_EMAIL = "pref_email"
const val PREF_USER_ID = "pref_user_id" const val PREF_USER_ID = "pref_user_id"
const val PREF_IS_ADULT = "pref_is_adult" const val PREF_IS_ADULT = "pref_is_adult"
const val PREF_COUNTRY_CODE = "pref_country_code"
const val PREF_NICKNAME = "pref_nickname" const val PREF_NICKNAME = "pref_nickname"
const val PREF_USER_ROLE = "pref_user_role" const val PREF_USER_ROLE = "pref_user_role"
const val PREF_NO_CHAT_ROOM = "pref_no_chat" const val PREF_NO_CHAT_ROOM = "pref_no_chat"
@@ -81,6 +82,7 @@ object Constants {
const val EXTRA_AUDIO_CONTENT_PLAYLIST = "extra_audio_content_playlist" const val EXTRA_AUDIO_CONTENT_PLAYLIST = "extra_audio_content_playlist"
const val EXTRA_PLAYLIST_SEGMENT_LOOP_IMAGE = "extra_playlist_segment_loop_image" const val EXTRA_PLAYLIST_SEGMENT_LOOP_IMAGE = "extra_playlist_segment_loop_image"
const val EXTRA_IS_SHOW_SECRET = "extra_is_show_secret" const val EXTRA_IS_SHOW_SECRET = "extra_is_show_secret"
const val EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE = "extra_show_sensitive_content_guide"
const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2 const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2
const val ACTION_AUDIO_CONTENT_RECEIVER = "soda_live_action_content_receiver" const val ACTION_AUDIO_CONTENT_RECEIVER = "soda_live_action_content_receiver"

View File

@@ -220,6 +220,12 @@ object SharedPreferenceManager {
setPreference(Constants.PREF_IS_ADULT, value) setPreference(Constants.PREF_IS_ADULT, value)
} }
var countryCode: String
get() = getPreference(Constants.PREF_COUNTRY_CODE, "KR")
set(value) {
setPreference(Constants.PREF_COUNTRY_CODE, value)
}
var isAuditionNotification: Boolean var isAuditionNotification: Boolean
get() = getPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, false) get() = getPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, false)
set(value) { set(value) {
@@ -227,7 +233,7 @@ object SharedPreferenceManager {
} }
var isAdultContentVisible: Boolean var isAdultContentVisible: Boolean
get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true) get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, false)
set(value) { set(value) {
setPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, value) setPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, value)
} }

View File

@@ -331,7 +331,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { EventViewModel(get()) } viewModel { EventViewModel(get()) }
viewModel { NotificationSettingsViewModel(get()) } viewModel { NotificationSettingsViewModel(get()) }
viewModel { NotificationReceiveSettingsViewModel(get(), get()) } viewModel { NotificationReceiveSettingsViewModel(get(), get()) }
viewModel { ContentSettingsViewModel() } viewModel { ContentSettingsViewModel(get()) }
viewModel { SettingsViewModel(get(), get()) } viewModel { SettingsViewModel(get(), get()) }
viewModel { SeriesDetailViewModel(get(), get()) } viewModel { SeriesDetailViewModel(get(), get()) }
viewModel { SeriesListAllViewModel(get()) } viewModel { SeriesListAllViewModel(get()) }

View File

@@ -42,7 +42,6 @@ interface ExplorerApi {
fun getCreatorProfile( fun getCreatorProfile(
@Path("id") id: Long, @Path("id") id: Long,
@Query("timezone") timezone: String, @Query("timezone") timezone: String,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<GetCreatorProfileResponse>> ): Single<ApiResponse<GetCreatorProfileResponse>>

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.explorer
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.explorer.profile.GetCheersResponse import kr.co.vividnext.sodalive.explorer.profile.GetCheersResponse
import kr.co.vividnext.sodalive.explorer.profile.cheers.PostWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.cheers.PostWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest
@@ -25,7 +24,6 @@ class ExplorerRepository(
fun getCreatorProfile(id: Long, token: String) = api.getCreatorProfile( fun getCreatorProfile(id: Long, token: String) = api.getCreatorProfile(
id = id, id = id,
timezone = TimeZone.getDefault().id, timezone = TimeZone.getDefault().id,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
authHeader = token authHeader = token
) )

View File

@@ -4,7 +4,6 @@ import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.audio_content.main.GetAudioContentRankingItem import kr.co.vividnext.sodalive.audio_content.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.audio_content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.audio_content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.settings.ContentType
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Query import retrofit2.http.Query
@@ -13,39 +12,29 @@ interface HomeApi {
@GET("/api/home") @GET("/api/home")
fun getHomeData( fun getHomeData(
@Query("timezone") timezone: String, @Query("timezone") timezone: String,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<GetHomeResponse>> ): Single<ApiResponse<GetHomeResponse>>
@GET("/api/home/latest-content") @GET("/api/home/latest-content")
fun getLatestContentByTheme( fun getLatestContentByTheme(
@Query("theme") theme: String, @Query("theme") theme: String,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<List<AudioContentMainItem>>> ): Single<ApiResponse<List<AudioContentMainItem>>>
@GET("/api/home/day-of-week-series") @GET("/api/home/day-of-week-series")
fun getDayOfWeekSeriesList( fun getDayOfWeekSeriesList(
@Query("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek, @Query("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetSeriesListResponse.SeriesListItem>>> ): Single<ApiResponse<List<GetSeriesListResponse.SeriesListItem>>>
@GET("/api/home/recommend-contents") @GET("/api/home/recommend-contents")
fun getRecommendContents( fun getRecommendContents(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<List<AudioContentMainItem>>> ): Single<ApiResponse<List<AudioContentMainItem>>>
@GET("/api/home/content-ranking") @GET("/api/home/content-ranking")
fun getContentRankingBySort( fun getContentRankingBySort(
@Query("sort") sort: ContentRankingSortType, @Query("sort") sort: ContentRankingSortType,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<List<GetAudioContentRankingItem>>> ): Single<ApiResponse<List<GetAudioContentRankingItem>>>
} }

View File

@@ -61,6 +61,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper import kr.co.vividnext.sodalive.settings.language.LocaleHelper
@@ -1339,7 +1340,8 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return return
} }
if (!SharedPreferenceManager.isAuth) { val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = requireActivity(), activity = requireActivity(),
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -1354,6 +1356,15 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return return
} }
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
onAuthed() onAuthed()
} }
@@ -1363,7 +1374,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = requireActivity(), activity = requireActivity(),
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -1378,6 +1391,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return return
} }
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
}
onAuthed() onAuthed()
} }

View File

@@ -1,21 +1,15 @@
package kr.co.vividnext.sodalive.home package kr.co.vividnext.sodalive.home
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.settings.ContentType
import java.util.TimeZone import java.util.TimeZone
class HomeRepository(private val api: HomeApi) { class HomeRepository(private val api: HomeApi) {
fun fetchData(token: String) = api.getHomeData( fun fetchData(token: String) = api.getHomeData(
timezone = TimeZone.getDefault().id, timezone = TimeZone.getDefault().id,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
fun getLatestContentByTheme(theme: String, token: String) = api.getLatestContentByTheme( fun getLatestContentByTheme(theme: String, token: String) = api.getLatestContentByTheme(
theme = theme, theme = theme,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
@@ -23,14 +17,10 @@ class HomeRepository(private val api: HomeApi) {
dayOfWeek: SeriesPublishedDaysOfWeek, token: String dayOfWeek: SeriesPublishedDaysOfWeek, token: String
) = api.getDayOfWeekSeriesList( ) = api.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek, dayOfWeek = dayOfWeek,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
fun getRecommendContents(token: String) = api.getRecommendContents( fun getRecommendContents(token: String) = api.getRecommendContents(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
@@ -39,8 +29,6 @@ class HomeRepository(private val api: HomeApi) {
token: String token: String
) = api.getContentRankingBySort( ) = api.getContentRankingBySort(
sort = sortType, sort = sortType,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.entries[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
} }

View File

@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.live.reservation_status.GetLiveReservationRespon
import kr.co.vividnext.sodalive.live.room.CancelLiveRequest import kr.co.vividnext.sodalive.live.room.CancelLiveRequest
import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.live.room.SetChatFreezeRequest
import kr.co.vividnext.sodalive.live.room.SetManagerOrSpeakerOrAudienceRequest import kr.co.vividnext.sodalive.live.room.SetManagerOrSpeakerOrAudienceRequest
import kr.co.vividnext.sodalive.live.room.StartLiveRequest import kr.co.vividnext.sodalive.live.room.StartLiveRequest
import kr.co.vividnext.sodalive.live.room.create.CreateLiveRoomResponse import kr.co.vividnext.sodalive.live.room.create.CreateLiveRoomResponse
@@ -29,7 +30,6 @@ import kr.co.vividnext.sodalive.live.room.like.GetLiveRoomHeartTotalResponse
import kr.co.vividnext.sodalive.live.room.like.LiveRoomLikeHeartRequest import kr.co.vividnext.sodalive.live.room.like.LiveRoomLikeHeartRequest
import kr.co.vividnext.sodalive.live.room.profile.GetLiveRoomUserProfileResponse import kr.co.vividnext.sodalive.live.room.profile.GetLiveRoomUserProfileResponse
import kr.co.vividnext.sodalive.live.room.tag.GetLiveTagResponse import kr.co.vividnext.sodalive.live.room.tag.GetLiveTagResponse
import kr.co.vividnext.sodalive.settings.ContentType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import retrofit2.http.Body import retrofit2.http.Body
@@ -54,7 +54,6 @@ interface LiveApi {
@Query("timezone") timezone: String, @Query("timezone") timezone: String,
@Query("dateString") dateString: String?, @Query("dateString") dateString: String?,
@Query("status") status: LiveRoomStatus, @Query("status") status: LiveRoomStatus,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
@@ -186,6 +185,12 @@ interface LiveApi {
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<Any>> ): Single<ApiResponse<Any>>
@PUT("/live/room/info/set/chat-freeze")
fun setChatFreeze(
@Body request: SetChatFreezeRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@GET("/live/room/{id}/donation-list") @GET("/live/room/{id}/donation-list")
fun donationStatus( fun donationStatus(
@Path("id") id: Long, @Path("id") id: Long,
@@ -243,8 +248,6 @@ interface LiveApi {
@GET("/api/live") @GET("/api/live")
fun getLiveMain( fun getLiveMain(
@Query("timezone") timezone: String, @Query("timezone") timezone: String,
@Query("contentType") contentType: ContentType,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<LiveMainResponse>> ): Single<ApiResponse<LiveMainResponse>>
} }

View File

@@ -65,6 +65,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.settings.notification.MemberRole import kr.co.vividnext.sodalive.settings.notification.MemberRole
@@ -862,7 +863,9 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = requireActivity(), activity = requireActivity(),
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -877,6 +880,16 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
return return
} }
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
}
onAuthed() onAuthed()
} }

View File

@@ -3,12 +3,12 @@ package kr.co.vividnext.sodalive.live
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationRequest
import kr.co.vividnext.sodalive.live.reservation_status.CancelLiveReservationRequest import kr.co.vividnext.sodalive.live.reservation_status.CancelLiveReservationRequest
import kr.co.vividnext.sodalive.live.room.CancelLiveRequest import kr.co.vividnext.sodalive.live.room.CancelLiveRequest
import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest import kr.co.vividnext.sodalive.live.room.EnterOrQuitLiveRoomRequest
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.live.room.SetChatFreezeRequest
import kr.co.vividnext.sodalive.live.room.SetManagerOrSpeakerOrAudienceRequest import kr.co.vividnext.sodalive.live.room.SetManagerOrSpeakerOrAudienceRequest
import kr.co.vividnext.sodalive.live.room.StartLiveRequest import kr.co.vividnext.sodalive.live.room.StartLiveRequest
import kr.co.vividnext.sodalive.live.room.create.CreateLiveRoomResponse import kr.co.vividnext.sodalive.live.room.create.CreateLiveRoomResponse
@@ -19,7 +19,6 @@ import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationResponse
import kr.co.vividnext.sodalive.live.room.kick_out.LiveRoomKickOutRequest import kr.co.vividnext.sodalive.live.room.kick_out.LiveRoomKickOutRequest
import kr.co.vividnext.sodalive.live.room.like.LiveRoomLikeHeartRequest import kr.co.vividnext.sodalive.live.room.like.LiveRoomLikeHeartRequest
import kr.co.vividnext.sodalive.live.room.menu.MenuApi import kr.co.vividnext.sodalive.live.room.menu.MenuApi
import kr.co.vividnext.sodalive.settings.ContentType
import kr.co.vividnext.sodalive.user.CreatorFollowRequestRequest import kr.co.vividnext.sodalive.user.CreatorFollowRequestRequest
import kr.co.vividnext.sodalive.user.UserApi import kr.co.vividnext.sodalive.user.UserApi
import okhttp3.MultipartBody import okhttp3.MultipartBody
@@ -42,7 +41,6 @@ class LiveRepository(
timezone = TimeZone.getDefault().id, timezone = TimeZone.getDefault().id,
dateString = dateString, dateString = dateString,
status = status, status = status,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
page = page - 1, page = page - 1,
size = size, size = size,
authHeader = token authHeader = token
@@ -112,6 +110,18 @@ class LiveRepository(
authHeader = token authHeader = token
) )
fun setChatFreeze(
roomId: Long,
isChatFrozen: Boolean,
token: String
) = api.setChatFreeze(
request = SetChatFreezeRequest(
roomId = roomId,
isChatFrozen = isChatFrozen
),
authHeader = token
)
fun getRoomInfo(roomId: Long, token: String) = api.getRoomInfo(roomId, authHeader = token) fun getRoomInfo(roomId: Long, token: String) = api.getRoomInfo(roomId, authHeader = token)
fun getDonationMessageList( fun getDonationMessageList(
@@ -195,7 +205,7 @@ class LiveRepository(
fun setManager(roomId: Long, userId: Long, token: String) = api.setManager( fun setManager(roomId: Long, userId: Long, token: String) = api.setManager(
request = SetManagerOrSpeakerOrAudienceRequest(roomId, memberId = userId), request = SetManagerOrSpeakerOrAudienceRequest(roomId, memberId = userId),
authHeader = token, authHeader = token
) )
fun creatorFollow( fun creatorFollow(
@@ -274,8 +284,6 @@ class LiveRepository(
fun getLiveMain(token: String) = api.getLiveMain( fun getLiveMain(token: String) = api.getLiveMain(
timezone = TimeZone.getDefault().id, timezone = TimeZone.getDefault().id,
contentType = ContentType.values()[SharedPreferenceManager.contentPreference],
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
authHeader = token authHeader = token
) )
} }

View File

@@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.mypage.MyPageViewModel
import kr.co.vividnext.sodalive.mypage.auth.Auth import kr.co.vividnext.sodalive.mypage.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
@@ -119,7 +120,9 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = this, activity = this,
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -134,6 +137,16 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
return return
} }
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(applicationContext, ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
}
onAuthed() onAuthed()
} }

View File

@@ -6,11 +6,11 @@ import android.animation.ObjectAnimator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AlertDialog import android.app.AlertDialog
import android.content.BroadcastReceiver
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.BroadcastReceiver
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
@@ -39,6 +39,7 @@ import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@@ -48,6 +49,7 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
@@ -55,7 +57,9 @@ import androidx.core.graphics.withTranslation
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
@@ -100,6 +104,7 @@ import kr.co.vividnext.sodalive.live.room.chat.LiveRoomDonationChat
import kr.co.vividnext.sodalive.live.room.chat.LiveRoomJoinChat import kr.co.vividnext.sodalive.live.room.chat.LiveRoomJoinChat
import kr.co.vividnext.sodalive.live.room.chat.LiveRoomNormalChat import kr.co.vividnext.sodalive.live.room.chat.LiveRoomNormalChat
import kr.co.vividnext.sodalive.live.room.chat.LiveRoomRouletteDonationChat import kr.co.vividnext.sodalive.live.room.chat.LiveRoomRouletteDonationChat
import kr.co.vividnext.sodalive.live.room.chat.LiveRoomSystemNoticeChat
import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog
import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationMessageDialog import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationMessageDialog
import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationMessageViewModel import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationMessageViewModel
@@ -119,11 +124,13 @@ import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.report.ProfileReportDialog import kr.co.vividnext.sodalive.report.ProfileReportDialog
import kr.co.vividnext.sodalive.report.ReportType import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.report.UserReportDialog import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.notification.MemberRole import kr.co.vividnext.sodalive.settings.notification.MemberRole
import org.koin.android.ext.android.inject
import org.json.JSONObject import org.json.JSONObject
import org.koin.android.ext.android.inject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.function.Consumer
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.random.Random import kotlin.random.Random
import io.agora.rtc2.Constants as AgoraConstants import io.agora.rtc2.Constants as AgoraConstants
@@ -143,9 +150,14 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private lateinit var imm: InputMethodManager private lateinit var imm: InputMethodManager
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val chatAdapter = LiveRoomChatAdapter { userId -> private val chatAdapter = LiveRoomChatAdapter(
onClickProfile = { userId ->
showLiveRoomUserProfileDialog(userId = userId) showLiveRoomUserProfileDialog(userId = userId)
},
onLongClickNormalChat = { chat ->
onLongClickChat(chat)
} }
)
private lateinit var layoutManager: LinearLayoutManager private lateinit var layoutManager: LinearLayoutManager
private var rvChatBaseBottomMargin: Int? = null private var rvChatBaseBottomMargin: Int? = null
@@ -159,6 +171,15 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private var isMicrophoneMute = false private var isMicrophoneMute = false
private var isSpeaker = false private var isSpeaker = false
private var isCapturePrivacyMuted = false
private var isScreenRecordingActive = false
// 라이프사이클 중복 호출에서 콜백 재등록을 방지한다.
private var isScreenRecordingCallbackRegistered = false
// API 레벨별 콜백 인스턴스를 재사용해 등록/해제 짝을 보장한다.
private var screenRecordingCallback: Any? = null
private var isHost = false private var isHost = false
private var isAvailableLikeHeart = false private var isAvailableLikeHeart = false
@@ -169,6 +190,10 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
// joinChannel 중복 호출 방지 플래그 // joinChannel 중복 호출 방지 플래그
private var hasInvokedJoinChannel = false private var hasInvokedJoinChannel = false
// RTM/RTC 연결 완료 추적 플래그 (둘 다 연결되면 레이아웃 강제 갱신)
private var isRtcJoined = false
private var isRtmJoined = false
private var v2vSourceLanguage: String? = null private var v2vSourceLanguage: String? = null
private var v2vTargetLanguage: String? = null private var v2vTargetLanguage: String? = null
private var isV2vAvailable = false private var isV2vAvailable = false
@@ -190,6 +215,8 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
// region 채팅 금지 // region 채팅 금지
private var isNoChatting = false private var isNoChatting = false
private var isChatFrozen = false
private var hasShownInitialChatFreezeNotice = false
private var remainingNoChattingTime = NO_CHATTING_TIME private var remainingNoChattingTime = NO_CHATTING_TIME
private val countDownTimer = object : CountDownTimer(remainingNoChattingTime * 1000, 1000) { private val countDownTimer = object : CountDownTimer(remainingNoChattingTime * 1000, 1000) {
override fun onTick(millisUntilFinished: Long) { override fun onTick(millisUntilFinished: Long) {
@@ -241,6 +268,100 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
val noChatRoomList = SharedPreferenceManager.noChatRoomList val noChatRoomList = SharedPreferenceManager.noChatRoomList
return noChatRoomList.contains(roomId) return noChatRoomList.contains(roomId)
} }
private fun setChatFrozenState(isFrozen: Boolean) {
isChatFrozen = isFrozen
updateChatFreezeToggleUi()
updateChatInputState()
}
private fun updateChatInputState() {
val canInputChat = isHost || !isChatFrozen
binding.etChat.isEnabled = true
binding.etChat.isFocusable = canInputChat
binding.etChat.isFocusableInTouchMode = canInputChat
binding.etChat.isCursorVisible = canInputChat
binding.ivSend.isEnabled = canInputChat
binding.ivSend.alpha = if (canInputChat) {
1f
} else {
0.4f
}
if (!canInputChat) {
binding.etChat.clearFocus()
}
}
private fun updateChatFreezeToggleUi() {
binding.tvChatFreezeSwitch.setBackgroundResource(
if (isChatFrozen) {
R.drawable.bg_round_corner_10_803bb9f1
} else {
R.drawable.bg_round_corner_10_99525252
}
)
}
private fun showChatFreezeWarning() {
Toast.makeText(
applicationContext,
getString(R.string.chat_freeze_blocked),
Toast.LENGTH_SHORT
).show()
}
private fun buildChatFreezeStatusMessage(isFrozen: Boolean, isForHost: Boolean): String {
return if (isFrozen) {
if (isForHost) {
getString(R.string.chat_freeze_status_creator)
} else {
getString(R.string.chat_freeze_status_listener)
}
} else {
getString(R.string.chat_freeze_status_off)
}
}
private fun addChatFreezeStatusMessage(message: String) {
if (message.isBlank()) {
return
}
chatAdapter.items.add(
LiveRoomSystemNoticeChat(message = message)
)
invalidateChat()
}
private fun toggleChatFreeze() {
if (!isHost) {
return
}
val nextChatFrozen = !isChatFrozen
viewModel.setChatFreeze(roomId = roomId, isChatFrozen = nextChatFrozen) {
setChatFrozenState(nextChatFrozen)
val noticeMessage = buildChatFreezeStatusMessage(
isFrozen = nextChatFrozen,
isForHost = true
)
addChatFreezeStatusMessage(noticeMessage)
agora.sendRawMessageToGroup(
rawMessage = Gson().toJson(
LiveRoomChatRawMessage(
type = LiveRoomChatRawMessageType.TOGGLE_CHAT_FREEZE,
message = "",
can = 0,
donationMessage = "",
isChatFrozen = nextChatFrozen
)
).toByteArray()
)
}
}
// endregion // endregion
private val onBackPressedCallback = object : OnBackPressedCallback(true) { private val onBackPressedCallback = object : OnBackPressedCallback(true) {
@@ -250,6 +371,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
private val deepLinkConfirmReceiver = object : BroadcastReceiver() { private val deepLinkConfirmReceiver = object : BroadcastReceiver() {
@OptIn(UnstableApi::class)
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
val bundle = intent?.getBundleExtra(Constants.EXTRA_DATA) ?: return val bundle = intent?.getBundleExtra(Constants.EXTRA_DATA) ?: return
@@ -268,6 +390,9 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
initAgora() initAgora()
// 라이브룸 화면이 캡처/녹화 결과에 노출되지 않도록 보안 플래그를 적용한다.
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
applyKeyboardPanInsets() applyKeyboardPanInsets()
onBackPressedDispatcher.addCallback(this, onBackPressedCallback) onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
@@ -293,6 +418,9 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
IntentFilter(Constants.ACTION_LIVE_ROOM_DEEPLINK_CONFIRM) IntentFilter(Constants.ACTION_LIVE_ROOM_DEEPLINK_CONFIRM)
) )
// 포그라운드 진입 시 API 레벨별 캡처/녹화 감지를 시작한다.
syncCaptureSecurityPolicyByRole()
if (this::layoutManager.isInitialized) { if (this::layoutManager.isInitialized) {
layoutManager.scrollToPosition(chatAdapter.itemCount - 1) layoutManager.scrollToPosition(chatAdapter.itemCount - 1)
} }
@@ -313,11 +441,16 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
override fun onStop() { override fun onStop() {
LocalBroadcastManager.getInstance(this).unregisterReceiver(deepLinkConfirmReceiver) LocalBroadcastManager.getInstance(this).unregisterReceiver(deepLinkConfirmReceiver)
// 백그라운드 전환 시 콜백을 해제해 누수와 오탐지를 막는다.
unregisterCaptureSecurityCallbacks()
isForeground = false isForeground = false
super.onStop() super.onStop()
} }
override fun onDestroy() { override fun onDestroy() {
// 액티비티 종료 전에 강제 음소거 상태를 원복한다.
clearCapturePrivacyMuteState()
cropper.cleanup() cropper.cleanup()
hideKeyboard { hideKeyboard {
viewModel.quitRoom(roomId) { viewModel.quitRoom(roomId) {
@@ -334,6 +467,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
// endregion // endregion
// region setupView // region setupView
@SuppressLint("ClickableViewAccessibility")
override fun setupView() { override fun setupView() {
bindData() bindData()
@@ -509,7 +643,10 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
) )
binding.etChat.setOnFocusChangeListener { view, hasFocus -> binding.etChat.setOnFocusChangeListener { view, hasFocus ->
if (isNoChatting && hasFocus) { if (isChatFrozen && !isHost && hasFocus) {
showChatFreezeWarning()
view.clearFocus()
} else if (isNoChatting && hasFocus) {
Toast.makeText( Toast.makeText(
applicationContext, applicationContext,
getString( getString(
@@ -521,24 +658,23 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
view.clearFocus() view.clearFocus()
} }
} }
binding.etChat.setOnTouchListener { view, event ->
if (isChatFrozen && !isHost) {
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
showChatFreezeWarning()
}
true
} else {
false
}
}
binding.tvQuit.setOnClickListener { onClickQuit() } binding.tvQuit.setOnClickListener { onClickQuit() }
binding.flMicrophoneMute.setOnClickListener { binding.flMicrophoneMute.setOnClickListener {
microphoneMute() microphoneMute()
if (isMicrophoneMute) {
binding.ivMicrophoneMute.setImageResource(R.drawable.ic_mic_off)
binding.ivNotiMicrophoneMute.visibility = View.VISIBLE
} else {
binding.ivMicrophoneMute.setImageResource(R.drawable.ic_mic_on)
binding.ivNotiMicrophoneMute.visibility = View.GONE
}
} }
binding.flSpeakerMute.setOnClickListener { binding.flSpeakerMute.setOnClickListener {
speakerMute() speakerMute()
if (isSpeakerMute) {
binding.ivSpeakerMute.setImageResource(R.drawable.ic_speaker_off)
} else {
binding.ivSpeakerMute.setImageResource(R.drawable.ic_speaker_on)
}
} }
binding.etChat.setOnEditorActionListener { _, actionId, _ -> binding.etChat.setOnEditorActionListener { _, actionId, _ ->
@@ -576,6 +712,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
binding.tvBgSwitch.setOnClickListener { viewModel.toggleBackgroundImage() } binding.tvBgSwitch.setOnClickListener { viewModel.toggleBackgroundImage() }
binding.tvSignatureSwitch.setOnClickListener { viewModel.toggleSignatureImage() } binding.tvSignatureSwitch.setOnClickListener { viewModel.toggleSignatureImage() }
binding.tvChatFreezeSwitch.setOnClickListener { toggleChatFreeze() }
binding.tvV2vSignatureSwitch.setOnClickListener { toggleV2vCaption() } binding.tvV2vSignatureSwitch.setOnClickListener { toggleV2vCaption() }
binding.llDonation.setOnClickListener { binding.llDonation.setOnClickListener {
LiveRoomDonationRankingDialog( LiveRoomDonationRankingDialog(
@@ -598,6 +735,41 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
// endregion // endregion
// RTM과 RTC가 모두 연결되면 키보드를 잠깐 올렸다 내려 레이아웃을 강제 갱신한다.
// 로딩 다이얼로그가 화면을 덮고 있는 동안 수행하여 사용자에게 변화가 보이지 않도록 한다.
private fun tryForceLayoutRefresh(): Boolean {
if (!isRtcJoined || !isRtmJoined) return false
handler.post {
// 키보드가 화면을 밀어올리지 않도록 임시로 adjustNothing 전환
window.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
)
binding.etChat.requestFocus()
imm.showSoftInput(binding.etChat, InputMethodManager.SHOW_IMPLICIT)
handler.postDelayed({
imm.hideSoftInputFromWindow(
binding.etChat.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS
)
binding.etChat.clearFocus()
// 원래 softInputMode 복원
window.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN
)
// 키보드 트릭 완료 후 로딩 다이얼로그 dismiss
loadingDialog.dismiss()
}, 200)
}
return true
}
private fun applyKeyboardPanInsets() { private fun applyKeyboardPanInsets() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return return
@@ -921,9 +1093,26 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
viewModel.changeIsAdultLiveData.observe(this) { viewModel.changeIsAdultLiveData.observe(this) {
if (it && !SharedPreferenceManager.isAuth) { if (it) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
val shouldBlockByAuth = isKoreanCountry && !SharedPreferenceManager.isAuth
val shouldBlockBySensitiveContent = !isKoreanCountry && !SharedPreferenceManager.isAdultContentVisible
if (!shouldBlockByAuth && !shouldBlockBySensitiveContent) {
return@observe
}
agora.muteAllRemoteAudioStreams(true) agora.muteAllRemoteAudioStreams(true)
binding.rvChat.visibility = View.INVISIBLE binding.rvChat.visibility = View.INVISIBLE
if (shouldBlockBySensitiveContent) {
showToast(getString(R.string.screen_content_settings_sensitive_content_guide))
startActivity(
Intent(applicationContext, ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
finish()
} else {
SodaDialog( SodaDialog(
this@LiveRoomActivity, this@LiveRoomActivity,
layoutInflater, layoutInflater,
@@ -934,6 +1123,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
).show(screenWidth) ).show(screenWidth)
} }
} }
}
donationMessageViewModel.isLoading.observe(this) { donationMessageViewModel.isLoading.observe(this) {
if (it) { if (it) {
@@ -1105,6 +1295,24 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
isHost = response.creatorId == SharedPreferenceManager.userId isHost = response.creatorId == SharedPreferenceManager.userId
syncCaptureSecurityPolicyByRole()
binding.tvChatFreezeSwitch.visibility = if (isHost) {
View.VISIBLE
} else {
View.GONE
}
setChatFrozenState(response.isChatFrozen)
if (!isHost && response.isChatFrozen && !hasShownInitialChatFreezeNotice) {
addChatFreezeStatusMessage(
buildChatFreezeStatusMessage(
isFrozen = true,
isForHost = false
)
)
hasShownInitialChatFreezeNotice = true
}
initLikeHeartButton() initLikeHeartButton()
initRouletteSettingButton() initRouletteSettingButton()
activatingRouletteButton(isActiveRoulette = response.isActiveRoulette) activatingRouletteButton(isActiveRoulette = response.isActiveRoulette)
@@ -1383,7 +1591,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
rvChatBaseBottomMargin = it rvChatBaseBottomMargin = it
} }
val captionHeight = if (binding.tvV2vCaption.visibility == View.VISIBLE) { val captionHeight = if (binding.tvV2vCaption.isVisible) {
binding.tvV2vCaption.height binding.tvV2vCaption.height
} else { } else {
0 0
@@ -1412,14 +1620,14 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private fun setAudience() { private fun setAudience() {
isSpeaker = false isSpeaker = false
isMicrophoneMute = false isMicrophoneMute = false
agora.muteLocalAudioStream(false)
agora.setClientRole(AgoraConstants.CLIENT_ROLE_AUDIENCE) agora.setClientRole(AgoraConstants.CLIENT_ROLE_AUDIENCE)
// 수동 mute 상태와 캡처 강제 mute를 합성해 오디오 상태를 즉시 맞춘다.
applyEffectiveAudioMuteState()
handler.postDelayed({ handler.postDelayed({
binding.tvChangeListener.visibility = View.GONE binding.tvChangeListener.visibility = View.GONE
binding.tvChangeListener.setOnClickListener { } binding.tvChangeListener.setOnClickListener { }
binding.ivMicrophoneMute.setImageResource(R.drawable.ic_mic_on)
binding.flMicrophoneMute.visibility = View.GONE binding.flMicrophoneMute.visibility = View.GONE
binding.ivNotiMicrophoneMute.visibility = View.GONE
speakerListAdapter.muteSpeakers.remove(SharedPreferenceManager.userId.toInt()) speakerListAdapter.muteSpeakers.remove(SharedPreferenceManager.userId.toInt())
}, 100) }, 100)
} }
@@ -1427,14 +1635,165 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private fun setBroadcaster() { private fun setBroadcaster() {
isSpeaker = true isSpeaker = true
isMicrophoneMute = false isMicrophoneMute = false
agora.muteLocalAudioStream(false)
agora.setClientRole(AgoraConstants.CLIENT_ROLE_BROADCASTER) agora.setClientRole(AgoraConstants.CLIENT_ROLE_BROADCASTER)
// 역할 전환 직후에도 강제 mute 상태가 유지되도록 동기화한다.
applyEffectiveAudioMuteState()
handler.postDelayed({ handler.postDelayed({
binding.flMicrophoneMute.visibility = View.VISIBLE binding.flMicrophoneMute.visibility = View.VISIBLE
binding.ivNotiMicrophoneMute.visibility = View.GONE updateMicrophoneMuteUi(isMicrophoneMute || isCapturePrivacyMuted)
}, 100) }, 100)
} }
private fun syncCaptureSecurityPolicyByRole() {
if (isHost) {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
unregisterScreenRecordingCallback()
clearCapturePrivacyMuteState()
return
}
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (isForeground) {
registerScreenRecordingCallback()
}
}
@Suppress("UNCHECKED_CAST")
private fun registerScreenRecordingCallback() {
// Android 15(API 35)+에서만 스크린녹화 상태 콜백을 등록한다.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM || isScreenRecordingCallbackRegistered) {
return
}
if (screenRecordingCallback == null) {
screenRecordingCallback = Consumer<Int> { state ->
onScreenRecordingStateChanged(
isRecording = state == WindowManager.SCREEN_RECORDING_STATE_VISIBLE
)
}
}
val initialRecordingState = windowManager.addScreenRecordingCallback(
mainExecutor,
screenRecordingCallback as Consumer<Int>
)
isScreenRecordingCallbackRegistered = true
// 등록 시점의 현재 녹화 상태를 즉시 반영해 초기 상태 불일치를 방지한다.
onScreenRecordingStateChanged(
isRecording = initialRecordingState == WindowManager.SCREEN_RECORDING_STATE_VISIBLE
)
}
private fun unregisterCaptureSecurityCallbacks() {
// onStart에서 등록한 콜백을 반대로 해제하고 강제 mute 상태를 정리한다.
unregisterScreenRecordingCallback()
clearCapturePrivacyMuteState()
}
@Suppress("UNCHECKED_CAST")
private fun unregisterScreenRecordingCallback() {
// Android 15+에서 등록된 녹화 상태 콜백만 안전하게 해제한다.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM || !isScreenRecordingCallbackRegistered) {
return
}
val callback = screenRecordingCallback as? Consumer<Int>
if (callback != null) {
windowManager.removeScreenRecordingCallback(callback)
}
isScreenRecordingCallbackRegistered = false
}
private fun onScreenRecordingStateChanged(isRecording: Boolean) {
// 시스템이 알려준 녹화 가시 상태를 강제 mute 계산에 반영한다.
isScreenRecordingActive = isRecording
syncCapturePrivacyMuteState()
}
private fun clearCapturePrivacyMuteState() {
// 라이프사이클 해제 시 캡처 기반 플래그를 모두 초기화해 원복을 보장한다.
isScreenRecordingActive = false
syncCapturePrivacyMuteState()
}
private fun syncCapturePrivacyMuteState() {
val shouldMute = !isHost && isScreenRecordingActive
if (isCapturePrivacyMuted == shouldMute) {
return
}
isCapturePrivacyMuted = shouldMute
applyEffectiveAudioMuteState()
}
private fun applyEffectiveAudioMuteState() {
// 사용자 토글 mute와 캡처 강제 mute를 OR 결합해 실제 오디오 상태를 계산한다.
val shouldMuteMicrophone = isMicrophoneMute || isCapturePrivacyMuted
val shouldMuteSpeaker = isSpeakerMute || isCapturePrivacyMuted
// 계산된 결과를 Agora 엔진과 UI 상태에 동시에 반영한다.
agora.muteLocalAudioStream(shouldMuteMicrophone)
agora.muteAllRemoteAudioStreams(shouldMuteSpeaker)
updateMicrophoneMuteUi(shouldMuteMicrophone)
updateSpeakerMuteUi(shouldMuteSpeaker)
updateSelfMuteUiState(shouldMuteMicrophone)
}
private fun updateMicrophoneMuteUi(isMuted: Boolean) {
// 마이크 아이콘과 알림 배지를 동일 기준으로 갱신한다.
binding.ivMicrophoneMute.setImageResource(
if (isMuted) {
R.drawable.ic_mic_off
} else {
R.drawable.ic_mic_on
}
)
binding.ivNotiMicrophoneMute.visibility = if (isMuted) {
View.VISIBLE
} else {
View.GONE
}
}
private fun updateSpeakerMuteUi(isMuted: Boolean) {
// 스피커 아이콘을 현재 mute 상태와 동기화한다.
binding.ivSpeakerMute.setImageResource(
if (isMuted) {
R.drawable.ic_speaker_off
} else {
R.drawable.ic_speaker_on
}
)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateSelfMuteUiState(isMuted: Boolean) {
// 방장/청취자 표시 규칙에 맞춰 자기 자신의 mute 표시를 갱신한다.
if (!viewModel.isRoomInfoInitialized()) {
return
}
if (SharedPreferenceManager.userId == viewModel.roomInfoResponse.creatorId) {
setMuteSpeakerCreator(isMuted)
return
}
if (!this::speakerListAdapter.isInitialized) {
return
}
val userId = SharedPreferenceManager.userId.toInt()
if (isMuted) {
speakerListAdapter.muteSpeakers.add(userId)
} else {
speakerListAdapter.muteSpeakers.remove(userId)
}
speakerListAdapter.notifyDataSetChanged()
}
private fun changeListenerMessage(peerId: Long, isFromManager: Boolean = false) { private fun changeListenerMessage(peerId: Long, isFromManager: Boolean = false) {
agora.sendRawMessageToPeer( agora.sendRawMessageToPeer(
receiverUid = peerId.toString(), receiverUid = peerId.toString(),
@@ -1493,8 +1852,128 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
) )
} }
private fun onLongClickChat(chat: LiveRoomNormalChat) {
if (!isHost) {
return
}
LiveDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.screen_live_room_chat_delete_title),
desc = getString(
R.string.screen_live_room_chat_delete_message_format,
chat.nickname,
chat.chat
),
confirmButtonTitle = getString(R.string.confirm_delete_title),
confirmButtonClick = {
deleteChat(chat)
},
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {}
).show(screenWidth)
}
private fun createChatId(): String {
return "${SharedPreferenceManager.userId}_${System.currentTimeMillis()}_${Random.nextInt(1000, 9999)}"
}
private fun deleteChat(chat: LiveRoomNormalChat) {
if (chat.chatId.isNotBlank()) {
removeNormalChatById(chat.chatId)
} else {
removeNormalChatByUserAndMessage(
userId = chat.userId,
message = chat.chat
)
}
agora.sendRawMessageToGroup(
rawMessage = Gson().toJson(
LiveRoomChatRawMessage(
type = LiveRoomChatRawMessageType.DELETE_CHAT,
message = chat.chat,
can = 0,
donationMessage = "",
chatId = chat.chatId.takeIf { it.isNotBlank() },
targetUserId = chat.userId
)
).toByteArray()
)
}
private fun deleteChatsByUser(userId: Long) {
if (userId <= 0L) {
return
}
removeChatsByUserId(userId)
agora.sendRawMessageToGroup(
rawMessage = Gson().toJson(
LiveRoomChatRawMessage(
type = LiveRoomChatRawMessageType.DELETE_CHAT_BY_USER,
message = "",
can = 0,
donationMessage = "",
targetUserId = userId
)
).toByteArray()
)
}
private fun removeNormalChatById(chatId: String) {
if (chatId.isBlank()) {
return
}
val removed = chatAdapter.items.removeAll { chat ->
chat is LiveRoomNormalChat && chat.chatId == chatId
}
if (removed) {
invalidateChat()
}
}
private fun removeNormalChatByUserAndMessage(userId: Long, message: String) {
if (userId <= 0L || message.isBlank()) {
return
}
val targetIndex = chatAdapter.items.indexOfFirst { chat ->
chat is LiveRoomNormalChat && chat.userId == userId && chat.chat == message
}
if (targetIndex >= 0) {
chatAdapter.items.removeAt(targetIndex)
invalidateChat()
}
}
private fun removeChatsByUserId(userId: Long) {
if (userId <= 0L) {
return
}
val removed = chatAdapter.items.removeAll { chat ->
when (chat) {
is LiveRoomNormalChat -> chat.userId == userId
is LiveRoomDonationChat -> chat.memberId == userId
is LiveRoomRouletteDonationChat -> chat.memberId == userId
else -> false
}
}
if (removed) {
invalidateChat()
}
}
private fun kickOut(userId: Long) { private fun kickOut(userId: Long) {
viewModel.kickOut(roomId, userId) viewModel.kickOut(roomId, userId)
deleteChatsByUser(userId)
agora.sendRawMessageToPeer( agora.sendRawMessageToPeer(
receiverUid = userId.toString(), receiverUid = userId.toString(),
requestType = LiveRoomRequestType.KICK_OUT requestType = LiveRoomRequestType.KICK_OUT
@@ -1548,22 +2027,12 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private fun microphoneMute() { private fun microphoneMute() {
isMicrophoneMute = !isMicrophoneMute isMicrophoneMute = !isMicrophoneMute
agora.muteLocalAudioStream(isMicrophoneMute) applyEffectiveAudioMuteState()
if (SharedPreferenceManager.userId == viewModel.roomInfoResponse.creatorId) {
setMuteSpeakerCreator(isMicrophoneMute)
} else {
if (isMicrophoneMute) {
speakerListAdapter.muteSpeakers.add(SharedPreferenceManager.userId.toInt())
} else {
speakerListAdapter.muteSpeakers.remove(SharedPreferenceManager.userId.toInt())
}
}
} }
private fun speakerMute() { private fun speakerMute() {
isSpeakerMute = !isSpeakerMute isSpeakerMute = !isSpeakerMute
agora.muteAllRemoteAudioStreams(isSpeakerMute) applyEffectiveAudioMuteState()
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
@@ -1572,7 +2041,9 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
val profileUrl = viewModel.getUserProfileUrl(SharedPreferenceManager.userId.toInt()) val profileUrl = viewModel.getUserProfileUrl(SharedPreferenceManager.userId.toInt())
val rank = viewModel.getUserRank(SharedPreferenceManager.userId) val rank = viewModel.getUserRank(SharedPreferenceManager.userId)
if (isNoChatting) { if (isChatFrozen && !isHost) {
showChatFreezeWarning()
} else if (isNoChatting) {
Toast.makeText( Toast.makeText(
applicationContext, applicationContext,
getString( getString(
@@ -1583,24 +2054,37 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
).show() ).show()
} else if (binding.etChat.text.isNotBlank() && nickname.isNotBlank() && profileUrl.isNotBlank()) { } else if (binding.etChat.text.isNotBlank() && nickname.isNotBlank() && profileUrl.isNotBlank()) {
val message = binding.etChat.text.toString() val message = binding.etChat.text.toString()
val chatId = createChatId()
chatAdapter.items.add( chatAdapter.items.add(
LiveRoomNormalChat( LiveRoomNormalChat(
userId = SharedPreferenceManager.userId, userId = SharedPreferenceManager.userId,
profileUrl = profileUrl, profileUrl = profileUrl,
nickname = nickname, nickname = nickname,
rank = rank, rank = rank,
chat = message chat = message,
chatId = chatId
) )
) )
invalidateChat() invalidateChat()
agora.inputChat(message) { agora.sendRawMessageToGroup(
rawMessage = Gson().toJson(
LiveRoomChatRawMessage(
type = LiveRoomChatRawMessageType.NORMAL_CHAT,
message = message,
can = 0,
donationMessage = "",
chatId = chatId
)
).toByteArray(),
onFailure = {
Toast.makeText( Toast.makeText(
applicationContext, applicationContext,
getString(R.string.screen_live_room_connection_issue), getString(R.string.screen_live_room_connection_issue),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} }
)
binding.etChat.setText("") binding.etChat.setText("")
} }
} }
@@ -1743,6 +2227,8 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
override fun onJoinChannelSuccess(channel: String, uid: Int, elapsed: Int) { override fun onJoinChannelSuccess(channel: String, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed) super.onJoinChannelSuccess(channel, uid, elapsed)
Logger.e("onJoinChannelSuccess - uid: $uid, channel: $channel") Logger.e("onJoinChannelSuccess - uid: $uid, channel: $channel")
isRtcJoined = true
tryForceLayoutRefresh()
} }
override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) { override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
@@ -1951,6 +2437,81 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
} }
LiveRoomChatRawMessageType.NORMAL_CHAT -> {
if (message.message.isBlank()) {
return
}
if (
memberId.toLong() != SharedPreferenceManager.userId &&
!viewModel.isNotBlockedMember(memberId.toLong())
) {
return
}
val chatId = message.chatId ?: ""
if (chatId.isBlank()) {
return
}
handler.post {
val alreadyExists = chatAdapter.items.any { chat ->
chat is LiveRoomNormalChat && chat.chatId == chatId
}
if (!alreadyExists) {
chatAdapter.items.add(
LiveRoomNormalChat(
userId = memberId.toLong(),
profileUrl = profileUrl,
nickname = nickname,
rank = rank,
chat = message.message,
chatId = chatId
)
)
invalidateChat()
}
}
}
LiveRoomChatRawMessageType.DELETE_CHAT -> {
if (!viewModel.isEqualToHostId(memberId.toInt())) {
return
}
val chatId = message.chatId
if (!chatId.isNullOrBlank()) {
handler.post {
removeNormalChatById(chatId)
}
return
}
val targetUserId = message.targetUserId ?: return
if (message.message.isBlank()) {
return
}
handler.post {
removeNormalChatByUserAndMessage(
userId = targetUserId,
message = message.message
)
}
}
LiveRoomChatRawMessageType.DELETE_CHAT_BY_USER -> {
if (!viewModel.isEqualToHostId(memberId.toInt())) {
return
}
val targetUserId = message.targetUserId ?: return
handler.post {
removeChatsByUserId(targetUserId)
}
}
LiveRoomChatRawMessageType.DONATION -> { LiveRoomChatRawMessageType.DONATION -> {
handler.post { handler.post {
chatAdapter.items.add( chatAdapter.items.add(
@@ -1981,10 +2542,27 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
) )
} }
LiveRoomChatRawMessageType.TOGGLE_CHAT_FREEZE -> {
if (memberId.toLong() != SharedPreferenceManager.userId) {
handler.post {
val frozen = message.isChatFrozen ?: false
setChatFrozenState(frozen)
val statusMessage = buildChatFreezeStatusMessage(
isFrozen = frozen,
isForHost = false
)
addChatFreezeStatusMessage(statusMessage)
}
}
}
LiveRoomChatRawMessageType.ROULETTE_DONATION -> { LiveRoomChatRawMessageType.ROULETTE_DONATION -> {
handler.post { handler.post {
chatAdapter.items.add( chatAdapter.items.add(
LiveRoomRouletteDonationChat( LiveRoomRouletteDonationChat(
memberId = memberId.toLong(),
profileUrl = profileUrl, profileUrl = profileUrl,
nickname = nickname, nickname = nickname,
rouletteResult = message.message rouletteResult = message.message
@@ -2297,8 +2875,10 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
rtmToken = roomInfo.rtmToken, rtmToken = roomInfo.rtmToken,
channelName = roomInfo.channelName, channelName = roomInfo.channelName,
rtmChannelJoinSuccess = { rtmChannelJoinSuccess = {
handler.post { isRtmJoined = true
loadingDialog.dismiss() // 두 채널 모두 연결 시 키보드 트릭 후 dismiss, 아니면 즉시 dismiss
if (!tryForceLayoutRefresh()) {
handler.post { loadingDialog.dismiss() }
} }
if (userId == roomInfo.creatorId) { if (userId == roomInfo.creatorId) {
@@ -3625,6 +4205,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
handler.post { handler.post {
chatAdapter.items.add( chatAdapter.items.add(
LiveRoomRouletteDonationChat( LiveRoomRouletteDonationChat(
memberId = SharedPreferenceManager.userId,
profileUrl = SharedPreferenceManager.profileImage, profileUrl = SharedPreferenceManager.profileImage,
nickname = SharedPreferenceManager.nickname, nickname = SharedPreferenceManager.nickname,
rouletteResult = randomItem rouletteResult = randomItem

View File

@@ -250,7 +250,14 @@ class LiveRoomViewModel(
getTotalDonationCan(roomId = roomId) getTotalDonationCan(roomId = roomId)
getTotalHeart(roomId = roomId) getTotalHeart(roomId = roomId)
if (it.data.isAdult && !SharedPreferenceManager.isAuth) { val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
val isAdultContentBlocked = if (isKoreanCountry) {
!SharedPreferenceManager.isAuth
} else {
!SharedPreferenceManager.isAdultContentVisible
}
if (it.data.isAdult && isAdultContentBlocked) {
_changeIsAdultLiveData.value = true _changeIsAdultLiveData.value = true
} }
@@ -543,6 +550,50 @@ class LiveRoomViewModel(
_isSignatureOn.value = !isSignatureOn.value!! _isSignatureOn.value = !isSignatureOn.value!!
} }
fun setChatFreeze(roomId: Long, isChatFrozen: Boolean, onSuccess: () -> Unit) {
_isLoading.value = true
compositeDisposable.add(
repository.setChatFreeze(
roomId = roomId,
isChatFrozen = isChatFrozen,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
_isLoading.value = false
if (it.success) {
if (isRoomInfoInitialized()) {
roomInfoResponse = roomInfoResponse.copy(isChatFrozen = isChatFrozen)
_roomInfoLiveData.postValue(roomInfoResponse)
}
onSuccess()
getRoomInfo(roomId)
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.msg_live_room_edit_update_failed)
)
}
}
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue(
SodaLiveApplicationHolder.get()
.getString(R.string.msg_live_room_edit_update_failed)
)
}
)
)
}
fun editLiveRoomInfo( fun editLiveRoomInfo(
roomId: Long, roomId: Long,
newTitle: String, newTitle: String,

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.live.room
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class SetChatFreezeRequest(
@SerializedName("roomId") val roomId: Long,
@SerializedName("isChatFrozen") val isChatFrozen: Boolean
)

View File

@@ -39,7 +39,8 @@ abstract class LiveRoomChat {
abstract fun bind( abstract fun bind(
context: Context, context: Context,
binding: ViewBinding, binding: ViewBinding,
onClickProfile: ((Long) -> Unit)? = null onClickProfile: ((Long) -> Unit)? = null,
onLongClickChat: ((LiveRoomNormalChat) -> Unit)? = null
) )
} }
@@ -48,7 +49,12 @@ data class LiveRoomJoinChat(
val nickname: String val nickname: String
) : LiveRoomChat() { ) : LiveRoomChat() {
override var type = LiveRoomChatType.JOIN override var type = LiveRoomChatType.JOIN
override fun bind(context: Context, binding: ViewBinding, onClickProfile: ((Long) -> Unit)?) { override fun bind(
context: Context,
binding: ViewBinding,
onClickProfile: ((Long) -> Unit)?,
onLongClickChat: ((LiveRoomNormalChat) -> Unit)?
) {
(binding as ItemLiveRoomJoinChatBinding).tvJoin.setTextColor( (binding as ItemLiveRoomJoinChatBinding).tvJoin.setTextColor(
ContextCompat.getColor(context, R.color.color_eeeeee) ContextCompat.getColor(context, R.color.color_eeeeee)
) )
@@ -83,6 +89,26 @@ data class LiveRoomJoinChat(
} }
} }
@Keep
data class LiveRoomSystemNoticeChat(
val message: String
) : LiveRoomChat() {
override var type = LiveRoomChatType.JOIN
override fun bind(
context: Context,
binding: ViewBinding,
onClickProfile: ((Long) -> Unit)?,
onLongClickChat: ((LiveRoomNormalChat) -> Unit)?
) {
val itemBinding = binding as ItemLiveRoomJoinChatBinding
itemBinding.tvJoin.setTextColor(
ContextCompat.getColor(context, R.color.color_eeeeee)
)
itemBinding.tvJoin.text = message
itemBinding.root.setBackgroundResource(R.drawable.bg_round_corner_4_7_cc004462)
}
}
@Keep @Keep
data class LiveRoomNormalChat( data class LiveRoomNormalChat(
@SerializedName("userId") val userId: Long, @SerializedName("userId") val userId: Long,
@@ -90,8 +116,14 @@ data class LiveRoomNormalChat(
@SerializedName("nickname") val nickname: String, @SerializedName("nickname") val nickname: String,
@SerializedName("rank") val rank: Int, @SerializedName("rank") val rank: Int,
@SerializedName("chat") val chat: String, @SerializedName("chat") val chat: String,
@SerializedName("chatId") val chatId: String = ""
) : LiveRoomChat() { ) : LiveRoomChat() {
override fun bind(context: Context, binding: ViewBinding, onClickProfile: ((Long) -> Unit)?) { override fun bind(
context: Context,
binding: ViewBinding,
onClickProfile: ((Long) -> Unit)?,
onLongClickChat: ((LiveRoomNormalChat) -> Unit)?
) {
val itemBinding = binding as ItemLiveRoomChatBinding val itemBinding = binding as ItemLiveRoomChatBinding
itemBinding.ivProfile.load(profileUrl) { itemBinding.ivProfile.load(profileUrl) {
crossfade(true) crossfade(true)
@@ -207,6 +239,16 @@ data class LiveRoomNormalChat(
} else { } else {
itemBinding.llMessageBg.setBackgroundResource(R.drawable.bg_round_corner_3_3_99000000) itemBinding.llMessageBg.setBackgroundResource(R.drawable.bg_round_corner_3_3_99000000)
} }
itemBinding.root.setOnLongClickListener {
if (onLongClickChat != null) {
onLongClickChat(this@LiveRoomNormalChat)
true
} else {
false
}
}
itemBinding.ivCan.visibility = View.GONE itemBinding.ivCan.visibility = View.GONE
itemBinding.tvDonationMessage.visibility = View.GONE itemBinding.tvDonationMessage.visibility = View.GONE
itemBinding.root.setBackgroundResource(0) itemBinding.root.setBackgroundResource(0)
@@ -224,7 +266,12 @@ data class LiveRoomDonationChat(
@SerializedName("donationMessage") val donationMessage: String, @SerializedName("donationMessage") val donationMessage: String,
val isSecret: Boolean = false val isSecret: Boolean = false
) : LiveRoomChat() { ) : LiveRoomChat() {
override fun bind(context: Context, binding: ViewBinding, onClickProfile: ((Long) -> Unit)?) { override fun bind(
context: Context,
binding: ViewBinding,
onClickProfile: ((Long) -> Unit)?,
onLongClickChat: ((LiveRoomNormalChat) -> Unit)?
) {
val itemBinding = binding as ItemLiveRoomChatBinding val itemBinding = binding as ItemLiveRoomChatBinding
val defaultProfileSize = 33.3f.dpToPx().toInt() val defaultProfileSize = 33.3f.dpToPx().toInt()
val defaultCrownSize = 16.7f.dpToPx().toInt() val defaultCrownSize = 16.7f.dpToPx().toInt()
@@ -309,6 +356,7 @@ data class LiveRoomDonationChat(
itemBinding.llMessageBg.setPadding(0) itemBinding.llMessageBg.setPadding(0)
itemBinding.llMessageBg.background = null itemBinding.llMessageBg.background = null
itemBinding.root.setOnLongClickListener(null)
if (isSecret) { if (isSecret) {
itemBinding.root.setBackgroundResource(R.drawable.bg_round_corner_6_7_cc59548f) itemBinding.root.setBackgroundResource(R.drawable.bg_round_corner_6_7_cc59548f)
@@ -351,11 +399,17 @@ data class LiveRoomDonationChat(
@Keep @Keep
data class LiveRoomRouletteDonationChat( data class LiveRoomRouletteDonationChat(
@SerializedName("memberId") val memberId: Long,
@SerializedName("profileUrl") val profileUrl: String, @SerializedName("profileUrl") val profileUrl: String,
@SerializedName("nickname") val nickname: String, @SerializedName("nickname") val nickname: String,
@SerializedName("rouletteResult") val rouletteResult: String @SerializedName("rouletteResult") val rouletteResult: String
) : LiveRoomChat() { ) : LiveRoomChat() {
override fun bind(context: Context, binding: ViewBinding, onClickProfile: ((Long) -> Unit)?) { override fun bind(
context: Context,
binding: ViewBinding,
onClickProfile: ((Long) -> Unit)?,
onLongClickChat: ((LiveRoomNormalChat) -> Unit)?
) {
val itemBinding = binding as ItemLiveRoomChatBinding val itemBinding = binding as ItemLiveRoomChatBinding
val defaultProfileSize = 33.3f.dpToPx().toInt() val defaultProfileSize = 33.3f.dpToPx().toInt()
val defaultCrownSize = 16.7f.dpToPx().toInt() val defaultCrownSize = 16.7f.dpToPx().toInt()
@@ -418,6 +472,7 @@ data class LiveRoomRouletteDonationChat(
itemBinding.llMessageBg.setPadding(0) itemBinding.llMessageBg.setPadding(0)
itemBinding.llMessageBg.background = null itemBinding.llMessageBg.background = null
itemBinding.root.setOnLongClickListener(null)
itemBinding.root.setBackgroundResource(R.drawable.bg_round_corner_6_7_ccc25264) itemBinding.root.setBackgroundResource(R.drawable.bg_round_corner_6_7_ccc25264)
itemBinding.root.setPadding(33) itemBinding.root.setPadding(33)

View File

@@ -10,7 +10,8 @@ import kr.co.vividnext.sodalive.databinding.ItemLiveRoomDonationStatusChatBindin
import kr.co.vividnext.sodalive.databinding.ItemLiveRoomJoinChatBinding import kr.co.vividnext.sodalive.databinding.ItemLiveRoomJoinChatBinding
class LiveRoomChatAdapter( class LiveRoomChatAdapter(
private val onClickProfile: (Long) -> Unit private val onClickProfile: (Long) -> Unit,
private val onLongClickNormalChat: ((LiveRoomNormalChat) -> Unit)? = null
) : RecyclerView.Adapter<LiveRoomChatViewHolder>() { ) : RecyclerView.Adapter<LiveRoomChatViewHolder>() {
val items = mutableListOf<LiveRoomChat>() val items = mutableListOf<LiveRoomChat>()
@@ -53,7 +54,7 @@ class LiveRoomChatAdapter(
} }
override fun onBindViewHolder(holder: LiveRoomChatViewHolder, position: Int) { override fun onBindViewHolder(holder: LiveRoomChatViewHolder, position: Int) {
holder.bind(items[position], onClickProfile) holder.bind(items[position], onClickProfile, onLongClickNormalChat)
} }
override fun getItemCount() = items.count() override fun getItemCount() = items.count()
@@ -65,7 +66,11 @@ class LiveRoomChatAdapter(
abstract class LiveRoomChatViewHolder(binding: ViewBinding) : abstract class LiveRoomChatViewHolder(binding: ViewBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
abstract fun bind(chat: LiveRoomChat, onClickProfile: ((Long) -> Unit)? = null) abstract fun bind(
chat: LiveRoomChat,
onClickProfile: ((Long) -> Unit)? = null,
onLongClickChat: ((LiveRoomNormalChat) -> Unit)? = null
)
} }
class LiveRoomNormalChatViewHolder( class LiveRoomNormalChatViewHolder(
@@ -74,8 +79,9 @@ class LiveRoomNormalChatViewHolder(
) : LiveRoomChatViewHolder(binding) { ) : LiveRoomChatViewHolder(binding) {
override fun bind( override fun bind(
chat: LiveRoomChat, chat: LiveRoomChat,
onClickProfile: ((Long) -> Unit)? onClickProfile: ((Long) -> Unit)?,
) = chat.bind(context, binding, onClickProfile) onLongClickChat: ((LiveRoomNormalChat) -> Unit)?
) = chat.bind(context, binding, onClickProfile, onLongClickChat)
} }
class LiveRoomDonationStatusChatViewHolder( class LiveRoomDonationStatusChatViewHolder(
@@ -84,7 +90,8 @@ class LiveRoomDonationStatusChatViewHolder(
) : LiveRoomChatViewHolder(binding) { ) : LiveRoomChatViewHolder(binding) {
override fun bind( override fun bind(
chat: LiveRoomChat, chat: LiveRoomChat,
onClickProfile: ((Long) -> Unit)? onClickProfile: ((Long) -> Unit)?,
onLongClickChat: ((LiveRoomNormalChat) -> Unit)?
) = chat.bind(context, binding) ) = chat.bind(context, binding)
} }
@@ -94,6 +101,7 @@ class LiveRoomJoinChatViewHolder(
) : LiveRoomChatViewHolder(binding) { ) : LiveRoomChatViewHolder(binding) {
override fun bind( override fun bind(
chat: LiveRoomChat, chat: LiveRoomChat,
onClickProfile: ((Long) -> Unit)? onClickProfile: ((Long) -> Unit)?,
onLongClickChat: ((LiveRoomNormalChat) -> Unit)?
) = chat.bind(context, binding) ) = chat.bind(context, binding)
} }

View File

@@ -12,7 +12,10 @@ data class LiveRoomChatRawMessage(
@SerializedName("signature") val signature: LiveRoomDonationResponse? = null, @SerializedName("signature") val signature: LiveRoomDonationResponse? = null,
@SerializedName("signatureImageUrl") val signatureImageUrl: String? = null, @SerializedName("signatureImageUrl") val signatureImageUrl: String? = null,
@SerializedName("donationMessage") val donationMessage: String?, @SerializedName("donationMessage") val donationMessage: String?,
@SerializedName("isActiveRoulette") val isActiveRoulette: Boolean? = null @SerializedName("isActiveRoulette") val isActiveRoulette: Boolean? = null,
@SerializedName("isChatFrozen") val isChatFrozen: Boolean? = null,
@SerializedName("chatId") val chatId: String? = null,
@SerializedName("targetUserId") val targetUserId: Long? = null
) )
enum class LiveRoomChatRawMessageType { enum class LiveRoomChatRawMessageType {
@@ -31,6 +34,18 @@ enum class LiveRoomChatRawMessageType {
@SerializedName("TOGGLE_ROULETTE") @SerializedName("TOGGLE_ROULETTE")
TOGGLE_ROULETTE, TOGGLE_ROULETTE,
@SerializedName("TOGGLE_CHAT_FREEZE")
TOGGLE_CHAT_FREEZE,
@SerializedName("NORMAL_CHAT")
NORMAL_CHAT,
@SerializedName("DELETE_CHAT")
DELETE_CHAT,
@SerializedName("DELETE_CHAT_BY_USER")
DELETE_CHAT_BY_USER,
@SerializedName("ROULETTE_DONATION") @SerializedName("ROULETTE_DONATION")
ROULETTE_DONATION, ROULETTE_DONATION,

View File

@@ -24,6 +24,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.AdultContentVisibilityPolicy
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.ImagePickerCropper import kr.co.vividnext.sodalive.common.ImagePickerCropper
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
@@ -286,7 +287,7 @@ class LiveRoomCreateActivity : BaseActivity<ActivityLiveRoomCreateBinding>(
) )
} }
if (SharedPreferenceManager.isAuth) { if (shouldShowAdultRestrictionSetting()) {
binding.llSetAdult.visibility = View.VISIBLE binding.llSetAdult.visibility = View.VISIBLE
} else { } else {
binding.llSetAdult.visibility = View.GONE binding.llSetAdult.visibility = View.GONE
@@ -581,10 +582,7 @@ class LiveRoomCreateActivity : BaseActivity<ActivityLiveRoomCreateBinding>(
} }
} }
if (SharedPreferenceManager.role == MemberRole.CREATOR.name || if (shouldShowAdultRestrictionSetting()) {
SharedPreferenceManager.isAuth
) {
binding.llAgeAll.setOnClickListener { binding.llAgeAll.setOnClickListener {
viewModel.setAdult(false) viewModel.setAdult(false)
} }
@@ -920,4 +918,12 @@ class LiveRoomCreateActivity : BaseActivity<ActivityLiveRoomCreateBinding>(
) )
) )
} }
private fun shouldShowAdultRestrictionSetting(): Boolean {
return AdultContentVisibilityPolicy.shouldShowAdultRestrictionSetting(
countryCode = SharedPreferenceManager.countryCode,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
isAuth = SharedPreferenceManager.isAuth
)
}
} }

View File

@@ -10,6 +10,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.URLUtil import android.webkit.URLUtil
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
@@ -44,6 +45,11 @@ class LiveRoomDetailFragment(
private val onClickCancel: () -> Unit private val onClickCancel: () -> Unit
) : BottomSheetDialogFragment() { ) : BottomSheetDialogFragment() {
private data class SnsItem(
val url: String,
val iconResId: Int
)
private val viewModel: LiveRoomDetailViewModel by inject() private val viewModel: LiveRoomDetailViewModel by inject()
private lateinit var binding: FragmentLiveRoomDetailBinding private lateinit var binding: FragmentLiveRoomDetailBinding
@@ -273,41 +279,7 @@ class LiveRoomDetailFragment(
transformations(CircleCropTransformation()) transformations(CircleCropTransformation())
} }
if ( bindManagerSnsItems(manager)
manager.kakaoOpenChatUrl.isNullOrBlank() ||
!URLUtil.isValidUrl(manager.kakaoOpenChatUrl)
) {
binding.ivManagerOpenChat.visibility = View.GONE
} else {
binding.ivManagerOpenChat.visibility = View.VISIBLE
binding.ivManagerOpenChat.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, manager.kakaoOpenChatUrl.toUri()))
}
}
if (
manager.instagramUrl.isNullOrBlank() ||
!URLUtil.isValidUrl(manager.instagramUrl)
) {
binding.ivManagerInstagram.visibility = View.GONE
} else {
binding.ivManagerInstagram.visibility = View.VISIBLE
binding.ivManagerInstagram.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, manager.instagramUrl.toUri()))
}
}
if (
manager.youtubeUrl.isNullOrBlank() ||
!URLUtil.isValidUrl(manager.youtubeUrl)
) {
binding.ivManagerYoutube.visibility = View.GONE
} else {
binding.ivManagerYoutube.visibility = View.VISIBLE
binding.ivManagerYoutube.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, manager.youtubeUrl.toUri()))
}
}
if (manager.isCreator) { if (manager.isCreator) {
binding.tvManagerProfile.visibility = View.VISIBLE binding.tvManagerProfile.visibility = View.VISIBLE
@@ -321,6 +293,66 @@ class LiveRoomDetailFragment(
} }
} }
private fun bindManagerSnsItems(manager: GetRoomDetailManager) {
val snsItems = listOf(
SnsItem(
url = manager.youtubeUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_youtube
),
SnsItem(
url = manager.instagramUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_instagram
),
SnsItem(
url = manager.xUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_x
),
SnsItem(
url = manager.fancimmUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_fancimm
),
SnsItem(
url = manager.kakaoOpenChatUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_kakao
)
).filter { item ->
item.url.isNotBlank() && URLUtil.isValidUrl(item.url)
}
binding.llManagerSnsIcons.removeAllViews()
binding.llManagerSnsIcons.visibility = if (snsItems.isEmpty()) {
View.GONE
} else {
View.VISIBLE
}
snsItems.forEachIndexed { index, item ->
val imageView = ImageView(requireContext()).apply {
setImageResource(item.iconResId)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
if (index > 0) {
marginStart = 8.dpToPx().toInt()
}
}
setOnClickListener {
openUrl(item.url)
}
}
binding.llManagerSnsIcons.addView(imageView)
}
}
private fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
if (intent.resolveActivity(requireActivity().packageManager) != null) {
startActivity(intent)
}
}
private fun setParticipantUserSummary(participatingUsers: List<GetRoomDetailUser>) { private fun setParticipantUserSummary(participatingUsers: List<GetRoomDetailUser>) {
val userCount = if (participatingUsers.size > 10) { val userCount = if (participatingUsers.size > 10) {
10 10

View File

@@ -27,6 +27,7 @@ data class GetRoomInfoResponse(
@SerializedName("menuPan") val menuPan: String, @SerializedName("menuPan") val menuPan: String,
@SerializedName("creatorLanguageCode") val creatorLanguageCode: String?, @SerializedName("creatorLanguageCode") val creatorLanguageCode: String?,
@SerializedName("isActiveRoulette") val isActiveRoulette: Boolean, @SerializedName("isActiveRoulette") val isActiveRoulette: Boolean,
@SerializedName("isChatFrozen") val isChatFrozen: Boolean = false,
@SerializedName("isPrivateRoom") val isPrivateRoom: Boolean, @SerializedName("isPrivateRoom") val isPrivateRoom: Boolean,
@SerializedName("password") val password: String? = null @SerializedName("password") val password: String? = null
) )

View File

@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.audio_content.PlaybackTrackingData
import kr.co.vividnext.sodalive.audio_content.PlaybackTrackingRepository import kr.co.vividnext.sodalive.audio_content.PlaybackTrackingRepository
import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.settings.ContentType
import kr.co.vividnext.sodalive.settings.event.EventItem import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.settings.event.EventRepository import kr.co.vividnext.sodalive.settings.event.EventRepository
import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest
@@ -103,6 +104,19 @@ class MainViewModel(
SharedPreferenceManager.point = data.point SharedPreferenceManager.point = data.point
SharedPreferenceManager.role = data.role.name SharedPreferenceManager.role = data.role.name
SharedPreferenceManager.isAuth = data.isAuth SharedPreferenceManager.isAuth = data.isAuth
val localCountryCode = SharedPreferenceManager.countryCode.ifBlank { "KR" }
val resolvedCountryCode = data.countryCode?.ifBlank { "KR" } ?: localCountryCode
val resolvedIsAdultContentVisible =
data.isAdultContentVisible ?: SharedPreferenceManager.isAdultContentVisible
val resolvedContentType =
data.contentType
?: ContentType.entries.getOrNull(SharedPreferenceManager.contentPreference)
?: ContentType.ALL
SharedPreferenceManager.countryCode = resolvedCountryCode
SharedPreferenceManager.isAdultContentVisible = resolvedIsAdultContentVisible
SharedPreferenceManager.contentPreference = resolvedContentType.ordinal
SharedPreferenceManager.isAuditionNotification = SharedPreferenceManager.isAuditionNotification =
data.auditionNotice ?: false data.auditionNotice ?: false
if ( if (

View File

@@ -380,13 +380,30 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
} }
viewModel.myPageLiveData.observe(viewLifecycleOwner) { viewModel.myPageLiveData.observe(viewLifecycleOwner) {
val isKoreanUser = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanUser) {
binding.btnIdentityVerification.root.visibility = View.VISIBLE
if (it.isAuth) { if (it.isAuth) {
FunctionButtonHelper.setupFunctionButton( FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root, buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth, iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verified) title = getString(R.string.screen_my_identity_verified)
) )
} else {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verification)
) {
showAuthDialog()
}
}
} else {
binding.btnIdentityVerification.root.visibility = View.INVISIBLE
}
if (it.isAuth) {
FunctionButtonHelper.setupFunctionButton( FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnCoupon.root, buttonView = binding.btnCoupon.root,
iconRes = R.drawable.ic_my_coupon, iconRes = R.drawable.ic_my_coupon,
@@ -400,14 +417,6 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
) )
} }
} else { } else {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verification)
) {
showAuthDialog()
}
FunctionButtonHelper.setupFunctionButton( FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnCoupon.root, buttonView = binding.btnCoupon.root,
iconRes = R.drawable.ic_my_coupon, iconRes = R.drawable.ic_my_coupon,

View File

@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.search
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.settings.ContentType
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Query import retrofit2.http.Query
@@ -11,16 +10,12 @@ interface SearchApi {
@GET("/search") @GET("/search")
fun searchUnified( fun searchUnified(
@Query("keyword") keyword: String, @Query("keyword") keyword: String,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<SearchUnifiedResponse>> ): Single<ApiResponse<SearchUnifiedResponse>>
@GET("/search/creators") @GET("/search/creators")
fun searchCreatorList( fun searchCreatorList(
@Query("keyword") keyword: String, @Query("keyword") keyword: String,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
@@ -29,8 +24,6 @@ interface SearchApi {
@GET("/search/contents") @GET("/search/contents")
fun searchContentList( fun searchContentList(
@Query("keyword") keyword: String, @Query("keyword") keyword: String,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
@@ -39,8 +32,6 @@ interface SearchApi {
@GET("/search/series") @GET("/search/series")
fun searchSeriesList( fun searchSeriesList(
@Query("keyword") keyword: String, @Query("keyword") keyword: String,
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Query("page") page: Int, @Query("page") page: Int,
@Query("size") size: Int, @Query("size") size: Int,
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String

View File

@@ -1,16 +1,11 @@
package kr.co.vividnext.sodalive.search package kr.co.vividnext.sodalive.search
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.settings.ContentType
class SearchRepository(private val api: SearchApi) { class SearchRepository(private val api: SearchApi) {
fun searchUnified( fun searchUnified(
keyword: String, keyword: String,
token: String token: String
) = api.searchUnified( ) = api.searchUnified(
keyword = keyword, keyword = keyword,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.values()[SharedPreferenceManager.contentPreference],
authHeader = token authHeader = token
) )
@@ -21,8 +16,6 @@ class SearchRepository(private val api: SearchApi) {
token: String token: String
) = api.searchCreatorList( ) = api.searchCreatorList(
keyword = keyword, keyword = keyword,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.values()[SharedPreferenceManager.contentPreference],
page = page - 1, page = page - 1,
size = size, size = size,
authHeader = token authHeader = token
@@ -35,8 +28,6 @@ class SearchRepository(private val api: SearchApi) {
token: String token: String
) = api.searchContentList( ) = api.searchContentList(
keyword = keyword, keyword = keyword,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.values()[SharedPreferenceManager.contentPreference],
page = page - 1, page = page - 1,
size = size, size = size,
authHeader = token authHeader = token
@@ -49,8 +40,6 @@ class SearchRepository(private val api: SearchApi) {
token: String token: String
) = api.searchSeriesList( ) = api.searchSeriesList(
keyword = keyword, keyword = keyword,
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.values()[SharedPreferenceManager.contentPreference],
page = page - 1, page = page - 1,
size = size, size = size,
authHeader = token authHeader = token

View File

@@ -3,9 +3,13 @@ package kr.co.vividnext.sodalive.settings
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityContentSettingsBinding import kr.co.vividnext.sodalive.databinding.ActivityContentSettingsBinding
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
@@ -16,37 +20,74 @@ class ContentSettingsActivity : BaseActivity<ActivityContentSettingsBinding>(
) { ) {
private val viewModel: ContentSettingsViewModel by inject() private val viewModel: ContentSettingsViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private val sensitiveContentConfirmDialog: SodaDialog by lazy {
SodaDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.dialog_sensitive_content_enable_title),
desc = getString(R.string.dialog_sensitive_content_enable_message),
confirmButtonTitle = getString(R.string.screen_live_room_yes),
confirmButtonClick = { viewModel.toggleAdultContentVisible() },
cancelButtonTitle = getString(R.string.screen_live_room_no),
cancelButtonClick = {}
)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
bindData() bindData()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
handleFinish() handleFinish()
} }
}) }
)
} }
override fun setupView() { override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = getString(R.string.screen_content_settings_title) binding.toolbar.tvBack.text = getString(R.string.screen_content_settings_title)
binding.toolbar.tvBack.setOnClickListener { handleFinish() } binding.toolbar.tvBack.setOnClickListener { handleFinish() }
if (intent.getBooleanExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, false)) {
Toast.makeText(
applicationContext,
getString(R.string.screen_content_settings_sensitive_content_guide),
Toast.LENGTH_LONG
).show()
}
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
val canControlSensitiveContent = if (isKoreanCountry) {
SharedPreferenceManager.isAuth
} else {
true
}
// 본인 인증 체크 // 본인 인증 체크
if (SharedPreferenceManager.isAuth) { if (canControlSensitiveContent) {
binding.llAdultContentVisible.visibility = View.VISIBLE binding.llAdultContentVisible.visibility = View.VISIBLE
// 19금 콘텐츠 보기 체크 // 19금 콘텐츠 보기 체크
if (SharedPreferenceManager.isAdultContentVisible) { if (SharedPreferenceManager.isAdultContentVisible) {
binding.llAdultContentPreference.visibility = View.VISIBLE binding.llAdultContentPreference.visibility = View.VISIBLE
} else { } else {
binding.llAdultContentPreference.visibility = View.GONE binding.llAdultContentPreference.visibility = View.GONE
} }
// 19금 콘텐츠 보기 스위치 액션 // 19금 콘텐츠 보기 스위치 액션
binding.ivAdultContentVisible.setOnClickListener { binding.ivAdultContentVisible.setOnClickListener {
val isAdultContentVisible = viewModel.isAdultContentVisible.value == true
if (isAdultContentVisible) {
viewModel.toggleAdultContentVisible() viewModel.toggleAdultContentVisible()
} else {
sensitiveContentConfirmDialog.show(screenWidth)
}
} }
binding.tvContentAll.setOnClickListener { binding.tvContentAll.setOnClickListener {
@@ -88,6 +129,21 @@ class ContentSettingsActivity : BaseActivity<ActivityContentSettingsBinding>(
else -> {} else -> {}
} }
} }
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(this) {
val text = it?.message ?: it?.resId?.let { resId -> getString(resId) }
text?.let { message ->
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
}
}
} }
private fun handleFinish() { private fun handleFinish() {

View File

@@ -1,39 +1,201 @@
package kr.co.vividnext.sodalive.settings package kr.co.vividnext.sodalive.settings
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.user.UserRepository
class ContentSettingsViewModel : BaseViewModel() { class ContentSettingsViewModel(
private var _isAdultContentVisible = MutableLiveData( private val userRepository: UserRepository
SharedPreferenceManager.isAdultContentVisible ) : BaseViewModel() {
private data class PreferenceState(
val isAdultContentVisible: Boolean,
val contentType: ContentType
) )
private val handler = Handler(Looper.getMainLooper())
private val initialState = getCurrentPreferenceState()
private var confirmedState = initialState
private var isRequestInFlight = false
private var pendingState: PreferenceState? = null
private var syncRunnable: Runnable? = null
private val _isAdultContentVisible = MutableLiveData(initialState.isAdultContentVisible)
val isAdultContentVisible: LiveData<Boolean> val isAdultContentVisible: LiveData<Boolean>
get() = _isAdultContentVisible get() = _isAdultContentVisible
private var _adultContentPreference = MutableLiveData( private val _adultContentPreference = MutableLiveData(initialState.contentType)
ContentType.values()[SharedPreferenceManager.contentPreference]
)
val adultContentPreference: LiveData<ContentType> val adultContentPreference: LiveData<ContentType>
get() = _adultContentPreference get() = _adultContentPreference
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _toastLiveData = MutableLiveData<ToastMessage?>()
val toastLiveData: LiveData<ToastMessage?>
get() = _toastLiveData
var isChangedAdultContentVisible = false var isChangedAdultContentVisible = false
private set
fun toggleAdultContentVisible() { fun toggleAdultContentVisible() {
val adultContentVisible = SharedPreferenceManager.isAdultContentVisible val currentState = getCurrentPreferenceState()
_isAdultContentVisible.value = !adultContentVisible val nextState = PreferenceState(
SharedPreferenceManager.isAdultContentVisible = !adultContentVisible isAdultContentVisible = !currentState.isAdultContentVisible,
isChangedAdultContentVisible = true contentType = if (currentState.isAdultContentVisible) {
ContentType.ALL
if (adultContentVisible) { } else {
SharedPreferenceManager.contentPreference = ContentType.ALL.ordinal currentState.contentType
} }
)
applyLocalState(nextState)
queueLatestStateForSync()
} }
fun setAdultContentPreference(adultContentPreference: ContentType) { fun setAdultContentPreference(adultContentPreference: ContentType) {
_adultContentPreference.value = adultContentPreference val currentState = getCurrentPreferenceState()
SharedPreferenceManager.contentPreference = adultContentPreference.ordinal if (currentState.contentType == adultContentPreference) {
isChangedAdultContentVisible = true return
}
applyLocalState(
currentState.copy(contentType = adultContentPreference)
)
queueLatestStateForSync()
}
private fun queueLatestStateForSync() {
pendingState = getCurrentPreferenceState()
syncRunnable?.let { handler.removeCallbacks(it) }
syncRunnable = Runnable {
flushPendingState()
}.also {
handler.postDelayed(it, CONTENT_PREFERENCE_DEBOUNCE_DELAY_MS)
}
}
private fun flushPendingState() {
if (isRequestInFlight) {
return
}
val targetState = pendingState ?: return
if (targetState == confirmedState) {
pendingState = null
return
}
pendingState = null
syncContentPreference(targetState)
}
private fun syncContentPreference(targetState: PreferenceState) {
val request = UpdateContentPreferenceRequest(
isAdultContentVisible = if (targetState.isAdultContentVisible != confirmedState.isAdultContentVisible) {
targetState.isAdultContentVisible
} else {
null
},
contentType = if (targetState.contentType != confirmedState.contentType) {
targetState.contentType
} else {
null
}
)
if (request.isAdultContentVisible == null && request.contentType == null) {
return
}
isRequestInFlight = true
_isLoading.value = true
compositeDisposable.add(
userRepository.updateContentPreference(
request = request,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isRequestInFlight = false
_isLoading.value = false
if (response.success) {
val syncedState = response.data?.let {
PreferenceState(
isAdultContentVisible = it.isAdultContentVisible,
contentType = it.contentType
)
} ?: targetState
confirmedState = syncedState
if (pendingState == null) {
applyLocalState(syncedState)
}
flushPendingState()
} else {
rollbackToConfirmedState(response.message)
}
},
{
isRequestInFlight = false
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
rollbackToConfirmedState(null)
}
)
)
}
private fun rollbackToConfirmedState(errorMessage: String?) {
pendingState = null
syncRunnable?.let { handler.removeCallbacks(it) }
syncRunnable = null
applyLocalState(confirmedState)
_toastLiveData.postValue(
errorMessage?.let { ToastMessage(message = it) } ?: ToastMessage(resId = R.string.retry)
)
}
private fun applyLocalState(state: PreferenceState) {
_isAdultContentVisible.value = state.isAdultContentVisible
_adultContentPreference.value = state.contentType
SharedPreferenceManager.isAdultContentVisible = state.isAdultContentVisible
SharedPreferenceManager.contentPreference = state.contentType.ordinal
isChangedAdultContentVisible = state != initialState
}
private fun getCurrentPreferenceState(): PreferenceState {
val localContentType = ContentType.entries.getOrNull(SharedPreferenceManager.contentPreference)
?: ContentType.ALL
return PreferenceState(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = localContentType
)
}
override fun onCleared() {
syncRunnable?.let { handler.removeCallbacks(it) }
syncRunnable = null
super.onCleared()
}
companion object {
private const val CONTENT_PREFERENCE_DEBOUNCE_DELAY_MS = 400L
} }
} }

View File

@@ -84,7 +84,8 @@ class SettingsActivity : BaseActivity<ActivitySettingsBinding>(ActivitySettingsB
) )
} }
if (SharedPreferenceManager.isAuth) { val isNotKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } != "KR"
if (SharedPreferenceManager.isAuth || isNotKoreanCountry) {
binding.dividerContentSettings.visibility = View.VISIBLE binding.dividerContentSettings.visibility = View.VISIBLE
binding.rlContentSettings.visibility = View.VISIBLE binding.rlContentSettings.visibility = View.VISIBLE
binding.rlContentSettings.setOnClickListener { binding.rlContentSettings.setOnClickListener {

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.settings
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class UpdateContentPreferenceRequest(
@SerializedName("isAdultContentVisible") val isAdultContentVisible: Boolean? = null,
@SerializedName("contentType") val contentType: ContentType? = null
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.settings
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class UpdateContentPreferenceResponse(
@SerializedName("isAdultContentVisible") val isAdultContentVisible: Boolean,
@SerializedName("contentType") val contentType: ContentType
)

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.settings.notification
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.settings.ContentType
@Keep @Keep
data class GetMemberInfoResponse( data class GetMemberInfoResponse(
@@ -18,7 +19,13 @@ data class GetMemberInfoResponse(
@SerializedName("followingChannelUploadContentNotice") @SerializedName("followingChannelUploadContentNotice")
val followingChannelUploadContentNotice: Boolean?, val followingChannelUploadContentNotice: Boolean?,
@SerializedName("auditionNotice") @SerializedName("auditionNotice")
val auditionNotice: Boolean? val auditionNotice: Boolean?,
@SerializedName("countryCode")
val countryCode: String? = null,
@SerializedName("isAdultContentVisible")
val isAdultContentVisible: Boolean? = null,
@SerializedName("contentType")
val contentType: ContentType? = null
) )
enum class MemberRole { enum class MemberRole {
@@ -26,5 +33,5 @@ enum class MemberRole {
USER, USER,
@SerializedName("CREATOR") @SerializedName("CREATOR")
CREATOR, CREATOR
} }

View File

@@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.mypage.block.GetBlockedMemberListResponse
import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse
import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest
import kr.co.vividnext.sodalive.mypage.profile.nickname.GetChangeNicknamePriceResponse import kr.co.vividnext.sodalive.mypage.profile.nickname.GetChangeNicknamePriceResponse
import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceRequest
import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceResponse
import kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponse import kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponse
import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.settings.signout.SignOutRequest import kr.co.vividnext.sodalive.settings.signout.SignOutRequest
@@ -26,6 +28,7 @@ import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Part import retrofit2.http.Part
@@ -49,6 +52,12 @@ interface UserApi {
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<GetMemberInfoResponse>> ): Single<ApiResponse<GetMemberInfoResponse>>
@PATCH("/member/content-preference")
fun updateContentPreference(
@Body request: UpdateContentPreferenceRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<UpdateContentPreferenceResponse>>
@GET("/push/notification/categories") @GET("/push/notification/categories")
fun getPushNotificationCategories( fun getPushNotificationCategories(
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String

View File

@@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.main.PushTokenUpdateRequest
import kr.co.vividnext.sodalive.mypage.MyPageResponse import kr.co.vividnext.sodalive.mypage.MyPageResponse
import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse
import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest
import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceRequest
import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceResponse
import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.settings.signout.SignOutRequest import kr.co.vividnext.sodalive.settings.signout.SignOutRequest
import kr.co.vividnext.sodalive.user.find_password.ForgotPasswordRequest import kr.co.vividnext.sodalive.user.find_password.ForgotPasswordRequest
@@ -38,6 +40,13 @@ class UserRepository(private val userApi: UserApi) {
fun getMemberInfo(token: String) = userApi.getMemberInfo(authHeader = token) fun getMemberInfo(token: String) = userApi.getMemberInfo(authHeader = token)
fun updateContentPreference(
request: UpdateContentPreferenceRequest,
token: String
): Single<ApiResponse<UpdateContentPreferenceResponse>> {
return userApi.updateContentPreference(request = request, authHeader = token)
}
fun getPushNotificationCategories(token: String): Single<ApiResponse<GetPushNotificationCategoryResponse>> { fun getPushNotificationCategories(token: String): Single<ApiResponse<GetPushNotificationCategoryResponse>> {
return userApi.getPushNotificationCategories(authHeader = token) return userApi.getPushNotificationCategories(authHeader = token)
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#803BB9F1" />
<corners android:radius="10dp" />
<stroke
android:width="1dp"
android:color="#803BB9F1" />
</shape>

View File

@@ -651,6 +651,24 @@
android:src="@drawable/ic_mic_on" /> android:src="@drawable/ic_mic_on" />
</FrameLayout> </FrameLayout>
<FrameLayout
android:id="@+id/tv_chat_freeze_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="13.3dp"
android:background="@drawable/bg_round_corner_10_99525252"
android:padding="10dp"
android:visibility="gone">
<ImageView
android:id="@+id/iv_chat_freeze"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:contentDescription="@null"
android:src="@drawable/ic_ice" />
</FrameLayout>
<FrameLayout <FrameLayout
android:id="@+id/fl_donation_message_list" android:id="@+id/fl_donation_message_list"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -248,33 +248,11 @@
android:orientation="vertical"> android:orientation="vertical">
<LinearLayout <LinearLayout
android:id="@+id/ll_manager_sns_icons"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"> android:orientation="horizontal"
android:visibility="gone" />
<ImageView
android:id="@+id/iv_manager_open_chat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:src="@drawable/ic_website_blue" />
<ImageView
android:id="@+id/iv_manager_instagram"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:contentDescription="@null"
android:src="@drawable/ic_instagram_blue" />
<ImageView
android:id="@+id/iv_manager_youtube"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:contentDescription="@null"
android:src="@drawable/ic_youtube_play_blue" />
</LinearLayout>
<TextView <TextView
android:id="@+id/tv_manager_profile" android:id="@+id/tv_manager_profile"

View File

@@ -31,6 +31,10 @@
<string name="chat_notice_character">VoiceOn AI Character Talk allows a high degree of freedom. You can be anyone in the conversation. Talk as a character in a universe or create your own story with a new persona together with the character.\nNote: AI Character Talk is in open beta; conversations may be awkward or incomplete.</string> <string name="chat_notice_character">VoiceOn AI Character Talk allows a high degree of freedom. You can be anyone in the conversation. Talk as a character in a universe or create your own story with a new persona together with the character.\nNote: AI Character Talk is in open beta; conversations may be awkward or incomplete.</string>
<string name="chat_character_type_clone">Clone</string> <string name="chat_character_type_clone">Clone</string>
<string name="chat_character_type_character">Character</string> <string name="chat_character_type_character">Character</string>
<string name="chat_freeze_status_creator">\"🧊 Freeze, everyone!\" The chat has been frozen.</string>
<string name="chat_freeze_status_listener">\"🧊 Freeze, everyone!\" The chat is now frozen.</string>
<string name="chat_freeze_status_off">\"💧 Ding!\" Chat freeze has been lifted.</string>
<string name="chat_freeze_blocked">🧊 The chat is now frozen.</string>
<string name="chat_profile_image_content_description">%1$s profile image</string> <string name="chat_profile_image_content_description">%1$s profile image</string>
<string name="chat_input_placeholder">Enter a message.</string> <string name="chat_input_placeholder">Enter a message.</string>
<string name="chat_send_failed">Failed to send message.</string> <string name="chat_send_failed">Failed to send message.</string>
@@ -462,6 +466,8 @@
<string name="screen_live_room_menu_prefix">[Menu] </string> <string name="screen_live_room_menu_prefix">[Menu] </string>
<string name="screen_live_room_leave">Leave</string> <string name="screen_live_room_leave">Leave</string>
<string name="screen_live_room_change_listener">Change to listener</string> <string name="screen_live_room_change_listener">Change to listener</string>
<string name="screen_live_room_chat_delete_title">Delete chat</string>
<string name="screen_live_room_chat_delete_message_format">%1$s: %2$s</string>
<string name="screen_live_room_signature_off_label">Sign OFF</string> <string name="screen_live_room_signature_off_label">Sign OFF</string>
<string name="screen_live_room_v2v_signature_off_label">Caption OFF</string> <string name="screen_live_room_v2v_signature_off_label">Caption OFF</string>
<string name="screen_live_room_v2v_signature_on_label">Caption ON</string> <string name="screen_live_room_v2v_signature_on_label">Caption ON</string>
@@ -988,6 +994,9 @@
<string name="dialog_logout_all_message">Log out from all devices?</string> <string name="dialog_logout_all_message">Log out from all devices?</string>
<string name="screen_content_settings_title">Content viewing settings</string> <string name="screen_content_settings_title">Content viewing settings</string>
<string name="screen_content_settings_adult_toggle">Show sensitive content</string> <string name="screen_content_settings_adult_toggle">Show sensitive content</string>
<string name="dialog_sensitive_content_enable_title">Are you over 18?</string>
<string name="dialog_sensitive_content_enable_message">This content is available only to users aged 18 and over!</string>
<string name="screen_content_settings_sensitive_content_guide">To view sensitive content, turn on the Show sensitive content switch.</string>
<string name="screen_content_settings_all">All</string> <string name="screen_content_settings_all">All</string>
<string name="screen_content_settings_male">Male-oriented</string> <string name="screen_content_settings_male">Male-oriented</string>
<string name="screen_content_settings_female">Female-oriented</string> <string name="screen_content_settings_female">Female-oriented</string>

View File

@@ -31,6 +31,10 @@
<string name="chat_notice_character">ボイスオンのAIキャラトークは自由度が高く、あなたは誰にでもなれます。\n世界観のキャラになりきったり、新しい人物としてあなただけのストーリーを作ってみましょう。\n※オープンベータ中のため、会話がぎこちない場合があります。</string> <string name="chat_notice_character">ボイスオンのAIキャラトークは自由度が高く、あなたは誰にでもなれます。\n世界観のキャラになりきったり、新しい人物としてあなただけのストーリーを作ってみましょう。\n※オープンベータ中のため、会話がぎこちない場合があります。</string>
<string name="chat_character_type_clone">クローン</string> <string name="chat_character_type_clone">クローン</string>
<string name="chat_character_type_character">キャラクター</string> <string name="chat_character_type_character">キャラクター</string>
<string name="chat_freeze_status_creator">「🧊 みんなフリーズ!」チャットを凍結しました。</string>
<string name="chat_freeze_status_listener">「🧊 みんなフリーズ!」チャットが凍結されました。</string>
<string name="chat_freeze_status_off">「💧 たん!」チャット凍結が解除されました。</string>
<string name="chat_freeze_blocked">🧊 チャットが凍結されました。</string>
<string name="chat_profile_image_content_description">%1$sのプロフィール画像</string> <string name="chat_profile_image_content_description">%1$sのプロフィール画像</string>
<string name="chat_input_placeholder">メッセージを入力してください。</string> <string name="chat_input_placeholder">メッセージを入力してください。</string>
<string name="chat_send_failed">メッセージを送信できませんでした。</string> <string name="chat_send_failed">メッセージを送信できませんでした。</string>
@@ -461,6 +465,8 @@
<string name="screen_live_room_menu_prefix">[メニュー] </string> <string name="screen_live_room_menu_prefix">[メニュー] </string>
<string name="screen_live_room_leave">退出</string> <string name="screen_live_room_leave">退出</string>
<string name="screen_live_room_change_listener">リスナー変更</string> <string name="screen_live_room_change_listener">リスナー変更</string>
<string name="screen_live_room_chat_delete_title">チャット削除</string>
<string name="screen_live_room_chat_delete_message_format">%1$s: %2$s</string>
<string name="screen_live_room_signature_off_label">シグ OFF</string> <string name="screen_live_room_signature_off_label">シグ OFF</string>
<string name="screen_live_room_v2v_signature_off_label">字幕 OFF</string> <string name="screen_live_room_v2v_signature_off_label">字幕 OFF</string>
<string name="screen_live_room_v2v_signature_on_label">字幕 ON</string> <string name="screen_live_room_v2v_signature_on_label">字幕 ON</string>
@@ -988,6 +994,9 @@
<string name="dialog_logout_all_message">すべての端末からログアウトしますか?</string> <string name="dialog_logout_all_message">すべての端末からログアウトしますか?</string>
<string name="screen_content_settings_title">コンテンツ表示設定</string> <string name="screen_content_settings_title">コンテンツ表示設定</string>
<string name="screen_content_settings_adult_toggle">センシティブなコンテンツ表示</string> <string name="screen_content_settings_adult_toggle">センシティブなコンテンツ表示</string>
<string name="dialog_sensitive_content_enable_title">あなたは18歳以上ですか</string>
<string name="dialog_sensitive_content_enable_message">このコンテンツは18歳以上のみ利用できます</string>
<string name="screen_content_settings_sensitive_content_guide">センシティブなコンテンツを表示するには「センシティブなコンテンツ表示」スイッチをオンにしてください。</string>
<string name="screen_content_settings_all"></string> <string name="screen_content_settings_all"></string>
<string name="screen_content_settings_male">男性向け</string> <string name="screen_content_settings_male">男性向け</string>
<string name="screen_content_settings_female">女性向け</string> <string name="screen_content_settings_female">女性向け</string>

View File

@@ -31,6 +31,10 @@
<string name="chat_notice_character">보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다.</string> <string name="chat_notice_character">보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다.</string>
<string name="chat_character_type_clone">Clone</string> <string name="chat_character_type_clone">Clone</string>
<string name="chat_character_type_character">Character</string> <string name="chat_character_type_character">Character</string>
<string name="chat_freeze_status_creator">“🧊 모두들 얼음!” 채팅창을 얼렸습니다.</string>
<string name="chat_freeze_status_listener">“🧊 모두들 얼음!” 채팅창이 얼었습니다.</string>
<string name="chat_freeze_status_off">“💧땡! “ 채팅창 얼리기가 해제되었습니다.</string>
<string name="chat_freeze_blocked">🧊 채팅창이 얼었습니다.</string>
<string name="chat_profile_image_content_description">%1$s 프로필 이미지</string> <string name="chat_profile_image_content_description">%1$s 프로필 이미지</string>
<string name="chat_input_placeholder">메세지를 입력하세요.</string> <string name="chat_input_placeholder">메세지를 입력하세요.</string>
<string name="chat_send_failed">메시지 전송에 실패했습니다.</string> <string name="chat_send_failed">메시지 전송에 실패했습니다.</string>
@@ -461,6 +465,8 @@
<string name="screen_live_room_menu_prefix">[메뉴판] </string> <string name="screen_live_room_menu_prefix">[메뉴판] </string>
<string name="screen_live_room_leave">나가기</string> <string name="screen_live_room_leave">나가기</string>
<string name="screen_live_room_change_listener">리스너 변경</string> <string name="screen_live_room_change_listener">리스너 변경</string>
<string name="screen_live_room_chat_delete_title">채팅 삭제</string>
<string name="screen_live_room_chat_delete_message_format">%1$s: %2$s</string>
<string name="screen_live_room_signature_off_label">시그 OFF</string> <string name="screen_live_room_signature_off_label">시그 OFF</string>
<string name="screen_live_room_v2v_signature_off_label">자막 OFF</string> <string name="screen_live_room_v2v_signature_off_label">자막 OFF</string>
<string name="screen_live_room_v2v_signature_on_label">자막 ON</string> <string name="screen_live_room_v2v_signature_on_label">자막 ON</string>
@@ -987,6 +993,9 @@
<string name="dialog_logout_all_message">모든 기기에서 로그아웃 하시겠어요?</string> <string name="dialog_logout_all_message">모든 기기에서 로그아웃 하시겠어요?</string>
<string name="screen_content_settings_title">콘텐츠 보기 설정</string> <string name="screen_content_settings_title">콘텐츠 보기 설정</string>
<string name="screen_content_settings_adult_toggle">민감한 콘텐츠 보기</string> <string name="screen_content_settings_adult_toggle">민감한 콘텐츠 보기</string>
<string name="dialog_sensitive_content_enable_title">당신은 18세 이상입니까?</string>
<string name="dialog_sensitive_content_enable_message">해당 콘텐츠는 18세 이상만 이용이 가능합니다!</string>
<string name="screen_content_settings_sensitive_content_guide">민감한 콘텐츠를 보려면 민감한 콘텐츠 보기 스위치를 켜주세요.</string>
<string name="screen_content_settings_all">전체</string> <string name="screen_content_settings_all">전체</string>
<string name="screen_content_settings_male">남성향</string> <string name="screen_content_settings_male">남성향</string>
<string name="screen_content_settings_female">여성향</string> <string name="screen_content_settings_female">여성향</string>

View File

@@ -0,0 +1,76 @@
package kr.co.vividnext.sodalive.common
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AdultContentVisibilityPolicyTest {
@Test
fun `isAdultContentVisible가 false면 항상 미표시`() {
assertFalse(
AdultContentVisibilityPolicy.shouldShowAdultRestrictionSetting(
countryCode = "KR",
isAdultContentVisible = false,
isAuth = true
)
)
assertFalse(
AdultContentVisibilityPolicy.shouldShowAdultRestrictionSetting(
countryCode = "US",
isAdultContentVisible = false,
isAuth = false
)
)
}
@Test
fun `한국 접속은 isAuth가 true일 때만 표시`() {
assertTrue(
AdultContentVisibilityPolicy.shouldShowAdultRestrictionSetting(
countryCode = "KR",
isAdultContentVisible = true,
isAuth = true
)
)
assertFalse(
AdultContentVisibilityPolicy.shouldShowAdultRestrictionSetting(
countryCode = "KR",
isAdultContentVisible = true,
isAuth = false
)
)
}
@Test
fun `비한국 접속은 isAdultContentVisible true면 표시`() {
assertTrue(
AdultContentVisibilityPolicy.shouldShowAdultRestrictionSetting(
countryCode = "US",
isAdultContentVisible = true,
isAuth = false
)
)
}
@Test
fun `countryCode가 비어있으면 한국으로 간주한다`() {
assertFalse(
AdultContentVisibilityPolicy.shouldShowAdultRestrictionSetting(
countryCode = "",
isAdultContentVisible = true,
isAuth = false
)
)
assertTrue(
AdultContentVisibilityPolicy.shouldShowAdultRestrictionSetting(
countryCode = "",
isAdultContentVisible = true,
isAuth = true
)
)
}
}

View File

@@ -0,0 +1,70 @@
package kr.co.vividnext.sodalive.settings.notification
import com.google.gson.Gson
import kr.co.vividnext.sodalive.settings.ContentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GetMemberInfoResponseCompatibilityTest {
private val gson = Gson()
@Test
fun `구서버 응답에서 신규 필드가 없어도 역직렬화된다`() {
val json = """
{
"can": 10,
"point": 120,
"isAuth": true,
"gender": "F",
"signupDate": "2024-01-01, 00:00:00",
"chargeCount": 3,
"role": "USER",
"messageNotice": true,
"followingChannelLiveNotice": false,
"followingChannelUploadContentNotice": true,
"auditionNotice": false
}
""".trimIndent()
val response = gson.fromJson(json, GetMemberInfoResponse::class.java)
assertEquals(10, response.can)
assertEquals(120, response.point)
assertTrue(response.isAuth)
assertEquals(MemberRole.USER, response.role)
assertNull(response.countryCode)
assertNull(response.isAdultContentVisible)
assertNull(response.contentType)
}
@Test
fun `신규 필드가 있으면 정상 매핑된다`() {
val json = """
{
"can": 10,
"point": 120,
"isAuth": true,
"gender": "F",
"signupDate": "2024-01-01, 00:00:00",
"chargeCount": 3,
"role": "CREATOR",
"messageNotice": true,
"followingChannelLiveNotice": false,
"followingChannelUploadContentNotice": true,
"auditionNotice": false,
"countryCode": "US",
"isAdultContentVisible": true,
"contentType": "FEMALE"
}
""".trimIndent()
val response = gson.fromJson(json, GetMemberInfoResponse::class.java)
assertEquals("US", response.countryCode)
assertEquals(true, response.isAdultContentVisible)
assertEquals(ContentType.FEMALE, response.contentType)
}
}

View File

@@ -0,0 +1,176 @@
# 20260319_라이브룸채팅삭제기능구현계획.md
## 개요
- 라이브 룸에서 방장(크리에이터) 전용 채팅 삭제 기능을 추가하기 위한 구현 계획 문서다.
- 기능 범위는 단건 삭제(길게 누름 + 확인 다이얼로그)와 강퇴 연계 일괄 삭제(다이얼로그 없이 즉시 삭제)다.
- 본 문서는 계획을 먼저 확정하고, 구현/검증 결과는 하단 `검증 기록`에 누적한다.
## 요구사항 해석(확정)
- 채팅 삭제 권한은 방장(크리에이터)만 가진다.
- 삭제 대상 채팅을 길게 누르면 삭제 확인 다이얼로그를 띄운다.
- 다이얼로그 본문은 `[닉네임]: [채팅 내용]` 형식으로 노출한다.
- 다이얼로그 버튼은 `취소/삭제` 두 가지다.
- 삭제 확정 시 모든 사용자 화면에서 동일 채팅이 제거되어야 한다.
- 강퇴 시에는 다이얼로그 없이 해당 유저의 채팅을 즉시 일괄 삭제하고, 이 결과가 모든 사용자에게 동기화되어야 한다.
## 현재 구조 조사 요약
- 일반 채팅은 `LiveRoomActivity.inputChat()`에서 `agora.inputChat(message)`로 RTM STRING 발송하고, 수신 측은 `onMessageEvent` STRING 분기에서 `LiveRoomNormalChat`을 리스트에 append 한다.
- 실시간 제어 이벤트(방정보 수정, 채팅 얼림, 룰렛 등)는 `LiveRoomChatRawMessageType` + `agora.sendRawMessageToGroup()` + `onMessageEvent` BINARY 분기 패턴을 이미 사용 중이다.
- 강퇴는 `LiveRoomActivity.kickOut()` -> `LiveRoomViewModel.kickOut()`(API) + `LiveRoomRequestType.KICK_OUT` peer 메시지 전송으로 처리되며, 강퇴 대상 단말은 수신 후 `finish()`로 종료한다.
- 현재 라이브룸 채팅 모델/어댑터에는 단건 삭제를 위한 명시적 식별자/long-press 콜백/삭제 브로드캐스트 타입이 없다.
## 설계 결정
- 삭제 동기화는 기존 제어 이벤트와 동일하게 RTM BINARY raw message로 처리한다.
- 단건 삭제 정합성을 위해 라이브룸 일반 채팅에 식별자(`chatId`)를 추가한다.
- 강퇴 연계 일괄 삭제는 `targetUserId` 기반 raw message 이벤트로 처리한다.
- 롱프레스 진입은 `LiveRoomChatAdapter`에서 `LiveRoomNormalChat` 항목에만 연결하고, 실제 권한 검증은 `LiveRoomActivity`에서 `isHost`로 최종 보장한다.
- 기존 STRING 채팅 수신 분기는 호환성 fallback으로 유지하고, 삭제 정합성이 필요한 경로는 raw payload 기반으로 처리한다.
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 방장(크리에이터)만 채팅 길게 누름 시 삭제 확인 다이얼로그를 볼 수 있다.
- [x] AC2: 삭제 다이얼로그에 `[닉네임]: [채팅 내용]` 형식과 `취소/삭제` 버튼이 노출된다.
- [x] AC3: 단건 삭제 확정 시 모든 사용자 화면에서 동일 채팅이 제거된다.
- [x] AC4: 유저 강퇴 시 다이얼로그 없이 해당 유저의 채팅이 일괄 삭제된다.
- [x] AC5: 강퇴 기반 일괄 삭제도 모든 사용자 화면에 동일하게 반영된다.
- [x] AC6: 기존 채팅/후원/강퇴 흐름(삭제 기능 외)은 회귀 없이 유지된다.
## 구현 체크리스트
### 1) 채팅 모델/식별자 확장
- [x] `LiveRoomNormalChat`에 단건 삭제용 `chatId` 필드를 추가한다.
- [x] 강퇴 기반 일괄 삭제 범위를 위해 작성자 식별이 가능한 채팅 타입(`LiveRoomNormalChat`, `LiveRoomDonationChat`, 필요 시 `LiveRoomRouletteDonationChat`)의 사용자 식별 정보를 정리한다.
### 2) Raw message 스키마 확장
- [x] `LiveRoomChatRawMessageType`에 삭제 관련 타입(`NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER`)을 추가한다.
- [x] `LiveRoomChatRawMessage`에 삭제/일반채팅 동기화에 필요한 필드(`chatId`, `targetUserId`)를 nullable로 추가한다.
### 3) `LiveRoomActivity` 송신/수신 경로 반영
- [x] `inputChat()`에서 일반 채팅 raw payload 송신 + 로컬 리스트 반영 로직을 정리한다.
- [x] `rtmEventListener.onMessageEvent` BINARY 분기에 `NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER` 처리 로직을 추가한다.
- [x] STRING 수신 분기는 fallback 경로로 유지하고, 삭제 식별자가 없는 legacy 메시지 처리 원칙을 명시한다.
### 4) 방장 전용 long-press 삭제 UX
- [x] `LiveRoomChatAdapter``LiveRoomNormalChat` long-press 콜백을 추가한다.
- [x] `LiveRoomActivity`에서 방장 권한(`isHost`) 검증 후 삭제 확인 다이얼로그를 노출한다.
- [x] 다이얼로그 본문은 문자열 포맷으로 `[닉네임]: [채팅 내용]`을 구성하고 `취소/삭제` 액션을 연결한다.
### 5) 강퇴 연계 일괄 삭제
- [x] `kickOut(userId)` 경로에서 강퇴 대상 사용자의 채팅 일괄 삭제 raw 이벤트를 다이얼로그 없이 즉시 브로드캐스트한다.
- [x] 수신 측에서 `targetUserId`에 해당하는 채팅을 일괄 제거하고 리스트를 갱신한다.
### 6) 문자열/국제화
- [x] `values/strings.xml`에 라이브룸 채팅 삭제 다이얼로그 제목/본문 포맷 문자열을 추가한다.
- [x] `values-en/strings.xml`, `values-ja/strings.xml`에도 동일 키를 추가한다.
### 7) 검증
- [x] `lsp_diagnostics`로 수정 파일의 신규 오류 유무를 확인한다.
- [x] `./gradlew :app:testDebugUnitTest`를 실행한다.
- [x] `./gradlew :app:assembleDebug`를 실행한다.
- [ ] 수동 QA: 방장/일반유저 2계정으로 단건 삭제 및 강퇴 일괄 삭제의 전 사용자 반영을 확인한다.
## 영향 파일(예상)
### 필수
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`
- 방장 전용 long-press 삭제 진입, 삭제 확인 다이얼로그, 단건/일괄 삭제 raw 이벤트 송신, 수신 분기 삭제 처리, 강퇴 연계 삭제 처리 추가.
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt`
- 삭제/일반채팅 동기화용 raw message type 및 payload 필드 추가.
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChat.kt`
- 단건 삭제용 `chatId` 및 작성자 기반 일괄 삭제 대응 필드(필요 타입) 추가.
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatAdapter.kt`
- 일반 채팅 아이템 long-press 콜백 전달 경로 추가.
- `app/src/main/res/values/strings.xml`
- 라이브룸 채팅 삭제 다이얼로그 문자열(제목/본문 포맷) 추가.
- `app/src/main/res/values-en/strings.xml`
- 동일 문자열 키 영문 번역 추가.
- `app/src/main/res/values-ja/strings.xml`
- 동일 문자열 키 일본어 번역 추가.
### 참고(변경 가능성 낮음)
- `app/src/main/java/kr/co/vividnext/sodalive/agora/Agora.kt`
- 기존 `sendRawMessageToGroup` API로 처리 가능해 직접 수정 가능성은 낮다.
## 리스크 및 확인사항
- 일반 채팅 송신 형식을 raw 중심으로 전환할 경우, 구버전/타플랫폼과의 프로토콜 호환성 리스크가 있다.
- fallback STRING 메시지는 삭제 식별자 정합성이 약할 수 있으므로, 삭제 대상 식별 우선순위를 명확히 정의해야 한다.
- `kickOut` API 호출은 현재 성공/실패 콜백을 사용하지 않으므로, “API 성공 후 삭제 이벤트 전파” 순서 보장이 필요하면 ViewModel 시그니처 확장을 검토해야 한다.
- 동일 사용자의 동일 본문 반복 메시지 삭제 시 단건 선택 정합성(정확히 1건 삭제) 검증이 필요하다.
## 외부 레퍼런스(요약)
- Agora Signaling 메시지 페이로드 구조화 권장(문자열/바이너리 + 앱 스키마):
- https://docs.agora.io/en/signaling/core-functionality/message-payload-structuring
- Stream Chat Android 삭제 권한/삭제 확인 다이얼로그/삭제 이벤트 처리 패턴:
- https://github.com/GetStream/stream-chat-android/blob/d7ce8ede69b0098a06fca17cacecaa9dc0bafdbd/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt#L137-L155
- https://github.com/GetStream/stream-chat-android/blob/d7ce8ede69b0098a06fca17cacecaa9dc0bafdbd/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt#L2269-L2277
## 검증 기록
- 기록 템플릿(후속 누적):
- YYYY-MM-DD
- 무엇/왜/어떻게:
- 실행 명령/도구:
- `명령 또는 사용 도구`
- 결과:
- 2026-03-19
- 무엇/왜/어떻게: 라이브룸 채팅 삭제 구현 전에 기존 채팅 송수신/강퇴/어댑터 구조를 병렬 탐색하고, 요구사항을 충족하는 상세 구현 계획(예상 수정 파일/추가 항목/검증 항목)을 문서화했다.
- 실행 명령/도구:
- `task(subagent_type="explore")` x3
- `task(subagent_type="librarian")` x2
- `grep("LiveRoomChatRawMessageType|kickOut\(|onMessageEvent|setOnLongClickListener|confirm_delete_title")`
- `ast_grep_search("agora.sendRawMessageToGroup($$$)")`
- `read(LiveRoomActivity.kt, LiveRoomChat.kt, LiveRoomChatAdapter.kt, LiveRoomChatRawMessage.kt, LiveRoomViewModel.kt, Agora.kt, LiveApi.kt, strings*.xml, dialog_live.xml)`
- `bash("rg -n ...")` 시도
- `apply_patch` (본 문서 생성/상세화)
- 결과:
- `LiveRoomActivity` 중심의 채팅 입력(STRING)/제어이벤트(BINARY)/강퇴(peer) 경로를 확인했다.
- 단건 삭제와 강퇴 일괄 삭제를 위해 필요한 확장 지점(모델, raw 타입, 어댑터 콜백, Activity 분기, 문자열 리소스)을 파일 단위로 확정했다.
- 현재 실행 환경에서 `rg` 명령은 미설치(`command not found`)로 확인되어 동일 탐색은 `grep`/`ast_grep_search`/`read`로 보완했다.
- 2026-03-19
- 무엇/왜/어떻게: 계획 문서 기준으로 라이브룸 채팅 삭제 기능(방장 long-press 단건 삭제, 강퇴 연계 일괄 삭제, 전 사용자 동기화)을 실제 코드에 반영하고 빌드/테스트/수동 실행 검증을 수행했다.
- 실행 명령/도구:
- 코드 반영: `apply_patch` (`LiveRoomActivity.kt`, `LiveRoomChat.kt`, `LiveRoomChatAdapter.kt`, `LiveRoomChatRawMessage.kt`, `strings.xml`, `values-en/strings.xml`, `values-ja/strings.xml`)
- 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt, LiveRoomChat.kt, LiveRoomChatAdapter.kt, LiveRoomChatRawMessage.kt, strings*.xml)`
- 테스트/빌드: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 수동 실행: `adb devices`, `./gradlew :app:installDebug`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1`
- 결과:
- 방장 long-press 삭제 진입/확인 다이얼로그/단건 삭제 브로드캐스트, 강퇴 시 즉시 일괄 삭제 브로드캐스트가 코드 경로에 반영되었다.
- `:app:testDebugUnitTest`, `:app:assembleDebug`, `:app:installDebug`가 성공했다.
- `lsp_diagnostics`는 Kotlin/XML LSP 미설정 환경으로 실행 불가 응답을 반환했다.
- 단말 앱 실행 및 `LiveRoomActivity` 인텐트 실행까지는 확인했으나, 이 환경에서는 방장/일반유저 2계정 동시 접속 시나리오를 재현할 계정/세션 준비가 없어 최종 E2E(두 사용자 화면 동시 확인)는 후속 수동 검증이 필요하다.
- 2026-03-19
- 무엇/왜/어떻게: Oracle 리뷰에서 삭제 이벤트 수신부의 권한 검증 누락(host-only 보장 약화)과 STRING fallback 채팅 개별 삭제 취약점이 확인되어, 삭제 수신 분기에 방장 검증을 추가하고 fallback 삭제 로직을 보완했다.
- 실행 명령/도구:
- 리뷰: `task(subagent_type="oracle")`
- 코드 반영: `apply_patch` (`LiveRoomActivity.kt`)
- 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt)`
- 테스트/빌드: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 수동 실행: `adb devices`, `./gradlew :app:installDebug`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1`
- 결과:
- `DELETE_CHAT`, `DELETE_CHAT_BY_USER` 수신 처리에서 발신자가 방장인지 검증하도록 반영했다.
- `chatId`가 비어있는 legacy STRING 채팅도 `targetUserId + message` 기준으로 단건 삭제를 시도하도록 보완했다.
- `:app:testDebugUnitTest`, `:app:assembleDebug`, `:app:installDebug`가 모두 성공했다.
- `lsp_diagnostics`는 Kotlin LSP 미설정 환경으로 실행 불가 응답을 반환했다.
- 2026-03-19
- 무엇/왜/어떻게: 사용자 요청에 따라 채팅 삭제 다이얼로그 본문 포맷에서 대괄호를 제거했다.
- 실행 명령/도구:
- 코드 반영: `apply_patch` (`app/src/main/res/values/strings.xml`, `app/src/main/res/values-en/strings.xml`, `app/src/main/res/values-ja/strings.xml`)
- 검증: `grep("screen_live_room_chat_delete_message_format")`, `lsp_diagnostics(strings*.xml)`, `./gradlew :app:testDebugUnitTest :app:assembleDebug`, `adb devices`, `./gradlew :app:installDebug`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1`
- 결과:
- `screen_live_room_chat_delete_message_format` 값이 `[%1$s]: [%2$s]`에서 `%1$s: %2$s`로 변경되어 대괄호 없이 노출된다.
- 테스트/빌드/설치 및 앱/액티비티 실행이 성공했다.
- `lsp_diagnostics`는 XML LSP 미설정 환경으로 실행 불가 응답을 반환했다.
- 2026-03-19
- 무엇/왜/어떻게: 삭제 이벤트 payload에서 `targetChatId`를 제거하고 `chatId` 단일 필드로 통일해, 삭제 송신/수신이 동일 키를 사용하도록 정리했다.
- 실행 명령/도구:
- 코드 반영: `apply_patch` (`app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt`, `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`, `docs/20260319_라이브룸채팅삭제기능구현계획.md`)
- 참조 확인: `grep("targetChatId")`, `grep("targetChatId", include="*.md")`
- 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt, LiveRoomChatRawMessage.kt)`
- 테스트/빌드: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 수동 실행: `adb devices`, `./gradlew :app:installDebug`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1`
- 결과:
- `LiveRoomChatRawMessage`에서 `targetChatId` 필드를 제거했고, `DELETE_CHAT` 송신은 `chatId`에 삭제 대상 채팅 ID를 담아 전송하도록 변경했다.
- `DELETE_CHAT` 수신도 `message.chatId`를 읽어 삭제하도록 변경했으며, 저장소 내 `targetChatId` 문자열 참조는 0건이다.
- `:app:testDebugUnitTest`, `:app:assembleDebug`, `:app:installDebug`가 성공했고 앱/액티비티 실행까지 확인했다.
- `lsp_diagnostics`는 Kotlin LSP 미설정 환경으로 실행 불가 응답을 반환했다.

View File

@@ -0,0 +1,70 @@
# 20260319_라이브룸채팅얼림터치동작수정.md
## 개요
- `LiveRoomActivity`에서 비방장 청취자가 채팅 얼림 상태일 때 터치 시 경고 토스트가 안정적으로 노출되도록 보정한다.
- `etChat.isEnabled = false`로 인해 터치 이벤트가 막히는 문제를 해결하고, 입력/전송 차단 정책은 유지한다.
- `etChat.setOnTouchListener``ClickableViewAccessibility` 경고가 표시되지 않도록 `performClick` 연계와 경고 억제를 반영한다.
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 비방장이 채팅 얼림 상태에서 `et_chat`을 터치하면 `screen_live_room_chat_freeze_warning` 토스트가 노출된다.
- [x] AC2: 비방장 + 채팅 얼림 상태에서 실제 채팅 입력과 전송은 계속 차단된다.
- [x] AC3: 방장 또는 비얼림 상태에서는 기존 입력 동작이 유지된다.
- [x] AC4: 경고 문구(ClickableViewAccessibility)가 코드 경로에서 재현되지 않는다.
- [x] AC5: 문자열 리소스는 기존 `screen_live_room_chat_freeze_warning`을 재사용한다.
- [x] AC6: 수정 코드 컴파일/테스트가 성공한다.
## 구현 체크리스트
- [x] `LiveRoomActivity`의 입력 비활성/터치 처리 코드를 재확인한다.
- [x] `etChat` 터치 토스트 경로를 입력창 자체 `OnTouchListener`로 보정한다.
- [x] `etChat`은 enabled 유지 + focus/cursor 제어로 입력 차단을 유지한다.
- [x] `MotionEvent.ACTION_UP`에서 `performClick` 호출을 추가해 접근성 경고 조건을 해소한다.
- [x] `@SuppressLint("ClickableViewAccessibility")``setupView`에 적용해 IDE/Lint 경고 노출을 억제한다.
- [x] 검증 실행 결과를 문서 하단에 누적 기록한다.
## 영향 파일
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`
- `docs/20260319_라이브룸채팅얼림터치동작수정.md`
## 검증 기록
- 2026-03-19
- 무엇/왜/어떻게: 비방장 청취자 터치 시 얼림 토스트를 먼저 보장하기 위해 입력 컨테이너 클릭 기반 경고를 적용했다.
- 실행 명령/도구:
- 탐색: `task(subagent_type="explore")` x3, `task(subagent_type="librarian")` x2
- 코드/리소스 확인: `grep("isChatFrozen|screen_live_room_chat_freeze_warning|setOnFocusChangeListener|etChat")`, `ast_grep_search`, `sg --lang kotlin -p ...`, `read(LiveRoomActivity.kt, activity_live_room.xml)`
- 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt)`
- 빌드/테스트: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 수동 스모크: `adb devices`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell am start -W -n ...LiveRoomActivity --el roomId 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n ...LiveRoomActivity --el roomId 1`
- 결과:
- 초기 수정에서 `binding.rlInputChat.setOnClickListener`로 토스트를 노출하도록 반영했다.
- 문자열 리소스는 기존 `screen_live_room_chat_freeze_warning`을 재사용했고 신규 리소스 추가는 없었다.
- `./gradlew :app:testDebugUnitTest :app:assembleDebug``BUILD SUCCESSFUL`이었다.
- `lsp_diagnostics`는 Kotlin LSP 미설정으로 실행 불가였다.
- adb 직접 `am start`는 non-exported Activity 제약으로 실패했고 `run-as` 경로로 Activity 시작 로그를 확인했다.
- 2026-03-19
- 무엇/왜/어떻게: `etChat.isEnabled = false` 상태에서 부모(`rlInputChat`) 클릭이 전달되지 않아 토스트가 뜨지 않는 문제를 확인했고, 입력창은 enabled를 유지한 채 포커스 가능 여부로 입력 차단을 제어하도록 수정했다. 동시에 `etChat.setOnTouchListener`에서 비방장+얼림 상태 터치를 직접 소비하며 토스트를 노출하도록 보정했다.
- 실행 명령/도구:
- 분석: `read(LiveRoomActivity.kt)`, `background_output(bg_7315e113)`
- 코드 반영: `apply_patch` (`LiveRoomActivity.kt`, 본 문서)
- 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt)`
- 빌드/테스트: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 수동 스모크: `adb devices`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1`
- 결과:
- 비방장+얼림 상태에서 `etChat` 터치 시 경고 토스트가 뜨도록 이벤트 경로가 복구되었다.
- 입력/전송 차단은 `etChat.isFocusable/isFocusableInTouchMode/isCursorVisible=false``ivSend.isEnabled=false`로 유지된다.
- `./gradlew :app:testDebugUnitTest :app:assembleDebug``BUILD SUCCESSFUL`로 통과했다.
- `lsp_diagnostics`는 Kotlin LSP 미설정 환경으로 실행 불가 메시지를 반환했다.
- 2026-03-19
- 무엇/왜/어떻게: `etChat.setOnTouchListener` 구간에서 IDE 경고(`Custom view EditText has setOnTouchListener called on it but does not override performClick`)가 남아 `performClick()` 호출과 `@SuppressLint("ClickableViewAccessibility")`를 추가해 경고 노출을 제거했다.
- 실행 명령/도구:
- 코드 반영: `apply_patch` (`LiveRoomActivity.kt`)
- 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt)`
- 린트 확인: `./gradlew :app:lintDebug`, `grep("ClickableViewAccessibility|performClick", lint-results-debug.txt)`
- 빌드/테스트: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 수동 스모크: `adb devices`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1`
- 결과:
- `LiveRoomActivity.kt:637``setOnTouchListener`에서 `view.performClick()` 호출이 반영됐다.
- `lint-results-debug.txt`에서 `ClickableViewAccessibility``performClick` 관련 경고 매치를 찾지 못해 해당 경고가 재현되지 않았다.
- `:app:lintDebug`는 기존 선행 이슈(`AndroidManifest.xml``MissingClass`, `com.facebook.FacebookActivity`)로 실패했으며 이번 수정 경고와는 별개였다.
- `./gradlew :app:testDebugUnitTest :app:assembleDebug``BUILD SUCCESSFUL`로 통과했다.

View File

@@ -0,0 +1,181 @@
# 20260319_라이브룸채팅창얼리기기능구현계획.md
## 개요
- `LiveRoomActivity`에 채팅창 얼리기(Freeze) 토글을 추가한다.
- 채팅창 얼리기 상태에서는 방장을 제외한 모든 사용자가 채팅 입력/전송을 할 수 없어야 한다.
- 본 문서는 계획과 구현/검증 결과를 함께 관리한다.
## 요구사항 요약
- 토글 버튼 위치: `activity_live_room.xml``tv_signature_switch` 왼쪽.
- 얼림(ON): 방장 제외 전체 유저 채팅 입력 불가(포커스/입력/전송 모두 차단).
- 녹임(OFF): 기존 채팅금지 해제와 동일하게 채팅 가능 상태로 복귀.
- 상태 메시지: 얼림/녹임 시 모든 유저 채팅 리스트에 시스템 메시지 노출.
- 상태 메시지 UI: 사용자 입장 알림(`LiveRoomJoinChat`)과 동일한 UI 사용.
- 지연 입장: 채팅창이 얼려진 상태로 입장한 사용자도 즉시 상태를 받아 입력 불가여야 함.
- 상태 변경 패턴: 룰렛과 동일하게 방장 ON/OFF 시 서버 API로 상태를 먼저 반영하고, 성공 시 RTM으로 실시간 전파.
## 상태 저장 전략 판단 (ROOM_INFO vs 별도 상태)
### 결론
- **ROOM_INFO에 `isChatFrozen` 상태를 저장**하고, RTM 이벤트는 즉시 반영용으로 병행하는 전략을 채택한다.
### 판단 근거
- ROOM_INFO 기반 초기 동기화 경로가 이미 존재한다.
- `LiveRoomViewModel.getRoomInfo` -> `roomInfoResponse`/`roomInfoLiveData` 갱신.
- `LiveRoomActivity``onCreate`, `onPresenceEvent(REMOTE_JOIN)` 등에서 `getRoomInfo`를 재호출한다.
- 룸 전역 상태 선례가 존재한다.
- `GetRoomInfoResponse.isActiveRoulette` + `LiveRoomChatRawMessageType.TOGGLE_ROULETTE` 조합으로 운용 중이다.
- 별도 로컬 상태(예: SharedPreferences)는 디바이스 단위라 전역 정합성에 취약하다.
- 현재 `noChatRoomList`는 로컬 단말 재진입 보조 용도이며, 전 유저 상태의 단일 진실원천(SSOT)으로는 부적합하다.
- 외부 근거(Agora Signaling)상 메시지 채널은 pub/sub 모델이므로, 지연 입장자 상태 보장은 저장소(메타데이터/룸 정보) 병행이 유리하다.
### 외부 레퍼런스(요약)
- Agora Signaling 문서: channel metadata는 room attributes 저장/변경 알림/지속성을 제공하므로 지연 입장 정합성 확보에 적합.
- https://github.com/AgoraIO/Docs-Source/blob/b0d41f805f49a43c041453ce3ca73db2b8292b2f/shared/signaling/store-channel-metadata/index.mdx
- Stream Chat Android: `Channel.frozen` 권위 상태 + 이벤트 병합(`mergeChannelFromEvent`) 패턴으로 freeze 상태를 모델에 유지.
- https://github.com/GetStream/stream-chat-android/blob/40119da704abfd56334db18c0f9fbb29f103f216/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Channel.kt
- Matrix SDK: `m.room.power_levels` 기반 `maySendMessage` 권한 판별로 room-wide 전송 제한을 구현.
- https://github.com/matrix-org/matrix-js-sdk/blob/028357f15f173770f2dc695e7b2e20d3120bf71a/src/models/room-state.ts
- LiveKit Android: participant metadata/attributes를 서버 권위 상태로 동기화하고 변경 이벤트를 분리 처리.
- https://github.com/livekit/client-sdk-android/blob/c91c476a5d6a674f4ff7f40f5b8326592754dabf/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/Participant.kt
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 방장이 얼림 ON 시, 방장을 제외한 사용자는 `et_chat`에 포커스/입력/전송이 모두 불가능하다.
- [x] AC2: 방장이 얼림 OFF 시, 방장을 제외한 사용자의 채팅 입력/전송이 즉시 복구된다.
- [x] AC3: 얼림/녹임 이벤트마다 모든 사용자 채팅 리스트에 시스템 상태 메시지가 1회씩 노출된다.
- [x] AC4: 얼림 상태에서 새로 입장한 사용자는 입장 직후 입력 불가 상태를 즉시 적용받는다.
- [x] AC5: 얼리기 토글 버튼은 `tv_signature_switch`의 왼쪽에 배치된다.
- [x] AC6: 방장 얼림 ON/OFF 시 서버 API가 선행 호출되고, 성공한 경우에만 RTM 상태 브로드캐스트가 전송된다.
## 구현 체크리스트
### 1) UI/입력 제어
- [x] `app/src/main/res/layout/activity_live_room.xml` 상단 토글 영역에 `tv_chat_freeze_switch` 추가 (`tv_signature_switch` 왼쪽).
- [x] `LiveRoomActivity`에 전역 상태 변수(`isChatFrozen`) 및 UI 바인딩 로직 추가.
- [x] `etChat` 포커스/입력/전송 경로(`setOnFocusChangeListener`, `inputChat`)를 통합 가드로 정리하여 Freeze 상태에서 완전 차단.
### 2) 상태 전파/수신
- [x] 서버 API 경로 반영: 전용 endpoint `PUT /live/room/info/set/chat-freeze` + `SetChatFreezeRequest(roomId, isChatFrozen)`로 ON/OFF를 서버 선반영.
- [x] 저장소/뷰모델 경로 추가: `LiveRepository`/`LiveRoomViewModel`에 chat freeze ON/OFF API 호출 메서드 추가.
- [x] 방장 토글 액션은 API 성공 콜백에서만 RTM 브로드캐스트를 전송하도록 순서 보장(룰렛과 동일).
- [x] API 실패 시 RTM 미전송 + 오류 토스트 처리 시나리오 포함.
- [x] `LiveRoomChatRawMessageType`에 Freeze 이벤트 타입 추가(예: `TOGGLE_CHAT_FREEZE`).
- [x] `LiveRoomChatRawMessage`에 Freeze 상태 전달 필드 추가(예: `isChatFrozen`).
- [x] 방장 토글 시 RTM 그룹 브로드캐스트 전송 + 수신 분기 처리(`rtmEventListener.onMessageEvent`) 구현.
### 3) 지연 입장 동기화
- [x] `GetRoomInfoResponse``isChatFrozen` 필드 추가.
- [x] `roomInfoLiveData.observe`에서 `isChatFrozen`을 입력 제어 상태에 반영.
- [x] 입장/재연결/REMOTE_JOIN 시점에서 ROOM_INFO 재조회 흐름으로 상태 재적용이 가능하도록 적용.
### 4) 시스템 메시지(UI 동일성)
- [x] 입장 알림과 동일한 UI(`item_live_room_join_chat.xml`)를 재사용할 수 있도록 시스템 메시지 모델 경로 확장.
- [x] 얼림/녹임 메시지를 `chatAdapter.items.add(...)` + `invalidateChat()` 경로로 주입.
### 5) 문자열/국제화
- [x] `values/strings.xml`, `values-en/strings.xml`, `values-ja/strings.xml`에 Freeze ON/OFF 라벨/상태 메시지 문구 추가.
### 6) 검증
- [x] 정적 진단: 수정 파일 대상 `lsp_diagnostics` 확인.
- [x] 빌드/테스트: `./gradlew :app:testDebugUnitTest`, `./gradlew :app:assembleDebug` 실행.
- [x] 수동 QA: 연결 단말 설치/실행 경로 확인 및 Activity 진입 시도 결과 기록.
## 영향 파일(예상)
- `app/src/main/res/layout/activity_live_room.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChat.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt`
- `app/src/main/res/values/strings.xml`
- `app/src/main/res/values-en/strings.xml`
- `app/src/main/res/values-ja/strings.xml`
- `app/src/main/java/kr/co/vividnext/sodalive/live/LiveApi.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/live/LiveRepository.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt`
## 리스크 및 의존성
- 서버 ROOM_INFO에 `isChatFrozen` 필드가 제공되지 않으면 지연 입장 정합성 보장이 어렵다.
- RTM 메시지만으로 구현하면 pub/sub 특성상 지연 입장 사용자에게 과거 상태 스냅샷이 누락될 수 있다.
- API 성공 이전 RTM을 먼저 전송하면 서버 상태와 클라이언트 UI가 불일치할 수 있으므로, 전송 순서를 API 성공 이후로 강제해야 한다.
- UI 차단을 포커스 차단만으로 처리하면 키보드/입력 우회가 가능하므로 `EditText` 자체 비활성 포함이 필요하다.
## 검증 기록
- 2026-03-19
- 무엇/왜/어떻게: 채팅창 얼리기 기능의 저장 전략 판단을 위해 LiveRoom 내부 구현 패턴, RTM 메시지 경로, ROOM_INFO 동기화 지점을 조사하고 계획 문서로 정리했다.
- 실행 명령/도구:
- `task(subagent_type="explore")` x3 (no-chat 흐름, room state sync, system UI 패턴)
- `task(subagent_type="librarian")` x2 (Agora 상태 전파 문서/실사례)
- `grep("isNoChatting|NO_CHATTING|...")`, `grep("LiveRoomChatRawMessageType|ROOM_INFO|...")`
- `ast_grep_search("LiveRoomChatRawMessage($$$)")`
- `read(LiveRoomActivity.kt, LiveRoomViewModel.kt, Agora.kt, GetRoomInfoResponse.kt, activity_live_room.xml, strings.xml 등)`
- `bash("rg -n ...")` 시도
- 결과:
- no-chat 기존 구현(로컬 저장 + peer 명령 + 타이머)과 ROOM_INFO 기반 전역 상태 동기화 패턴을 분리 확인.
- `isActiveRoulette` 선례를 통해 ROOM_INFO + RTM 병행 전략이 지연 입장 정합성에 유리함을 확인.
- 시스템 메시지 UI는 `LiveRoomJoinChat`/`item_live_room_join_chat.xml` 재사용이 가장 요구사항에 부합함을 확인.
- 환경에서 `rg` 명령은 미설치(`command not found`)로 확인되어, 동일 탐색은 `grep`/`ast_grep_search`/에이전트 결과로 보완.
- 2026-03-19
- 무엇/왜/어떻게: 계획 문서 기준으로 채팅창 얼리기 기능을 구현하고, 룰렛과 동일하게 서버 API 성공 후 RTM 브로드캐스트 순서를 적용했다.
- 실행 명령/도구:
- 코드 반영: `apply_patch` (`LiveRoomActivity.kt`, `LiveRoomViewModel.kt`, `LiveRepository.kt`, `LiveRoomChat.kt`, `LiveRoomChatRawMessage.kt`, `GetRoomInfoResponse.kt`, `EditLiveRoomInfoRequest.kt`, `activity_live_room.xml`, `strings*.xml`)
- 정적 진단: `lsp_diagnostics` (현재 환경 `.kt`, `.xml` 서버 미설정)
- 빌드/테스트: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 스타일 검증: `./gradlew :app:ktlintCheck`
- 디바이스 수동 확인: `adb devices`, `./gradlew :app:installDebug`, `adb shell am start -W -n ...LiveRoomActivity --el roomId 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n ...LiveRoomActivity --el roomId 1`
- 결과:
- 초기 빌드에서 `values-en/strings.xml` 신규 문구 1건(`screen_live_room_chat_freeze_warning`) 리소스 컴파일 오류를 확인하고 문구를 수정했다.
- 재실행 결과 `./gradlew :app:testDebugUnitTest :app:assembleDebug` 성공.
- `./gradlew :app:ktlintCheck` 성공.
- `./gradlew :app:installDebug` 성공(연결 단말 1대 설치 확인).
- 셸 직접 실행은 non-exported Activity 제약으로 일반 `am start`가 차단되었고, `run-as` 기반 실행은 가능했으나 디버그 앱 사용자 데이터(`shared_prefs/kr.co.vividnext.sodalive.debug_preferences.xml`)가 비어 있어 실제 라이브룸 시나리오(방장/일반유저 2계정)까지는 환경 제약으로 진행하지 못했다.
- 2026-03-19
- 무엇/왜/어떻게: 사용자 요청에 따라 채팅 얼리기 API를 `editLiveRoomInfo`에서 완전히 분리해 전용 URL(`/live/room/info/set/chat-freeze`)로 교체했다.
- 실행 명령/도구:
- 코드 반영: `apply_patch` (`LiveApi.kt`, `LiveRepository.kt`, `LiveRoomViewModel.kt`, `SetChatFreezeRequest.kt`, `EditLiveRoomInfoRequest.kt`)
- 확인: `grep("setChatFreeze|editLiveRoomInfo|/live/room/info/set/chat-freeze")`, `git diff -- LiveApi.kt LiveRepository.kt LiveRoomViewModel.kt SetChatFreezeRequest.kt`
- 결과:
- `setChatFreeze` 호출은 전용 endpoint로 분리되었고 `api.editLiveRoomInfo(...)` 재사용이 제거되었다.
- `EditLiveRoomInfoRequest``isChatFrozen` 필드는 제거되어 채팅 얼리기와 라이브 정보 수정 API 계약이 분리되었다.
- 2026-03-19
- 무엇/왜/어떻게: 전용 API 분리 이후 실제 단말 실행 가능 여부를 확인해 수동 QA 착수 가능 상태를 점검했다.
- 실행 명령/도구:
- `adb devices`
- `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`
- 결과:
- 연결 단말 1대(`2cec640c34017ece`)가 확인되었고, debug 앱 런처 실행 이벤트 주입이 성공했다.
- 채팅 얼리기 엔드투엔드 검증(방장/일반 유저 2계정, 동일 룸 입장, ON/OFF 동기화)은 로그인 세션/테스트 계정/실제 룸 시나리오 준비가 필요해 후속 수동 QA 항목으로 유지한다.
- 2026-03-19
- 무엇/왜/어떻게: TODO 잔여 항목(회귀 검증)을 완료하기 위해 테스트/빌드/코드스타일 검증을 단일 Gradle 실행으로 재확인했다.
- 실행 명령/도구:
- `./gradlew :app:testDebugUnitTest :app:assembleDebug :app:ktlintCheck`
- 결과:
- `:app:testDebugUnitTest`, `:app:assembleDebug`, `:app:ktlintCheck` 모두 `UP-TO-DATE` 상태로 성공했고 전체 `BUILD SUCCESSFUL`을 확인했다.
- 추가 회귀 및 스타일 위반은 재현되지 않았다.
- 2026-03-19
- 무엇/왜/어떻게: 사용자 요청에 따라 채팅창 얼림/해제 시스템 메시지에서 크리에이터 닉네임 노출을 제거하고 고정 문구만 표시되도록 문자열 리소스를 수정했다.
- 실행 명령/도구:
- 코드 반영: `apply_patch` (`app/src/main/res/values/strings.xml`, `app/src/main/res/values-en/strings.xml`, `app/src/main/res/values-ja/strings.xml`)
- 수동 확인: `read(.../values*/strings.xml)``screen_live_room_chat_freeze_started/ended` 값에서 `%1$s` 제거 확인
- 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt)`, `lsp_diagnostics(strings.xml)` (환경상 `.kt`/`.xml` LSP 미구성)
- 회귀 검증: `./gradlew :app:testDebugUnitTest :app:assembleDebug :app:ktlintCheck`
- 결과:
- 한국어 메시지는 요청대로 `채팅창을 얼렸습니다.`, `채팅창 얼리기를 해제했습니다`로 고정되어 닉네임이 표시되지 않는다.
- `:app:testDebugUnitTest`, `:app:assembleDebug`는 성공했다.
- `:app:ktlintCheck``LiveRoomActivity.kt`의 기존 광범위 스타일 위반으로 실패했으며, 본 요청에서 수정한 문자열 리소스 3개 파일에서는 신규 위반이 관찰되지 않았다.
- 2026-03-19
- 무엇/왜/어떻게: 지연 입장 사용자가 얼림 상태를 인지하지 못하는 문제를 해결하기 위해 `isChatFrozen == true` 초기 동기화 시 시스템 메시지를 1회 노출하고, 채팅 입력 영역 터치 시 얼림 토스트를 표시하도록 `LiveRoomActivity`를 수정했다.
- 실행 명령/도구:
- 코드 반영: `apply_patch` (`app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`)
- 정적 확인: `read(LiveRoomActivity.kt)``hasShownInitialChatFreezeNotice`, `rlInputChat.setOnTouchListener`, `roomInfoLiveData.observe` 분기 추가 확인
- 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt)` (환경상 `.kt` LSP 미구성)
- 검증: `./gradlew :app:testDebugUnitTest :app:assembleDebug :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 수동 스모크: `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`
- 결과:
- 지연 입장 초기 동기화에서 non-host + `isChatFrozen=true`일 때 채팅 리스트에 얼림 메시지가 1회 표시되도록 반영했다.
- non-host가 채팅 입력 영역(`rl_input_chat`)을 터치하면 `screen_live_room_chat_freeze_warning` 토스트가 노출되도록 반영했다.
- `:app:testDebugUnitTest`, `:app:assembleDebug`는 성공했다.
- `:app:ktlintCheck``LiveRoomActivity.kt`의 기존 광범위 스타일 위반으로 실패했으며, 이번 수정 라인에서 신규 위반은 확인되지 않았다.

View File

@@ -0,0 +1,21 @@
# 20260320_라이브룸채팅창얼리기국제화
## 개요
라이브룸 채팅창 얼리기(Freeze) 기능과 관련된 텍스트가 `LiveRoomActivity.kt`에 하드코딩되어 있어, 이를 `strings.xml`로 추출하고 국제화(en, ja)를 적용한다.
## 작업 내용
- [x] 다국어 리소스 파일 존재 여부 확인 및 생성 (ko, en, ja)
- [x] `strings.xml`에 채팅창 얼리기 관련 리소스 추가
- `chat_freeze_status_creator`
- `chat_freeze_status_listener`
- `chat_freeze_status_off`
- `chat_freeze_blocked`
- [x] `LiveRoomActivity.kt`의 하드코딩된 문자열을 리소스 참조로 변경
- [x] 빌드 및 코드 변경 사항 검증
## 검증 기록
### 2026-03-20
- 무엇: 채팅창 얼리기 관련 텍스트 국제화 적용
- 왜: 하드코딩된 텍스트를 리소스로 관리하여 다국어 지원이 가능하게 함
- 어떻게: `strings.xml` (ko, en, ja)에 리소스 추가 및 `LiveRoomActivity.kt` 수정
- 결과: `./gradlew :app:assembleDebug` 빌드 성공 및 코드 수정 사항 확인 완료

View File

@@ -0,0 +1,30 @@
# 2026-03-20 채팅창 얼림 아이콘 이동 및 문구 정리
## 구현 체크리스트
- [x] LiveRoom 화면에서 채팅창 얼림 아이콘 위치를 마이크 음소거 아이콘 아래로 이동한다. (QA: 레이아웃 계층에서 마이크 음소거 컨트롤 다음 위치로 배치되었는지 확인)
- [x] `showChatFreezeWarning` 표시 문구를 `🧊 채팅창이 얼었습니다.`로 변경한다. (QA: 코드 상수/리소스 참조가 아닌 해당 문자열로 토스트 호출되는지 확인)
- [x] `docs/*`를 제외한 실제 코드 기준 미사용 얼림 관련 문구를 제거한다. (QA: 검색 결과에서 제거 대상 문구가 코드 경로에 남아있지 않은지 확인)
- [x] 변경 파일 진단 및 Gradle 검증을 수행한다. (QA: LSP 오류 0건, 관련 Gradle 테스트/체크 명령 성공)
## 검증 기록
- [x] 작업 완료 후 아래에 무엇/왜/어떻게, 실행 명령, 결과를 누적 기록한다.
- 무엇/왜/어떻게: 채팅 얼림 컨트롤의 실제 배치를 정확히 바꾸기 위해 코드베이스 전수 검색(직접 검색 + background explore/librarian)을 수행했고, `ll_option_buttons` 내부 순서 변경만으로 요구사항을 충족하도록 최소 수정했다.
- 실행 명령/도구: `grep`, `ast_grep_search`, `task(subagent_type=explore/librarian, run_in_background=true)`, `read`
- 결과: `activity_live_room.xml`에서 `tv_chat_freeze_switch``fl_microphone_mute` 아래로 이동할 위치를 확정했고, 얼림 관련 리소스 사용/미사용 목록을 확보했다.
- 무엇/왜/어떻게: 경고 문구를 요청 텍스트로 고정하기 위해 `showChatFreezeWarning`의 리소스 참조를 리터럴 문자열로 교체했다.
- 실행 명령/도구: `apply_patch`, `grep`
- 결과: `LiveRoomActivity.kt`에서 토스트 문구가 `🧊 채팅창이 얼었습니다.`로 반영됨을 확인했다.
- 무엇/왜/어떻게: docs 제외 실제 코드에서 미사용 얼림 문구를 제거하기 위해 얼림 키 참조를 재검색하고 미참조 리소스를 삭제했다.
- 실행 명령/도구: `grep(pattern="screen_live_room_chat_freeze_...")`, `apply_patch`
- 결과: `screen_live_room_chat_freeze_off_label`, `screen_live_room_chat_freeze_on_label`, `screen_live_room_chat_freeze_warning`, `screen_live_room_chat_freeze_started`, `screen_live_room_chat_freeze_ended``values*` 문자열 파일에서 제거했고, `app/src/main` 기준 재검색 시 잔여 참조가 없음을 확인했다.
- 무엇/왜/어떻게: 변경 안정성 검증을 위해 진단/테스트/빌드를 실행했다.
- 실행 명령: `lsp_diagnostics`(kt/xml), `./gradlew :app:testDebugUnitTest`, `./gradlew :app:assembleDebug`, `./gradlew :app:lintDebug`
- 결과: LSP는 환경에 Kotlin/XML 서버가 없어 실행 불가, `:app:testDebugUnitTest` 성공, `:app:assembleDebug` 성공, `:app:lintDebug`는 기존 이슈(`AndroidManifest.xml``com.facebook.FacebookActivity` MissingClass)로 실패했다.
- 무엇/왜/어떻게: 사용자 요청된 검색 모드 이행 여부를 확인하기 위해 `rg` 실행 가능성을 점검했다.
- 실행 명령: `rg -n --hidden --glob '!docs/**' ...`
- 결과: 현 환경에서 `rg` 바이너리가 없어 `command not found`가 발생했고, 대신 `grep`/`ast_grep_search`로 동일 범위 검증을 완료했다.

View File

@@ -0,0 +1,38 @@
# 20260320 채팅창 얼림 on/off 수정 계획
## 구현 체크리스트
- [x] 얼림 버튼 위치를 스피커 음소거 버튼 위로 이동하고 하단 마진을 13.3으로 적용한다. (QA: 레이아웃 속성 확인)
- [x] 얼림 OFF 상태 배경을 동일 위치의 기존 버튼 스타일과 동일하게 적용한다. (QA: 배경 리소스 확인)
- [x] 얼림 ON 상태 배경을 라운드 코너 10, `#3bb9f1` 50% 투명도로 적용하고 아이콘 `ic_ice`를 노출한다. (QA: 배경/아이콘 리소스 확인)
- [x] 얼림 버튼은 방장(크리에이터)에게만 보이도록 유지한다. (QA: `isHost` 분기 확인)
- [x] 얼림 ON/OFF 시스템 문구를 방장/일반 유저 조건에 맞게 변경한다. (QA: 메시지 생성/수신 분기 확인)
- [x] 수정 파일 진단, 테스트, 빌드를 실행하고 결과를 기록한다. (QA: 명령 실행 결과 확인)
## 검증 기록
- 2026-03-20
- 무엇: 얼림 버튼을 상단 텍스트 토글에서 우측 옵션 버튼 영역으로 이동하고, ON/OFF 배경 및 아이콘을 요구 조건으로 변경했다.
- 왜: 스피커 음소거 버튼 위 배치, `13.3dp` 하단 마진, OFF 동일 스타일/ON 50% 투명 `#3BB9F1` 스타일 요구사항을 충족하기 위해서다.
- 어떻게: `activity_live_room.xml`에서 `tv_chat_freeze_switch``ll_option_buttons` 최상단 `FrameLayout`로 재배치하고 `ic_ice` 아이콘을 적용했으며, `LiveRoomActivity.kt`에서 토글 UI 배경 리소스 분기를 `bg_round_corner_10_99525252`/`bg_round_corner_10_803bb9f1`로 변경했다.
- 2026-03-20
- 무엇: 얼림 ON/OFF 시스템 문구를 방장/리스너 조건으로 분기했다.
- 왜: 방장은 "채팅창을 얼렸습니다." 문구, 리스너는 "채팅창이 얼었습니다." 문구를 요구받았고 OFF 문구는 동일하게 맞춰야 했기 때문이다.
- 어떻게: `LiveRoomActivity.kt``buildChatFreezeStatusMessage`를 호스트 여부 기반으로 재작성하고, 토글 송신/수신 및 입장 시 초기 얼림 공지에서 각각 호스트/리스너 문구를 사용하도록 반영했다.
- 2026-03-20
- 무엇: 수정분 정합성 검증을 수행했다.
- 왜: 컴파일/테스트 성공 여부와 런타임 반영 가능성을 확인하기 위해서다.
- 어떻게:
- LSP 진단: `.kt`/`.xml`에 대한 로컬 LSP 서버 미구성으로 `lsp_diagnostics` 실행 불가 확인.
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL` (테스트 및 디버그 빌드 성공, 기존 경고만 출력)
- 2026-03-20
- 무엇: 요구사항 기반 수동 QA(소스 기준)를 수행했다.
- 왜: 얼림 버튼 위치/마진/배경/아이콘과 방장·리스너 문구가 요구 문자열과 정확히 일치하는지 최종 확인하기 위해서다.
- 어떻게:
- 실행 명령: `python3` 스크립트로 `activity_live_room.xml`의 뷰 순서·속성(`tv_chat_freeze_switch``fl_speaker_mute` 위, `13.3dp`, OFF 배경, `ic_ice`)과 `LiveRoomActivity.kt` 문구를 자동 검증
- 결과: `MANUAL QA PASS: Layout placement/style and freeze notice phrases verified from source.`
- 2026-03-20
- 무엇: 최종 빌드 재검증을 수행했다.
- 왜: 최종 응답 직전 변경 상태에서 디버그 조립 성공을 다시 확인하기 위해서다.
- 어떻게:
- 실행 명령: `./gradlew :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL` (기존 경고만 출력)

View File

@@ -0,0 +1,39 @@
# 20260324 라이브룸 캡처/녹화 정합 개선
## 구현 체크리스트
- [x] 기존 구현에서 `FLAG_SECURE`와 스크린샷/녹화 콜백 결합 지점을 다시 점검한다. (QA: `LiveRoomActivity` 캡처 보안 관련 함수 흐름 확인)
- [x] `FLAG_SECURE` 유지 기준으로 dead path인 스크린샷 콜백 경로를 제거한다. (QA: `registerScreenCaptureCallback`/관련 상태 플래그 제거 확인)
- [x] 녹화 상태 기반 강제 mute 경로만 유지되도록 로직과 주석을 정리한다. (QA: `addScreenRecordingCallback` 경로 단일화 확인)
- [x] Manifest에서 불필요해진 스크린샷 감지 권한을 제거한다. (QA: `DETECT_SCREEN_CAPTURE` 선언 제거 확인)
- [x] 진단/테스트/빌드 및 수동 QA 결과를 누적 기록한다. (QA: 실행 명령과 결과 로그 확인)
## 검증 기록
- 2026-03-24
- 무엇: 후속 정합 개선 작업 계획 문서를 생성했다.
- 왜: 사용자 요청(더 나은/최신 방식 반영)에 맞춰 변경 범위와 완료 기준을 명확히 고정하기 위해서다.
- 어떻게: `docs/20260324_라이브룸캡처녹화정합개선.md` 파일을 생성하고 체크리스트/검증 섹션을 작성했다.
- 2026-03-24
- 무엇: 스크린샷 콜백 경로를 제거하고 녹화 기반 강제 mute 경로만 유지하도록 구현을 단순화했다.
- 왜: `FLAG_SECURE`를 유지하는 현재 전략에서 스크린샷 콜백 기반 강제 mute는 실질 동작하지 않는 dead path였기 때문이다.
- 어떻게:
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`에서 `registerScreenCaptureCallback`/`unregisterScreenCaptureCallback` 관련 함수, 스크린샷 상태 플래그(`isScreenshotMuteActive`) 및 타이머 상수/러너블을 제거했다.
- 강제 mute 계산을 `isScreenRecordingActive` 단일 상태로 정리했다.
- 2026-03-24
- 무엇: Manifest에서 불필요 권한을 제거했다.
- 왜: 스크린샷 콜백 경로를 제거했으므로 `DETECT_SCREEN_CAPTURE` 권한이 더 이상 필요하지 않다.
- 어떻게: `app/src/main/AndroidManifest.xml`에서 `<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />` 선언을 삭제하고 `DETECT_SCREEN_RECORDING`만 유지했다.
- 2026-03-24
- 무엇: 설계 근거와 실행 검증을 완료했다.
- 왜: 변경이 실제로 dead path 제거 + 기존 보안 전략 유지를 만족하는지 객관적으로 확인하기 위해서다.
- 어떻게:
- 외부 근거 확인:
- Android `Activity.ScreenCaptureCallback` 문구 "This is not invoked if the activity window has WindowManager.LayoutParams.FLAG_SECURE set."를 근거로 스크린샷 콜백 비동작 조건을 재확인했다.
- `WindowManager` API 레퍼런스(`addScreenRecordingCallback`, `SCREEN_RECORDING_STATE_VISIBLE`, Added in API level 35)와 `WindowManager.LayoutParams.FLAG_SECURE` 레퍼런스를 확인해 녹화 상태 콜백/보안 플래그의 현재 문서 기준을 재점검했다.
- 정적 진단:
- `lsp_diagnostics` 결과: `.kt`/`.xml` LSP 서버 미구성으로 진단 불가, `.md` 파일은 diagnostics 없음.
- 빌드/테스트:
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL`
- 수동 QA:
- 실행 명령: `python3` 검증 스크립트(FLAG_SECURE 유지, 스크린샷 API 제거, recording-only mute 계산, Manifest 권한 정합 확인)
- 결과: `MANUAL QA PASS: FLAG_SECURE 유지 + dead screenshot path 제거 + recording-only mute flow verified.`

View File

@@ -0,0 +1,56 @@
# 20260324 라이브룸 화면 캡쳐/녹화 차단 처리 계획
## 구현 체크리스트
- [x] LiveRoomActivity의 기존 오디오 음소거/복원 흐름을 탐색해 캡쳐/녹화 감지 시 재사용 지점을 확정한다. (QA: 기존 `muteAllRemoteAudioStreams`/마이크 음소거 관련 코드 경로 확인)
- [x] 화면 캡쳐/녹화 시작 상태를 감지하는 Android API 적용 방식을 확정한다. (QA: 코드베이스 탐색 + 공식 문서 근거 확인)
- [x] 감지 시 캡쳐 결과 배경이 검정색이 되도록 처리한다. (QA: `FLAG_SECURE` 또는 동등 보호 처리 코드 반영 확인)
- [x] 감지 시 음소거가 적용되고 종료 시 원복되도록 처리한다. (QA: 상태 플래그 기반 mute/unmute 분기 확인)
- [x] 수정 코드 진단/테스트/빌드/수동 검증 결과를 기록한다. (QA: 실행 명령과 결과 로그 확인)
## 검증 기록
- 2026-03-24
- 무엇: 작업 계획 문서를 생성하고 구현/검증 체크리스트를 정의했다.
- 왜: 구현 범위와 완료 기준을 명확히 고정한 상태에서 안전하게 변경하기 위해서다.
- 어떻게: `docs/20260324_라이브룸화면캡쳐녹화차단처리.md` 파일을 생성해 체크박스와 QA 기준을 작성했다.
- 2026-03-24
- 무엇: 코드베이스/외부 문서 병렬 탐색으로 캡쳐·녹화 보호 및 음소거 적용 근거를 확정했다.
- 왜: 기존 구현 패턴과 Android/Agora API 제약을 확인한 뒤 안전한 최소 변경으로 적용하기 위해서다.
- 어떻게:
- 코드 탐색: `grep`, `ast_grep_search`, `sg run`, `read``LiveRoomActivity`, `Agora.kt`, `BaseActivity`, `AndroidManifest.xml` 확인.
- 배경 탐색: `explore` 3건(`bg_7ba54780`, `bg_885fde99`, `bg_bbf958e8`), `librarian` 2건(`bg_ad1f9b7a`, `bg_486409ac`) 결과 수집.
- API 레벨 검증: `/Users/klaus/Library/Android/sdk/platforms/android-35/data/api-versions.xml`에서 `addScreenRecordingCallback`/`removeScreenRecordingCallback`/`SCREEN_RECORDING_STATE_VISIBLE``since="35"`임을 확인.
- 2026-03-24
- 무엇: LiveRoomActivity에 캡쳐/녹화 보안 처리와 음소거 동기화 로직을 구현했다.
- 왜: 화면 캡쳐/녹화 시 민감 화면이 노출되지 않고 오디오가 즉시 음소거되도록 하기 위해서다.
- 어떻게:
- `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)``onCreate`에 추가해 캡쳐 결과 비표시(검정/빈 화면) 처리.
- API 34+ `registerScreenCaptureCallback`(스크린샷), API 35+ `addScreenRecordingCallback`(녹화 상태) 등록/해제 로직을 `onStart`/`onStop`에 추가.
- `isCapturePrivacyMuted`, `isScreenRecordingActive`, `isScreenshotMuteActive` 상태를 기반으로 `agora.muteLocalAudioStream`/`agora.muteAllRemoteAudioStreams`를 일괄 적용하고 종료 시 원복.
- 기존 마이크/스피커 토글 로직을 `applyEffectiveAudioMuteState()`로 통합해 UI/실제 mute 상태를 동기화.
- 2026-03-24
- 무엇: 스크린샷/녹화 감지 콜백 동작을 위한 권한을 Manifest에 반영했다.
- 왜: Android 14+ 스크린샷 감지와 Android 15+ 녹화 상태 감지 콜백의 권한 요구사항을 충족하기 위해서다.
- 어떻게: `app/src/main/AndroidManifest.xml``<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />`, `<uses-permission android:name="android.permission.DETECT_SCREEN_RECORDING" />`를 추가했다.
- 2026-03-24
- 무엇: 구현 완료 후 Oracle 리뷰로 API/권한 회귀 위험을 점검하고 보완 반영했다.
- 왜: `addScreenRecordingCallback` 사용 시 Android 15 권한 누락으로 인한 `SecurityException` 가능성을 제거하기 위해서다.
- 어떻게: Oracle 결과(`ses_2e18fd285ffefWU6DrqSIJbgWY`)를 수집해 `DETECT_SCREEN_RECORDING` 권한을 추가하고 재빌드/재검증했다.
- 2026-03-24
- 무엇: 수정분 정합성 검증(진단/테스트/빌드/수동 QA)을 수행했다.
- 왜: 컴파일 안정성과 요구사항 반영 여부를 객관적으로 확인하기 위해서다.
- 어떻게:
- LSP 진단: `.kt` LSP 서버 미구성으로 `lsp_diagnostics` 실행 불가 확인.
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL` (수정 단계별 반복 실행 모두 성공, 기존 Gradle deprecation/namespace 경고만 존재)
- 수동 QA 명령: `python3` 스크립트로 `FLAG_SECURE`, API 34/35 콜백 등록/해제, 캡쳐 기반 음소거 동기화, Manifest 권한 선언을 검증.
- 수동 QA 결과: `MANUAL QA PASS: capture/record security + mute flow + required permissions verified from source.`
- 2026-03-24
- 무엇: 사용자 요청에 맞춰 캡처/녹화 관련 추가 코드 전반에 의미 단위 주석을 보강했다.
- 왜: API 레벨 분기(34/35), 콜백 등록/해제 대칭, 강제 mute 원복 의도를 유지보수 시 즉시 파악할 수 있도록 하기 위해서다.
- 어떻게:
- `LiveRoomActivity.kt`의 신규 상태 변수, 라이프사이클 훅, 콜백 등록/해제, 강제 mute 계산/적용 함수에 한 문장 주석을 추가했다.
- `AndroidManifest.xml``DETECT_SCREEN_CAPTURE`/`DETECT_SCREEN_RECORDING` 권한 선언 위에 목적 주석을 추가했다.
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL`
- 수동 QA 명령: `python3` 스크립트로 주석 반영 지점을 점검.
- 수동 QA 결과: `MANUAL QA PASS: meaning-unit comments added for all capture/recording additions.`

View File

@@ -0,0 +1,22 @@
# 라이브 상세 SNS 아이콘 변경
## 구현 체크리스트
- [x] `LiveRoomDetailFragment`의 SNS 아이콘 렌더링을 `CreatorDetailDialog`와 동일한 아이콘 세트(`ic_sns_*`) 및 동적 노출 방식으로 변경한다.
- [x] `GetRoomDetailManager``youtubeUrl`, `instagramUrl`, `kakaoOpenChatUrl`, `fancimmUrl`, `xUrl`를 모두 SNS 표시 대상에 포함한다.
- [x] 레이아웃에서 매니저 SNS 아이콘 영역을 동적 추가 구조로 정리한다.
- [x] 수정 파일 진단, 테스트/빌드를 실행해 결과를 확인한다.
## 검증 기록
- LSP 진단 시도
- 명령: `lsp_diagnostics` (`LiveRoomDetailFragment.kt`, `fragment_live_room_detail.xml`)
- 결과: 현재 실행 환경에 Kotlin/XML LSP 서버가 설정되어 있지 않아 진단 불가(`No LSP server configured for extension: .kt/.xml`).
- 단위 테스트 + 빌드
- 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL`
- 디바이스 설치 확인
- 명령: `./gradlew :app:installDebug`
- 결과: `Installed on 1 device.`
- 수동 QA 실행(실기기)
- 명령: `adb shell am start -n kr.co.vividnext.sodalive/.splash.SplashActivity`
- 결과: 앱 실행은 성공했으나 원격 설정에 의한 필수 업데이트 다이얼로그(`업데이트 후 사용가능합니다.`)가 표시되어 라이브 상세 진입 경로까지 진행 불가.
- 추가 확인: `adb shell am start -n kr.co.vividnext.sodalive/.live.now.all.LiveNowAllActivity` 실행 시 비-exported Activity로 `Permission Denial` 발생.

View File

@@ -0,0 +1,284 @@
# 20260326_멤버정보응답확장및콘텐츠보기설정동기화.md
## 개요
- `/member/info` 응답 스펙 확장(`countryCode`, `isAdultContentVisible`, `contentType`)을 앱 데이터 모델과 로컬 상태에 반영한다.
- 설정 화면의 `콘텐츠 보기 설정` 노출 조건을 기존 `SharedPreferenceManager.isAuth == true` 유지 + `countryCode != "KR"` 조건 추가로 확장한다.
- `ContentSettings`에서 값 변경 시 `/member/content-preference`(PATCH) API로 서버 동기화를 수행하고, API 호출 구간에 `LoadingDialog`를 표시한다.
- 빠른 연타 상황에서는 **RxJava debounce를 사용하지 않고** 마지막 상태만 서버로 전송되도록 처리한다.
## 요구사항 해석(확정)
- `/member/info` 응답 모델에 아래 필드를 추가한다.
- `countryCode: String`
- `isAdultContentVisible: Boolean`
- `contentType: ContentType`
- `/member/info`에서 수신한 `countryCode`, `isAdultContentVisible`, `contentType`을 로컬 저장소(`SharedPreferenceManager`)에 동기화한다.
- `SettingsActivity``콘텐츠 보기 설정` 메뉴 노출은 아래 중 하나라도 만족하면 표시한다.
- `SharedPreferenceManager.isAuth == true`
- `countryCode != "KR"`
- `ContentSettingsActivity`에서 성인 콘텐츠 토글/콘텐츠 타입 변경 시 `/member/content-preference` PATCH를 호출한다.
- `/member/content-preference` PATCH 요청은 실제로 변경된 필드만 전송할 수 있도록 request 파라미터를 optional로 지원한다.
- API 호출 동안 Loading UI를 노출한다.
- 연속 입력 시 debounce 처리하여 마지막 값만 전송한다.
- debounce 구현은 RxJava 연산자(`debounce`, `switchMap` 등)를 사용하지 않는다.
## 현재 구조 조사 요약
- `UserApi.getMemberInfo()`는 이미 `@GET("/member/info")`로 연결되어 있고 응답은 `GetMemberInfoResponse`를 사용한다.
- `MainViewModel.getMemberInfo()``SharedPreferenceManager.isAuth`를 갱신하는 현재 유일 경로다.
- `SettingsActivity`에서 `rlContentSettings` 노출 조건은 현재 `SharedPreferenceManager.isAuth` 단일 조건이다.
- `ContentSettingsActivity`/`ContentSettingsViewModel`은 현재 로컬 `SharedPreferenceManager`만 갱신하며 서버 PATCH 연동이 없다.
- `ContentSettingsViewModel`은 현재 `UserRepository`를 주입받지 않으며(`AppDI.kt`에서 `ContentSettingsViewModel()`), 로딩/오류 상태 LiveData도 없다.
## 설계 결정
- `GetMemberInfoResponse`를 확장하고, `MainViewModel.getMemberInfo()`에서 신규 필드를 `SharedPreferenceManager`에 동기화한다.
- 국가 코드 저장을 위해 `Constants.PREF_COUNTRY_CODE` + `SharedPreferenceManager.countryCode`를 추가한다.
- 설정 메뉴 노출 조건은 `isAuth || countryCode != "KR"`로 통합한다.
- `countryCode` 미존재/공백 시 기본값은 `"KR"`로 간주하여 기존 노출 정책을 깨지 않도록 한다.
- `/member/content-preference` PATCH API를 `UserApi`/`UserRepository`에 추가하고, 요청/응답 DTO를 분리한다.
- `ContentSettingsViewModel``UserRepository`를 주입해 서버 동기화 책임을 이동한다.
- debounce는 RxJava 없이 `Handler(Looper.getMainLooper()) + Runnable`로 구현한다.
- 입력마다 이전 Runnable을 취소하고 지연 전송을 재등록한다.
- 지연 종료 시점의 최신 상태(`isAdultContentVisible`, `contentType`)만 전송한다.
- API 진행 중 추가 변경이 들어오면 최신 상태를 대기열로 유지하고, 현재 요청 완료 직후 마지막 상태 1회만 추가 전송한다.
- `LoadingDialog`는 **실제 PATCH 요청 수행 구간**에만 표시한다(디바운스 대기 시간에는 미표시).
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `/member/info` 응답 확장 필드 3개가 모델에 반영되고, 로컬 저장소 동기화까지 정상 동작한다.
- [x] AC2: 설정 화면에서 `isAuth == true` 또는 `countryCode != "KR"`이면 `콘텐츠 보기 설정` 메뉴가 노출된다.
- [x] AC3: `ContentSettings`의 토글/타입 변경이 `/member/content-preference` PATCH로 서버에 전달된다.
- [x] AC4: PATCH 요청 수행 중 `LoadingDialog`가 표시되고 완료/실패 시 정상 해제된다.
- [x] AC5: 연속 탭 시 마지막 상태만 서버로 전송된다.
- [x] AC6: debounce 구현에서 RxJava debounce 계열 연산자를 사용하지 않는다.
## 구현 체크리스트
### 1) `/member/info` 응답 모델 확장
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt`
- `countryCode`, `isAdultContentVisible`, `contentType` 필드 추가
- [x] `contentType`은 현재 로컬에서 사용하는 `ContentType` enum과 동일 타입으로 사용하도록 매핑 정합을 유지한다.
### 2) 로컬 저장소 키/상태 확장
- [x] `app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt`
- `PREF_COUNTRY_CODE` 상수 추가
- [x] `app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt`
- `countryCode: String` 프로퍼티 추가
- 필요 시 멤버 정보 기반 기본값 동기화 로직 정의
- [x] `app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt`
- `getMemberInfo()` 성공 시 `countryCode`, `isAdultContentVisible`, `contentType`을 각각 `SharedPreferenceManager.countryCode`, `SharedPreferenceManager.isAdultContentVisible`, `SharedPreferenceManager.contentPreference`로 동기화한다.
### 3) 설정 메뉴 노출 조건 확장
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt`
- `rlContentSettings` 표시 조건을 `isAuth || countryCode != "KR"`로 변경
- 기존 숨김/표시 및 클릭 연결 흐름 유지
### 4) 콘텐츠 설정 PATCH API 추가
- [x] `app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt`
- `@PATCH("/member/content-preference")` API 정의 추가
- [x] `app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt`
- content preference 업데이트 메서드 추가
- [x] 신규 DTO 파일 추가
- 요청: `UpdateContentPreferenceRequest` (`isAdultContentVisible`, `contentType`)
- 응답: `UpdateContentPreferenceResponse` (`isAdultContentVisible`, `contentType`)
### 5) ContentSettings 서버 동기화 + Non-Rx debounce
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt`
- `UserRepository` 주입으로 생성자 변경
- 로딩/오류 상태 LiveData 추가
- 로컬 상태 반영 후 debounce 스케줄링 함수 추가(Handler/Runnable)
- in-flight + pending 상태를 분리해 마지막 상태 1회 전송 보장
- `onCleared()`에서 callback 정리
- [x] `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- `viewModel { ContentSettingsViewModel(get()) }`로 DI 갱신
### 6) ContentSettings 화면 로딩 UI 반영
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt`
- `LoadingDialog` 초기화 및 `viewModel.isLoading` 관찰
- 요청 중 표시/요청 완료 해제 처리
### 7) 검증
- [ ] `lsp_diagnostics`로 수정 파일 신규 오류 확인
- [x] `./gradlew :app:testDebugUnitTest` 실행
- [x] `./gradlew :app:assembleDebug` 실행
- [ ] 수동 QA
- 인증 사용자 + 비인증/해외 국가 코드 조합별 메뉴 노출 확인
- 콘텐츠 설정 연타 시 네트워크 요청이 마지막 값 위주로 수렴되는지 확인
- 요청 중 LoadingDialog 표시/해제 확인
### 8) 추가 요구사항 반영 (2026-03-27)
- [x] `app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt`
- 본인인증/인증완료 아이템(`btnIdentityVerification`)을 `countryCode == "KR"`일 때만 노출
- 비KR에서는 본인인증 버튼을 숨김 처리
### 9) 콘텐츠 설정 PATCH optional request 반영 (2026-03-27)
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt`
- `isAdultContentVisible`, `contentType`를 nullable optional 파라미터로 변경
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt`
- 변경된 필드만 request에 담아 PATCH 호출하도록 분기
### 10) 캐릭터 상세 진입 인증 체크 국가 분기 반영 (2026-03-27)
- [x] `app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt`
- 캐릭터 상세 진입 전 `ensureLoginAndAuth`를 KR/비KR 분기로 조정
- [x] `app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt`
- 캐릭터 상세 진입 전 `ensureLoginAndAuth`를 KR/비KR 분기로 조정
### 11) KR 인증완료 + 민감 콘텐츠 OFF 케이스 동작 일치화 (2026-03-27)
- [x] `app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt`
- `ensureLoginAndAdultAuth`(19금 라이브 상세)에서 KR 인증완료 상태라도 `isAdultContentVisible == false`면 설정 가이드로 이동
- `ensureLoginAndAuth`(캐릭터 상세)에서 KR 인증완료 상태라도 `isAdultContentVisible == false`면 설정 가이드로 이동
- [x] `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`
- `ensureLoginAndAdultAuth`(19금 라이브 상세)에서 KR 인증완료 + 민감 콘텐츠 OFF를 비KR OFF와 동일 처리
- [x] `app/src/main/java/kr/co/vividnext/sodalive/live/now/all/LiveNowAllActivity.kt`
- `ensureLoginAndAdultAuth`(19금 라이브 상세)에서 KR 인증완료 + 민감 콘텐츠 OFF를 비KR OFF와 동일 처리
- [x] `app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt`
- `ensureLoginAndAuth`(캐릭터 상세)에서 KR 인증완료 + 민감 콘텐츠 OFF를 비KR OFF와 동일 처리
## 영향 파일(예상)
### 필수
- `app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
### 신규 파일(예상)
- `app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceResponse.kt`
## 리스크 및 확인사항
- `contentType`은 현재 로컬에서 사용하는 `ContentType`과 동일 타입을 사용한다.
- 멤버 정보 API 호출 시점은 앱 실행 후 3번째 실행 흐름으로, 메뉴 노출 조건 반영 타이밍상 문제는 없는 것으로 본다.
- PATCH 실패 시 직전 값으로 롤백한다(값 변경 시 API 호출 + LoadingDialog 표시 구간에서 사용자 대기가 가능한 UX를 전제로 함).
- 연타 + 네트워크 지연 상황에서 LoadingDialog 과도 점멸을 방지하기 위해 표시 조건을 실제 API 요청 구간으로 제한한다(반영).
## 검증 계획
- 정적 확인: 응답/요청 DTO 필드, 메뉴 노출 조건식, debounce 상태 변수 흐름 점검
- 자동 검증: 단위 테스트 및 디버그 빌드 성공 확인
- 수동 검증: 한국/비한국 노출 케이스, 토글 연타 케이스, 마지막 값 전송 케이스
## 검증 기록
- 기록 템플릿(후속 누적):
- YYYY-MM-DD
- 무엇/왜/어떻게:
- 실행 명령/도구:
- `명령 또는 사용 도구`
- 결과:
- 2026-03-26
- 무엇/왜/어떻게: `/member/info` 응답 확장, 설정 메뉴 노출 확장, `/member/content-preference` PATCH, Non-Rx debounce 제약을 반영한 구현 계획 문서를 작성했다.
- 실행 명령/도구:
- `read(docs/*, UserApi.kt, GetMemberInfoResponse.kt, SettingsActivity.kt, ContentSettingsActivity.kt, ContentSettingsViewModel.kt, SharedPreferenceManager.kt, AppDI.kt)`
- `grep("/member/info|content-preference|isAuth|countryCode|ContentType")`
- `apply_patch(docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md 생성)`
- 결과:
- 요구사항과 추가 제약("debounce는 RxJava 미사용")이 반영된 체크리스트 중심 계획 문서를 생성했다.
- 2026-03-26
- 무엇/왜/어떻게: 사용자 피드백에 따라 리스크/확인사항을 확정 대응안으로 갱신하고, `/member/info` 수신 필드 3종의 로컬 저장소 동기화 요구를 문서에 명시했다.
- 실행 명령/도구:
- `read(docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md)`
- `apply_patch(docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md 수정)`
- 결과:
- `contentType 동일 타입 사용`, `멤버 정보 호출 타이밍 이슈 없음`, `PATCH 실패 롤백`, `LoadingDialog API 구간 제한`이 문서에 반영되었다.
- `/member/info` 확장 필드(`countryCode`, `isAdultContentVisible`, `contentType`)의 로컬 저장소 동기화 요구가 AC/체크리스트에 반영되었다.
- 2026-03-26
- 무엇/왜/어떻게: 계획 문서 기준으로 `/member/info` 응답 확장 필드 로컬 동기화, 설정 메뉴 노출 조건 확장, `/member/content-preference` PATCH 연동, ContentSettings의 Non-Rx debounce + in-flight/pending 마지막 값 전송 보장, LoadingDialog 연동을 구현했다.
- 실행 명령/도구:
- `task(explore x2)`
- `grep/read(app/src/main/java/**)`
- `apply_patch(소스 12개 파일 + DTO 2개 파일)`
- `lsp_diagnostics(수정된 .kt 파일 일괄 시도)`
- `./gradlew :app:testDebugUnitTest`
- `./gradlew :app:assembleDebug`
- 결과:
- `GetMemberInfoResponse``countryCode/isAdultContentVisible/contentType`가 추가되고 `MainViewModel.getMemberInfo()`에서 `SharedPreferenceManager`로 동기화된다.
- `SettingsActivity``콘텐츠 보기 설정` 노출 조건이 `isAuth || countryCode != "KR"`로 확장되었다.
- `UserApi/UserRepository``/member/content-preference` PATCH와 요청/응답 DTO가 추가되었다.
- `ContentSettingsViewModel``Handler + Runnable` 기반 debounce와 in-flight/pending 제어를 적용해 연타 시 마지막 상태만 서버로 전송되며, 요청 중에만 `isLoading`이 true가 된다.
- `ContentSettingsActivity``isLoading`을 관찰해 `LoadingDialog`를 표시/해제하고 오류 토스트를 노출한다.
- `lsp_diagnostics`는 현재 환경에 Kotlin LSP가 없어 실행 불가 메시지를 확인했다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다.
- 2026-03-26
- 무엇/왜/어떻게: 정적 검사 대체 검증과 실제 단말 스모크 실행으로 구현 안정성을 추가 확인했다.
- 실행 명령/도구:
- `./gradlew :app:ktlintCheck`
- `adb devices`
- `adb install -r app/build/outputs/apk/debug/app-debug.apk`
- `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`
- `adb shell dumpsys activity | grep -E "ResumedActivity|mFocusedApp|topResumedActivity|mCurrentFocus"`
- `adb logcat -d -s AndroidRuntime`
- `adb shell pidof kr.co.vividnext.sodalive.debug`
- 결과:
- `ktlintCheck`는 기존 코드베이스(주로 `LiveRoomActivity` 등) 기등록 스타일 위반으로 실패했다.
- 이번 변경 파일 기준으로는 `ContentSettingsActivity`, `GetMemberInfoResponse` 지적 항목을 정리 후 재빌드하여 `:app:testDebugUnitTest`, `:app:assembleDebug` 재성공을 확인했다.
- 디버그 APK 설치 및 런처 실행(monkey) 후 `SplashActivity`가 resumed 상태이며 앱 프로세스(pid) 실행 중임을 확인했다.
- 2026-03-27
- 무엇/왜/어떻게: 추가 요청에 따라 `MyPageFragment`의 본인인증/인증완료 아이템을 한국 접속국가에서만 노출하도록 분기했다.
- 실행 명령/도구:
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `./gradlew :app:testDebugUnitTest`
- `./gradlew :app:assembleDebug`
- 결과:
- `countryCode.ifBlank { "KR" } == "KR"` 조건에서만 `btnIdentityVerification`이 표시되며, 비KR에서는 숨김 처리된다.
- `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다.
- 2026-03-27
- 무엇/왜/어떻게: `/member/content-preference` 요청에 변경된 값만 전송하기 위해 request 필드를 optional로 전환하고, `confirmedState` 대비 변경 필드만 body에 담아 PATCH 호출하도록 조정했다.
- 실행 명령/도구:
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt)`
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt)`
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과:
- `UpdateContentPreferenceRequest``isAdultContentVisible`, `contentType`가 nullable optional로 변경되었다.
- `ContentSettingsViewModel.syncContentPreference()`에서 `confirmedState`와 비교해 변경된 필드만 request에 포함하며, 변경 필드가 없으면 API 호출을 생략한다.
- `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다.
- 2026-03-27
- 무엇/왜/어떻게: 캐릭터 터치 후 상세 진입 시 사용되는 `ensureLoginAndAuth` 인증 체크를 접속국가 기준으로 분기했다. KR은 기존 본인인증 다이얼로그 흐름을 유지하고, 비KR은 `isAdultContentVisible`이 꺼져 있으면 `ContentSettingsActivity`로 이동해 민감한 콘텐츠 스위치 안내 흐름을 사용하도록 맞췄다.
- 실행 명령/도구:
- `task(explore x2: 캐릭터 상세 진입 경로/인증 게이트 탐색)`
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt)`
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt)`
- `lsp_diagnostics(HomeFragment.kt, CharacterTabFragment.kt)`
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- `adb devices`
- `adb install -r app/build/outputs/apk/debug/app-debug.apk`
- `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`
- `adb shell uiautomator dump /sdcard/window_dump.xml`
- `adb pull /sdcard/window_dump.xml /tmp/sodalive_window_dump.xml`
- 결과:
- `HomeFragment``CharacterTabFragment`의 캐릭터 상세 진입 전 인증 체크가 `countryCode.ifBlank { "KR" } == "KR"` 기준으로 분기된다.
- KR에서 미인증이면 기존 인증 다이얼로그/인증 플로우를 유지한다.
- 비KR에서 `isAdultContentVisible == false`이면 `ContentSettingsActivity`로 이동하고 `EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE`를 전달한다.
- `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다.
- 수동 검증 시도 중 ADB 장치 연결이 해제되어 캐릭터 탭 실제 터치 시나리오를 끝까지 완료하지 못했다.
- 2026-03-27
- 무엇/왜/어떻게: 한국 접속 + 본인인증 완료 상태에서도 `isAdultContentVisible == false`이면 19금 라이브 상세와 채팅 캐릭터 상세를 비KR OFF와 동일하게 처리하도록 게이트 조건을 조정했다.
- 실행 명령/도구:
- `task(explore: live/character gate 위치 점검)`
- `grep("ensureLoginAndAdultAuth|ensureLoginAndAuth|onCharacterClick")`
- `apply_patch(HomeFragment.kt, LiveFragment.kt, LiveNowAllActivity.kt, CharacterTabFragment.kt)`
- `lsp_diagnostics(수정된 .kt 파일 4개)`
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- `adb devices`
- `adb shell am force-stop kr.co.vividnext.sodalive.debug`
- `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`
- `adb shell uiautomator dump /sdcard/window_dump.xml`
- 결과:
- KR에서 `isAuth=true`라도 `isAdultContentVisible=false`이면 19금 라이브 상세/캐릭터 상세 진입 시 `ContentSettingsActivity` 안내 흐름으로 분기된다.
- 기존 KR 미인증 케이스의 본인인증 다이얼로그 흐름은 유지된다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 성공.
- `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다.
- 수동 QA는 디바이스 연결이 반복 해제되어 조건 기반 실제 탭 시나리오를 끝까지 수행하지 못했다.

View File

@@ -0,0 +1,35 @@
# 연령제한 설정 UI 표시 조건 수정
## 작업 목표
- 라이브 생성, 콘텐츠 업로드 페이지의 연령제한 설정 UI 표시 조건을 접속국가와 `isAdultContentVisible` 기준으로 조정한다.
## 체크리스트
- [x] AC1: 공통 필수조건으로 `isAdultContentVisible == true`일 때만 연령제한 설정 UI를 표시한다.
- QA: `isAdultContentVisible=false`에서 UI 미표시, `isAdultContentVisible=true`에서 국가별 추가 조건 적용 확인
- [x] AC2: 접속국가가 한국(`countryCode == "KR"`)인 경우 `isAuth == true`일 때만 연령제한 설정 UI를 표시한다.
- QA: KR + `isAuth=false` 미표시, KR + `isAuth=true` 표시
- [x] AC3: 접속국가가 한국이 아닌 경우 `isAuth`와 무관하게 AC1만 충족하면 UI를 표시한다.
- QA: non-KR + `isAdultContentVisible=true`에서 `isAuth` true/false 모두 표시
- [x] AC4: 라이브 생성/콘텐츠 업로드 양쪽 페이지에 동일 규칙이 적용된다.
- QA: 두 화면에서 동일 입력 조건 대비 동일 표시 결과 확인
- [x] AC5: 변경 파일 진단/테스트/빌드 검증을 통과한다.
- QA: Kotlin LSP 미지원 환경 확인, `./gradlew :app:testDebugUnitTest`, `./gradlew :app:assembleDebug`
## 검증 기록
- 무엇/왜/어떻게: 라이브 생성(`LiveRoomCreateActivity`)과 콘텐츠 업로드(`AudioContentUploadActivity`)의 연령제한 UI 노출 조건을 공통 정책(`AdultContentVisibilityPolicy`)으로 통합했다. 요청 조건(필수 `isAdultContentVisible=true`, KR 추가 `isAuth=true`)을 두 화면에서 동일하게 적용하기 위해 기존 `isAuth` 단일 조건을 정책 함수 호출로 교체했다.
- 실행 명령: 코드 수정(해당 3개 Kotlin 파일 + 정책 테스트 1개 파일 추가)
- 결과: 두 화면 모두 `shouldShowAdultRestrictionSetting()` 기반으로 `llSetAdult` 표시 및 연령선택 핸들러 등록 조건이 동작하도록 반영됨
- 무엇/왜/어떻게: 정책 로직의 조건 분기 오동작을 방지하기 위해 단위 테스트(`AdultContentVisibilityPolicyTest`)로 KR/non-KR, `isAdultContentVisible`, `isAuth`, 빈 `countryCode` 조합을 검증했다.
- 실행 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.common.AdultContentVisibilityPolicyTest"`
- 결과: BUILD SUCCESSFUL
- 무엇/왜/어떻게: 변경 영향 범위의 회귀 확인을 위해 디버그 단위 테스트 전체와 디버그 빌드를 수행했다.
- 실행 명령: `./gradlew :app:testDebugUnitTest`
- 결과: BUILD SUCCESSFUL
- 실행 명령: `./gradlew :app:assembleDebug`
- 결과: BUILD SUCCESSFUL
- 무엇/왜/어떻게: 정적 진단 요구사항 확인을 위해 LSP diagnostics를 시도했으나 현재 실행 환경에는 `.kt`용 LSP 서버가 구성되어 있지 않음을 확인했다. 대신 Gradle 컴파일/테스트/빌드 성공으로 Kotlin 컴파일 오류 유무를 검증했다.
- 실행 명령: `lsp_diagnostics` (4개 Kotlin 파일)
- 결과: `No LSP server configured for extension: .kt`

View File

@@ -0,0 +1,36 @@
# 20260327_콘텐츠보기설정파라미터전송정리
## 작업 목적
- `PATCH /member/content-preference`를 제외한 모든 API 요청에서 `isAdultContentVisible`, `contentType` 전송을 제거한다.
## 구현 체크리스트
- [x] 코드베이스에서 `isAdultContentVisible`, `contentType` 전송 위치를 전수 확인한다.
- [x] 예외 API(`PATCH /member/content-preference`) 선언 및 호출 경로를 확인한다.
- [x] 예외 API 외 DTO/호출부에서 두 파라미터를 제거한다.
- [x] 수정 파일 진단, 테스트, 빌드 검증을 수행한다.
- [x] 검증 기록을 문서 하단에 누적한다.
## 수용 기준 (Pass/Fail)
- [x] PASS: `PATCH /member/content-preference`에서는 `isAdultContentVisible`, `contentType`가 유지된다.
- [x] PASS: 그 외 API 요청에서는 `isAdultContentVisible`, `contentType`가 전송되지 않는다.
- [x] PASS: `./gradlew :app:testDebugUnitTest`가 성공한다.
- [x] PASS: `./gradlew :app:assembleDebug`가 성공한다.
## 검증 기록
- 2026-03-27
- 무엇/왜/어떻게: `/member/content-preference` PATCH를 제외한 API 요청에서 `isAdultContentVisible`, `contentType` 전송을 제거했다. Retrofit API 시그니처와 각 Repository 호출 인자를 함께 정리해 누락 없이 제거했다.
- 실행 명령/도구:
- `task(explore: 전송 경로 탐색 2건)`
- `grep("@Query(\"isAdultContentVisible\")|@Query(\"contentType\")", include="*Api.kt")`
- `apply_patch(Live/Home/Search/Explorer/AudioContent/Series API·Repository 수정)`
- `lsp_diagnostics(수정된 .kt 파일 14개)`
- `grep("@PATCH(\"/member/content-preference\")", include="UserApi.kt")`
- `grep("@Query(\"isAdultContentVisible\")|@Query(\"contentType\")", include="*Api.kt")`
- `grep("isAdultContentVisible|contentType", include="*Request*.kt")`
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과:
- `UserApi.kt``@PATCH("/member/content-preference")`는 유지됨을 확인했다.
- 전체 `*Api.kt`에서 `@Query("isAdultContentVisible")`, `@Query("contentType")`가 0건임을 확인했다.
- `*Request*.kt`에서 두 필드는 `UpdateContentPreferenceRequest.kt` 1건만 남아 예외 API 요청으로 제한됨을 확인했다.
- `lsp_diagnostics`는 Kotlin LSP 미구성으로 진단 불가 메시지를 확인했다.
- `./gradlew :app:testDebugUnitTest :app:assembleDebug` 성공(`BUILD SUCCESSFUL`).

View File

@@ -0,0 +1,43 @@
# 20260328 라이브룸 방장 캡쳐/녹화 허용
## 구현 체크리스트
- [x] 방장 판별 시점과 캡처 보안 적용 지점을 확인한다. (QA: `isHost` 갱신 지점과 `FLAG_SECURE` 적용 지점 라인 확인)
- [x] 방장일 때만 `FLAG_SECURE`를 해제하고, 청취자는 기존 차단 상태를 유지한다. (QA: 방장/비방장 분기에서 `addFlags`/`clearFlags` 동작 확인)
- [x] 방장일 때 녹화 감지 기반 강제 mute가 적용되지 않도록 정합을 맞춘다. (QA: `syncCapturePrivacyMuteState` 분기 및 콜백 등록/해제 흐름 확인)
- [x] 진단/빌드/테스트/수동 QA를 수행하고 결과를 기록한다. (QA: 실행 명령과 결과 로그 확인)
## 검증 기록
- 2026-03-28
- 무엇: 방장 예외 적용을 위한 코드베이스/외부 레퍼런스 병렬 탐색을 수행했다.
- 왜: `FLAG_SECURE`를 역할 기반으로 런타임 토글할 때 라이프사이클/콜백 경합 없이 최소 변경으로 구현하기 위해서다.
- 어떻게:
- 내부 탐색(`explore`):
- `bg_016c0dfd` (host 보안 플로우 맵)
- `bg_ba4aa673` (host 판별 지연 시 race 위험 분석)
- `bg_3132d80b` (저장소 내 역할 기반 secure 패턴 탐색)
- 외부 탐색(`librarian`):
- `bg_1875bb8f` (Android 공식 문서/AOSP의 addFlags/clearFlags 근거)
- `bg_d010820d` (OSS 동적 토글 사례: Fenix/Signal/Bitwarden)
- 직접 검색:
- `grep`/`ast_grep_search`/`sg run`으로 `FLAG_SECURE`, `isHost`, 콜백 등록/해제, mute 계산식을 교차 확인
- `rg`는 로컬 환경에 설치되어 있지 않아(`command -v rg` 결과 없음) `grep`/`sg`로 대체 검증
- 2026-03-28
- 무엇: `LiveRoomActivity`에 방장 전용 캡처/녹화 허용 정책을 구현했다.
- 왜: 사용자 요청대로 방장(host)은 캡처/화면녹화를 허용하고, 청취자는 기존 차단 정책을 유지해야 하기 때문이다.
- 어떻게:
- `isHost` 판별 직후 정책 동기화를 위해 `syncCaptureSecurityPolicyByRole()`를 추가했다.
- 방장(`isHost=true`): `window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)` + 녹화 콜백 해제 + 강제 mute 상태 정리
- 청취자(`isHost=false`): `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)` + 포그라운드에서 녹화 콜백 등록 유지
- `viewModel.roomInfoLiveData.observe`에서 `isHost` 갱신 직후 `syncCaptureSecurityPolicyByRole()`를 호출해 비동기 roomInfo 도착 시점에도 즉시 반영되도록 했다.
- `syncCapturePrivacyMuteState()``val shouldMute = !isHost && isScreenRecordingActive`로 변경해 방장은 녹화 중에도 강제 mute 대상에서 제외했다.
- 2026-03-28
- 무엇: 진단/빌드/테스트/수동 QA를 완료했다.
- 왜: 컴파일 안정성과 요청 동작(방장 허용, 청취자 유지)을 실제 증거로 확인하기 위해서다.
- 어떻게:
- LSP 진단: `.kt` 서버 미구성으로 `lsp_diagnostics` 불가(환경 제약 확인), `.md` 파일 diagnostics는 없음.
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL`
- 수동 QA 명령: `python3` 스크립트로 정책 함수/분기/호출 순서/mute 계산식을 점검
- 수동 QA 결과: `MANUAL_QA_PASS: host can bypass capture security while listeners remain protected in source flow`

View File

@@ -0,0 +1,42 @@
# 20260328 라이브룸 캡쳐/화면녹화 차단 점검
## 구현/점검 체크리스트
- [x] `LiveRoomActivity` 내 캡쳐/화면녹화 차단 적용 지점 확인
- [x] 전체 코드베이스에서 차단 해제/우회 가능 경로(`clearFlags`, `FLAG_SECURE` 재설정 등) 탐색
- [x] 화면녹화 감지 및 후속 처리(음소거/콜백 등록 해제) 로직 검증
- [x] 외부 레퍼런스(Android 공식 동작)와 현재 구현 정합성 검증
- [x] 점검 결과 및 근거(명령/파일/라인) 기록
## 검증 기록
- 2026-03-28
- 무엇: 라이브룸 캡처/화면녹화 차단 적용 여부를 코드베이스 전역과 외부 레퍼런스로 교차 점검했다.
- 왜: 사용자 질문("현재 모든 사람이 캡쳐와 화면녹화가 불가능한지")에 대해 단일 파일 확인이 아닌 우회 경로/플랫폼 제약까지 포함한 근거를 확보해야 했기 때문이다.
- 어떻게:
- 병렬 탐색(내부): `explore` 에이전트 3건
- `bg_baa23d06` (LiveRoomActivity 보안 플로우 추적)
- `bg_c991f78f` (우회/해제 경로 탐색)
- `bg_a5c8b08e` (대체 라이브 진입 화면 탐색)
- 병렬 탐색(외부): `librarian` 에이전트 2건
- `bg_b2336b84` (Android 공식 동작/제약)
- `bg_320d7f9b` (OSS 구현 패턴 비교)
- 직접 검색/정적 검증 명령:
- `grep "FLAG_SECURE|registerScreenRecordingCallback|addScreenRecordingCallback|removeScreenRecordingCallback"` (저장소 전역)
- `ast_grep_search`/`sg run`으로 `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)``window.clearFlags(...)` 존재 여부 확인
- `grep "clearFlags\(|setFlags\("` (app/src/main/java 전역)
- `read``LiveRoomActivity.kt` 라이프사이클/콜백/mute 처리 라인 직접 확인
- SDK 레퍼런스 확인: `/Users/klaus/Library/Android/sdk/platforms/android-35/data/api-versions.xml`
- 결과(핵심 근거):
- `LiveRoomActivity.kt:390`에서 `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)`가 무조건 적용된다.
- 앱 코드에서 `window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)`는 발견되지 않았다(`sg run`/`ast_grep_search`/`grep` 교차 검증).
- 녹화 감지/후처리 로직:
- 등록: `LiveRoomActivity.kt:1627` (`windowManager.addScreenRecordingCallback`)
- 해제: `LiveRoomActivity.kt:1654` (`windowManager.removeScreenRecordingCallback`)
- 상태 반영: `LiveRoomActivity.kt:1659-1693` (`isScreenRecordingActive` -> `isCapturePrivacyMuted` -> `applyEffectiveAudioMuteState`)
- API 레벨 근거:
- `addScreenRecordingCallback`/`removeScreenRecordingCallback`/`SCREEN_RECORDING_STATE_VISIBLE`는 API 35부터(`api-versions.xml:70441,70451,70475`).
- `FLAG_SECURE`는 Android 플랫폼 상수로 존재(`api-versions.xml:70558`).
- 권한 근거:
- `AndroidManifest.xml:19``<uses-permission android:name="android.permission.DETECT_SCREEN_RECORDING" />` 선언이 존재한다.
- 결론:
- **LiveRoomActivity 화면에 입장한 사용자 기준으로는 캡처/녹화 노출이 차단되도록 구현되어 있다(FLAG_SECURE 적용 + 해제 경로 부재).**
- 다만 Android 공식 문서 범위상 `FLAG_SECURE`는 기본적으로 스크린샷/비보안 디스플레이 노출 차단을 보장하며, 모든 녹화 시나리오 100% 차단을 플랫폼이 절대 보장한다고 단정할 수는 없다.

View File

@@ -0,0 +1,29 @@
# 마이페이지 본인인증 버튼 숨김 시 정렬 유지 수정
## 작업 목표
- 국가가 한국이 아닌 경우 `btn_identity_verification`을 숨기더라도 Function Buttons Grid의 다른 아이콘 위치가 기존과 동일하게 유지되도록 수정한다.
## 체크리스트
- [x] AC1: `countryCode != "KR"`인 경우 `btn_identity_verification`이 화면에 보이지 않는다.
- QA: `btnIdentityVerification.root.visibility``View.INVISIBLE`로 설정되어 슬롯 공간이 유지되는지 코드 확인
- [x] AC2: `countryCode != "KR"`인 경우에도 같은 행의 다른 버튼(`btn_notice`, `btn_event`, `btn_customer_service`) 위치가 기존과 동일하게 유지된다.
- QA: `View.GONE` 대신 `View.INVISIBLE` 사용 여부 확인
- [x] AC3: `countryCode == "KR"`인 경우 기존 본인인증 버튼 노출/동작 로직이 유지된다.
- QA: KR 분기에서 기존 `View.VISIBLE` + 인증 상태별 버튼 설정 코드 보존 확인
- [x] AC4: 변경 파일 진단/테스트/빌드 검증을 통과한다.
- QA: `lsp_diagnostics`, `./gradlew :app:testDebugUnitTest`, `./gradlew :app:assembleDebug`
## 검증 기록
- 2026-03-28
- 무엇/왜/어떻게: Function Buttons Grid 두 번째 행이 `LinearLayout``layout_weight` 기반이어서 `btn_identity_verification``GONE` 처리하면 남은 버튼이 재배치된다. 슬롯은 유지하고 아이콘만 숨기기 위해 non-KR 분기에서 `View.GONE``View.INVISIBLE`로 변경했다.
- 실행 명령/도구:
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `read(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `lsp_diagnostics(docs/20260328_마이페이지본인인증버튼숨김정렬유지.md)`
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과:
- non-KR 분기에서 `btnIdentityVerification.root.visibility = View.INVISIBLE`로 반영되어 버튼 슬롯 유지 조건을 충족했다.
- KR 분기의 `View.VISIBLE` 및 인증 상태별 버튼 구성 로직은 변경 없이 유지됐다.
- `.kt` 파일 대상 `lsp_diagnostics`는 현재 환경에 Kotlin LSP가 없어 실행 불가(`No LSP server configured for extension: .kt`)였고, 문서 파일 진단은 이슈 없음.
- `:app:testDebugUnitTest`, `:app:assembleDebug`를 포함한 Gradle 실행이 `BUILD SUCCESSFUL`로 완료됐다.

View File

@@ -0,0 +1,49 @@
# 20260328_멤버정보응답하위호환수정.md
## 개요
- 이전 서버의 `/member/info` 응답에 `countryCode`, `isAdultContentVisible`, `contentType`가 없어도 신규 앱이 동일하게 동작하도록 하위 호환을 보장한다.
## 요구사항 해석(확정)
- `GetMemberInfoResponse`의 신규 필드 3개는 구서버 응답에서 누락될 수 있으므로 nullable로 처리한다.
- `MainViewModel.getMemberInfo()` 동기화 시 누락된 값은 로컬 저장값(없으면 안전 기본값)으로 대체한다.
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 구서버 응답(JSON에 신규 3개 필드 누락) 역직렬화가 실패하지 않는다.
- [x] AC2: 구서버 응답 수신 시 `SharedPreferenceManager.countryCode/isAdultContentVisible/contentPreference`가 null로 오염되지 않고 기존 동작을 유지한다.
- [x] AC3: 관련 단위 테스트와 디버그 빌드가 성공한다.
## 구현 체크리스트
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt`
- 신규 필드(`countryCode`, `isAdultContentVisible`, `contentType`)를 nullable + default null로 변경
- [x] `app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt`
- 멤버 정보 동기화 시 신규 필드 null-safe fallback 적용
- [x] `app/src/test/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponseCompatibilityTest.kt`
- 구서버 응답 누락 필드 역직렬화 및 fallback 동작 검증 테스트 추가
- [x] 검증 실행
- `lsp_diagnostics`(수정 파일)
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponseCompatibilityTest"`
- `./gradlew :app:testDebugUnitTest`
- `./gradlew :app:assembleDebug`
## 검증 기록
- 기록 템플릿(후속 누적):
- YYYY-MM-DD
- 무엇/왜/어떻게:
- 실행 명령/도구:
- `명령 또는 사용 도구`
- 결과:
- 2026-03-28
- 무엇/왜/어떻게: 구서버(`/member/info`)에서 신규 필드 3종이 누락돼도 신규 앱이 동일 동작하도록 응답 모델 nullable 처리 + 멤버 정보 동기화 fallback 로직을 적용했고, 역직렬화 호환 테스트를 추가했다.
- 실행 명령/도구:
- `apply_patch(GetMemberInfoResponse.kt, MainViewModel.kt, GetMemberInfoResponseCompatibilityTest.kt, docs/20260328_멤버정보응답하위호환수정.md)`
- `lsp_diagnostics(GetMemberInfoResponse.kt, MainViewModel.kt, GetMemberInfoResponseCompatibilityTest.kt)`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponseCompatibilityTest"`
- `./gradlew :app:testDebugUnitTest`
- `./gradlew :app:assembleDebug`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponseCompatibilityTest" :app:assembleDebug`
- `read(app/build/test-results/testDebugUnitTest/TEST-kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponseCompatibilityTest.xml)`
- 결과:
- `lsp_diagnostics``.kt` LSP 서버 미구성으로 실행 불가를 확인했다.
- 호환성 테스트 2건(구서버 누락 필드 역직렬화/신규 필드 정상 매핑)이 모두 통과했다.
- 전체 단위 테스트와 디버그 빌드가 모두 성공했고, 마지막 재검증 명령에서도 성공을 재확인했다.

View File

@@ -0,0 +1,57 @@
# 라이브룸 UI 미갱신 버그 수정
## 현상
- 라이브 입장 후 공지, 메뉴판 터치 시 UI가 보이지 않음
- 상대방 채팅이 화면에 갱신되지 않음
- 방장이 아닌 유저에게서 두드러지게 나타남
- 키보드가 올라오거나 화면에 변화가 생기면 모든 것이 해결됨
## 원인 분석
- `BaseActivity`에서 `WindowCompat.setDecorFitsSystemWindows(window, false)` (edge-to-edge) 적용
- `LiveRoomActivity`의 manifest에 `adjustPan` 설정과 edge-to-edge가 충돌
- `adjustPan`은 시스템이 창을 pan 하려 하지만, edge-to-edge 모드에서는 앱이 insets을 직접 처리
- 이 충돌로 DecorView 내부 스크롤 트래킹 상태가 불일치하여 `invalidate()` 더티 영역 계산 오류 발생
- 키보드가 올라가면 시스템이 WindowInsets를 재분배하고, `OnApplyWindowInsetsListener`에서 `setPadding()` 호출 → `requestLayout()` → 전체 레이아웃 패스가 강제 수행되어 해결됨
## 수정 방법
- RTM과 RTC가 모두 연결 완료된 시점에 키보드를 프로그래밍적으로 올렸다 내려 레이아웃을 강제 갱신
- `isRtcJoined`, `isRtmJoined` 플래그로 두 연결 상태를 추적
- 두 플래그가 모두 true가 되면 `tryForceLayoutRefresh()`를 호출
### 눈속임 처리 (사용자에게 변화가 보이지 않도록)
1. 로딩 다이얼로그가 화면을 덮고 있는 동안 키보드 트릭을 수행
2. `adjustNothing`으로 임시 전환하여 키보드가 화면을 밀어올리지 않도록 방지
3. 키보드 show → 200ms 후 hide → `adjustPan` 복원 → 로딩 다이얼로그 dismiss
4. RTM 콜백의 `loadingDialog.dismiss()``tryForceLayoutRefresh()` 내부로 이동
## 수정 계획
- [x] `isRtcJoined`, `isRtmJoined` 플래그 추가
- [x] `onJoinChannelSuccess`에서 `isRtcJoined = true` 설정 및 `tryForceLayoutRefresh()` 호출
- [x] RTM 성공 콜백에서 `isRtmJoined = true` 설정 및 `tryForceLayoutRefresh()` 호출
- [x] `tryForceLayoutRefresh()` 메서드 구현
- [x] adjustNothing 임시 전환으로 화면 이동 방지
- [x] 로딩 다이얼로그 뒤에서 키보드 트릭 수행
- [x] 완료 후 adjustPan 복원 및 로딩 다이얼로그 dismiss
- [x] Boolean 반환으로 RTM 콜백에서 fallback dismiss 처리
- [x] 빌드 검증
## 검증 기록
### 2026-03-29 빌드 검증 (1차 - adjustNothing 방식)
- 명령: `./gradlew :app:assembleDebug`
- 결과: BUILD SUCCESSFUL (16s, 46 tasks)
- 변경 파일: `AndroidManifest.xml` (adjustPan→adjustNothing), `LiveRoomActivity.kt` (API S→R)
- 비고: 실기기에서 효과 없음 → 되돌림
### 2026-03-29 빌드 검증 (2차 - 키보드 강제 갱신 방식)
- 명령: `./gradlew :app:assembleDebug`
- 결과: BUILD SUCCESSFUL (21s, 46 tasks)
- 변경 파일: `LiveRoomActivity.kt` (RTM/RTC 연결 완료 후 키보드 올렸다 내리기)
- 비고: 키보드가 화면을 위로 밀어올리는 것이 사용자에게 보임 → 눈속임 개선 필요
### 2026-03-29 빌드 검증 (3차 - 눈속임 개선)
- 명령: `./gradlew :app:assembleDebug`
- 결과: BUILD SUCCESSFUL (18s, 46 tasks)
- 변경 파일: `LiveRoomActivity.kt`
- 방식: adjustNothing 임시 전환 + 로딩 다이얼로그 뒤에서 키보드 트릭 수행