Compare commits

12 Commits

56 changed files with 1670 additions and 319 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 231 versionCode 234
versionName "1.53.0" 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,19 +1374,31 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
SodaDialog( val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
activity = requireActivity(), if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
layoutInflater = layoutInflater, SodaDialog(
title = getString(R.string.auth_title), activity = requireActivity(),
desc = getString(R.string.auth_desc_live), layoutInflater = layoutInflater,
confirmButtonTitle = getString(R.string.auth_go), title = getString(R.string.auth_title),
confirmButtonClick = { startAuthFlow() }, desc = getString(R.string.auth_desc_live),
cancelButtonTitle = getString(R.string.cancel), confirmButtonTitle = getString(R.string.auth_go),
cancelButtonClick = {}, confirmButtonClick = { startAuthFlow() },
descGravity = Gravity.CENTER cancelButtonTitle = getString(R.string.cancel),
).show(screenWidth) cancelButtonClick = {},
return descGravity = Gravity.CENTER
).show(screenWidth)
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

@@ -30,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
@@ -55,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
@@ -250,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,19 +863,31 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
SodaDialog( val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
activity = requireActivity(), if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
layoutInflater = layoutInflater, SodaDialog(
title = getString(R.string.auth_title), activity = requireActivity(),
desc = getString(R.string.auth_desc_live), layoutInflater = layoutInflater,
confirmButtonTitle = getString(R.string.auth_go), title = getString(R.string.auth_title),
confirmButtonClick = { startAuthFlow() }, desc = getString(R.string.auth_desc_live),
cancelButtonTitle = getString(R.string.cancel), confirmButtonTitle = getString(R.string.auth_go),
cancelButtonClick = {}, confirmButtonClick = { startAuthFlow() },
descGravity = Gravity.CENTER cancelButtonTitle = getString(R.string.cancel),
).show(screenWidth) cancelButtonClick = {},
return descGravity = Gravity.CENTER
).show(screenWidth)
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,7 +3,6 @@ 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
@@ -20,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
@@ -43,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
@@ -287,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,19 +120,31 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
SodaDialog( val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
activity = this, if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
layoutInflater = layoutInflater, SodaDialog(
title = getString(R.string.auth_title), activity = this,
desc = getString(R.string.auth_desc_live), layoutInflater = layoutInflater,
confirmButtonTitle = getString(R.string.auth_go), title = getString(R.string.auth_title),
confirmButtonClick = { startAuthFlow() }, desc = getString(R.string.auth_desc_live),
cancelButtonTitle = getString(R.string.cancel), confirmButtonTitle = getString(R.string.auth_go),
cancelButtonClick = {}, confirmButtonClick = { startAuthFlow() },
descGravity = Gravity.CENTER cancelButtonTitle = getString(R.string.cancel),
).show(screenWidth) cancelButtonClick = {},
return descGravity = Gravity.CENTER
).show(screenWidth)
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
@@ -120,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
@@ -165,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
@@ -175,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
@@ -352,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
@@ -370,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)
@@ -395,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)
} }
@@ -415,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) {
@@ -641,21 +672,9 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
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, _ ->
@@ -716,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
@@ -1039,17 +1093,35 @@ 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
SodaDialog(
this@LiveRoomActivity, if (shouldBlockBySensitiveContent) {
layoutInflater, showToast(getString(R.string.screen_content_settings_sensitive_content_guide))
getString(R.string.screen_live_room_age_limit_title), startActivity(
getString(R.string.screen_live_room_age_limit_message), Intent(applicationContext, ContentSettingsActivity::class.java).apply {
getString(R.string.screen_live_room_ok), putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
{ finish() } }
).show(screenWidth) )
finish()
} else {
SodaDialog(
this@LiveRoomActivity,
layoutInflater,
getString(R.string.screen_live_room_age_limit_title),
getString(R.string.screen_live_room_age_limit_message),
getString(R.string.screen_live_room_ok),
{ finish() }
).show(screenWidth)
}
} }
} }
@@ -1223,6 +1295,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
isHost = response.creatorId == SharedPreferenceManager.userId isHost = response.creatorId == SharedPreferenceManager.userId
syncCaptureSecurityPolicyByRole()
binding.tvChatFreezeSwitch.visibility = if (isHost) { binding.tvChatFreezeSwitch.visibility = if (isHost) {
View.VISIBLE View.VISIBLE
} else { } else {
@@ -1518,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
@@ -1547,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)
} }
@@ -1562,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(),
@@ -1803,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")
@@ -2013,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?) {
@@ -2659,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) {

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
} }

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

@@ -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) {
if (it.isAuth) { val isKoreanUser = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verified)
)
if (isKoreanUser) {
binding.btnIdentityVerification.root.visibility = View.VISIBLE
if (it.isAuth) {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
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(
override fun handleOnBackPressed() { this,
handleFinish() object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
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 {
viewModel.toggleAdultContentVisible() val isAdultContentVisible = viewModel.isAdultContentVisible.value == true
if (isAdultContentVisible) {
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
} else {
currentState.contentType
}
)
if (adultContentVisible) { applyLocalState(nextState)
SharedPreferenceManager.contentPreference = ContentType.ALL.ordinal 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)
} }

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

@@ -994,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

@@ -994,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

@@ -993,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,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 임시 전환 + 로딩 다이얼로그 뒤에서 키보드 트릭 수행