Compare commits
12 Commits
a893d85632
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a6e3f71ca5 | |||
| c78849e994 | |||
| 5609560b77 | |||
| 4bb91c605a | |||
| 9dfad913bc | |||
| 4815cac49b | |||
| 25d549b06f | |||
| 0c0801561e | |||
| 0fcd929c6f | |||
| 6aa7b9e98c | |||
| 8c0690b1e5 | |||
| 08524bd79a |
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) }
|
||||||
|
|||||||
@@ -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>>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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>>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
docs/20260324_라이브룸캡처녹화정합개선.md
Normal file
39
docs/20260324_라이브룸캡처녹화정합개선.md
Normal 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.`
|
||||||
56
docs/20260324_라이브룸화면캡쳐녹화차단처리.md
Normal file
56
docs/20260324_라이브룸화면캡쳐녹화차단처리.md
Normal 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.`
|
||||||
22
docs/20260324_라이브상세sns아이콘변경.md
Normal file
22
docs/20260324_라이브상세sns아이콘변경.md
Normal 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` 발생.
|
||||||
284
docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md
Normal file
284
docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md
Normal 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는 디바이스 연결이 반복 해제되어 조건 기반 실제 탭 시나리오를 끝까지 수행하지 못했다.
|
||||||
35
docs/20260327_연령제한표시조건수정.md
Normal file
35
docs/20260327_연령제한표시조건수정.md
Normal 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`
|
||||||
36
docs/20260327_콘텐츠보기설정파라미터전송정리.md
Normal file
36
docs/20260327_콘텐츠보기설정파라미터전송정리.md
Normal 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`).
|
||||||
43
docs/20260328_라이브룸방장캡쳐녹화허용.md
Normal file
43
docs/20260328_라이브룸방장캡쳐녹화허용.md
Normal 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`
|
||||||
42
docs/20260328_라이브룸캡쳐녹화차단점검.md
Normal file
42
docs/20260328_라이브룸캡쳐녹화차단점검.md
Normal 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% 차단을 플랫폼이 절대 보장한다고 단정할 수는 없다.
|
||||||
29
docs/20260328_마이페이지본인인증버튼숨김정렬유지.md
Normal file
29
docs/20260328_마이페이지본인인증버튼숨김정렬유지.md
Normal 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`로 완료됐다.
|
||||||
49
docs/20260328_멤버정보응답하위호환수정.md
Normal file
49
docs/20260328_멤버정보응답하위호환수정.md
Normal 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건(구서버 누락 필드 역직렬화/신규 필드 정상 매핑)이 모두 통과했다.
|
||||||
|
- 전체 단위 테스트와 디버그 빌드가 모두 성공했고, 마지막 재검증 명령에서도 성공을 재확인했다.
|
||||||
57
docs/20260329_라이브룸_UI미갱신_버그수정.md
Normal file
57
docs/20260329_라이브룸_UI미갱신_버그수정.md
Normal 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 임시 전환 + 로딩 다이얼로그 뒤에서 키보드 트릭 수행
|
||||||
Reference in New Issue
Block a user